17 Commits

Author SHA1 Message Date
Felipe Coutinho
20d0c3e0a7 chore(docs): atualizar regra de versionamento no CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:56 +00:00
Felipe Coutinho
71b5a004e3 chore: ajustes de formatação e configuração
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:27 +00:00
Felipe Coutinho
65b1506d75 chore(release): publicar versão 2.1.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:23 +00:00
Felipe Coutinho
2a458d5a3c chore(configurações): redesign visual da página de configurações
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:19 +00:00
Felipe Coutinho
f418987f47 feat(lançamentos): escopo "period" na ação em lote e correção do fluxo de anexos em séries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:46:33 +00:00
Felipe Coutinho
59b4dea071 feat(preferências): configuração de tamanho máximo de anexo por arquivo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:46:28 +00:00
Felipe Coutinho
6ce132fe0c feat(db): adicionar coluna attachmentMaxSizeMb em userPreferences
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:45:41 +00:00
Felipe Coutinho
49731238e4 Update version badge from 2.1.0 to 2.1.1 2026-03-29 11:14:23 -03:00
Felipe Coutinho
c5df97f7aa chore(setup): reduzir logo e colocar nome ASCII lado a lado
Logo reduzido de 19 para 10 linhas (seleção dos frames-chave).
Nome do projeto em ASCII art posicionado ao lado direito do logo,
centralizado verticalmente. Tagline abaixo do bloco.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:05:53 +00:00
Felipe Coutinho
3476fda4db chore(setup): adicionar banner ASCII do logo e corrigir script db:extensions
Substitui o header simples pelo logo em ASCII art na cor primária
(laranja) com nome e tagline centralizados. Corrige chamada
db:enableExtensions → db:extensions após renomeio do script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:55:26 +00:00
Felipe Coutinho
519b673ae5 chore(release): publicar versão 2.1.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:21 +00:00
Felipe Coutinho
303b8bedd4 chore(config): limpeza de tsconfig.json e .vscode/settings.json
Reformata arrays no tsconfig para multi-line. Remove configurações
obsoletas do .vscode (explorerExclude.backup, eslint.enable,
typescript.preferences.organizeImportsCollation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:17 +00:00
Felipe Coutinho
f2b9b16896 chore(package): renomear scripts e remover dependências Vercel
Renomeia mockup→db:seed, db:enableExtensions→db:extensions e remove
o script dev-env. Remove @vercel/analytics e @vercel/speed-insights.
Atualiza README com o novo nome do script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:13 +00:00
Felipe Coutinho
6eba35542b chore(logo): remover prop showVersion e atualizar logo_small.png
Remove a prop showVersion do componente Logo e seu uso na sidebar.
Aplica iconFilterClass também no variant compact. Atualiza a imagem
logo_small.png.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:10 +00:00
Felipe Coutinho
f5e95ffba6 chore(analytics): substituir Vercel Analytics por Umami self-hosted
Remove @vercel/analytics e @vercel/speed-insights e adiciona o script
do Umami self-hosted no layout raiz, restrito ao domínio openmonetis.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:03 +00:00
Felipe Coutinho
a75bb86eec refactor(navbar): extrair NavbarShell e adicionar variante navbar no Button
Unifica a estrutura da navbar entre o app e a landing page via novo
componente NavbarShell. Centraliza estilos de botões da navbar na
variante `navbar` do Button, eliminando nav-styles.ts e as classes
inline duplicadas. AnimatedThemeToggler, RefreshPageButton e MobileNav
passam a aceitar prop `variant` para adaptar ao contexto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:43:59 +00:00
Felipe Coutinho
a3b858621f fix(transactions): preservar período salvo ao editar lançamento de cartão
No modal de edição, o período não era recalculado com base no fechamento
do cartão, garantindo que o valor salvo no banco seja sempre exibido.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:51:43 +00:00
54 changed files with 4033 additions and 744 deletions

View File

@@ -12,7 +12,6 @@
"**/.next": true, "**/.next": true,
".next": true ".next": true
}, },
"explorerExclude.backup": {},
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
@@ -25,9 +24,7 @@
"[json]": { "[json]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"eslint.enable": false,
"prettier.enable": false, "prettier.enable": false,
"typescript.preferences.organizeImportsCollation": "ordinal",
"editor.fontSize": 15, "editor.fontSize": 15,
"[jsonc]": { "[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features" "editor.defaultFormatter": "vscode.json-language-features"

View File

@@ -7,6 +7,43 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [Unreleased]
## [2.1.2] - 2026-03-30
### Adicionado
- Preferências: nova configuração de tamanho máximo por arquivo de anexo (5, 10, 25, 50 ou 100 MB), persistida no banco e respeitada em todos os pontos de upload
- Lançamentos: novo escopo `"period"` na ação em lote, que aplica a alteração a todos os lançamentos do período sem sobrescrever o pagador individual de cada um
### Corrigido
- Lançamentos: ao editar um lançamento de série, uploads e remoções de anexo agora aguardam a escolha de escopo da ação em lote antes de serem executados, evitando que o anexo fosse aplicado no lançamento errado
- Lançamentos: ação em lote com escopo `"period"` não sobrescreve mais o `payerId` individual de cada lançamento ao alterar o pagador
### Alterado
- Configurações: redesign visual da página com separadores entre seções e títulos maiores
- Configurações: seção "Extrato e lançamentos" renomeada para "Lançamentos"
## [2.1.1] - 2026-03-29
### Adicionado
- Navbar: novo componente `NavbarShell` que unifica a estrutura da barra de navegação entre o app e a landing page
- UI: nova variante `navbar` no componente `Button`, centralizando os estilos de botões usados dentro da navbar
- Analytics: integração com Umami self-hosted via script tag no layout raiz
### Alterado
- Navbar: `AnimatedThemeToggler` e `RefreshPageButton` passam a aceitar prop `variant` para adaptar estilos ao contexto (navbar ou sidebar)
- Navbar: estilos inline duplicados de `nav-styles.ts` migrados para a variante `navbar` do Button
- Logo: prop `showVersion` removida; prop `colorIcon` passa a aplicar filtro de cor também no variant `compact`
- Scripts: `mockup` renomeado para `db:seed`; `db:enableExtensions` renomeado para `db:extensions`; script `dev-env` removido
- Landing: `MobileNav` simplificado com a remoção da prop `triggerClassName`
### Removido
- Navbar: arquivo `nav-styles.ts` removido após migração dos estilos para a variante `navbar`
- Dependências: `@vercel/analytics` e `@vercel/speed-insights` removidos (substituídos pelo Umami self-hosted)
## [2.1.0] - 2026-03-28 ## [2.1.0] - 2026-03-28
### Adicionado ### Adicionado

View File

@@ -16,7 +16,7 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`. 3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`. 4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations. 5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog. 6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json`.
7. **Comunicacao**: responder em portugues clara e direta com o time. 7. **Comunicacao**: responder em portugues clara e direta com o time.
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema. 8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. > **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.1.0-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.1.1-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -156,7 +156,7 @@ O script irá:
```bash ```bash
docker compose up db -d docker compose up db -d
pnpm db:enableExtensions pnpm db:extensions
``` ```
4. **Execute as migrations e inicie** 4. **Execute as migrations e inicie**

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN "attachment_max_size_mb" integer DEFAULT 50 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1,174 +1,181 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1762993507299, "when": 1762993507299,
"tag": "0000_flashy_manta", "tag": "0000_flashy_manta",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "7",
"when": 1765199006435, "when": 1765199006435,
"tag": "0001_young_mister_fear", "tag": "0001_young_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "7", "version": "7",
"when": 1765200545692, "when": 1765200545692,
"tag": "0002_slimy_flatman", "tag": "0002_slimy_flatman",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "7", "version": "7",
"when": 1767102605526, "when": 1767102605526,
"tag": "0003_green_korg", "tag": "0003_green_korg",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 4, "idx": 4,
"version": "7", "version": "7",
"when": 1767104066872, "when": 1767104066872,
"tag": "0004_acoustic_mach_iv", "tag": "0004_acoustic_mach_iv",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 5, "idx": 5,
"version": "7", "version": "7",
"when": 1767106121811, "when": 1767106121811,
"tag": "0005_adorable_bruce_banner", "tag": "0005_adorable_bruce_banner",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 6, "idx": 6,
"version": "7", "version": "7",
"when": 1767107487318, "when": 1767107487318,
"tag": "0006_youthful_mister_fear", "tag": "0006_youthful_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 7, "idx": 7,
"version": "7", "version": "7",
"when": 1767118780033, "when": 1767118780033,
"tag": "0007_sturdy_kate_bishop", "tag": "0007_sturdy_kate_bishop",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "7", "version": "7",
"when": 1767125796314, "when": 1767125796314,
"tag": "0008_fat_stick", "tag": "0008_fat_stick",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 9, "idx": 9,
"version": "7", "version": "7",
"when": 1768925100873, "when": 1768925100873,
"tag": "0009_add_dashboard_widgets", "tag": "0009_add_dashboard_widgets",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 10, "idx": 10,
"version": "7", "version": "7",
"when": 1769369834242, "when": 1769369834242,
"tag": "0010_lame_psynapse", "tag": "0010_lame_psynapse",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 11, "idx": 11,
"version": "7", "version": "7",
"when": 1769447087678, "when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns", "tag": "0011_remove_unused_inbox_columns",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 12, "idx": 12,
"version": "7", "version": "7",
"when": 1769533200000, "when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese", "tag": "0012_rename_tables_to_portuguese",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 13, "idx": 13,
"version": "7", "version": "7",
"when": 1769523352777, "when": 1769523352777,
"tag": "0013_fancy_rick_jones", "tag": "0013_fancy_rick_jones",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 14, "idx": 14,
"version": "7", "version": "7",
"when": 1769619226903, "when": 1769619226903,
"tag": "0014_yielding_jack_flag", "tag": "0014_yielding_jack_flag",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 15, "idx": 15,
"version": "7", "version": "7",
"when": 1770332054481, "when": 1770332054481,
"tag": "0015_concerned_kat_farrell", "tag": "0015_concerned_kat_farrell",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 16, "idx": 16,
"version": "7", "version": "7",
"when": 1771166328908, "when": 1771166328908,
"tag": "0016_complete_randall", "tag": "0016_complete_randall",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 17, "idx": 17,
"version": "7", "version": "7",
"when": 1772400510326, "when": 1772400510326,
"tag": "0017_previous_warstar", "tag": "0017_previous_warstar",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 18, "idx": 18,
"version": "7", "version": "7",
"when": 1773020417482, "when": 1773020417482,
"tag": "0018_rainy_epoch", "tag": "0018_rainy_epoch",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 19, "idx": 19,
"version": "7", "version": "7",
"when": 1773699152928, "when": 1773699152928,
"tag": "0019_ordinary_wild_pack", "tag": "0019_ordinary_wild_pack",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 20, "idx": 20,
"version": "7", "version": "7",
"when": 1773841892114, "when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints", "tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 21, "idx": 21,
"version": "7", "version": "7",
"when": 1774033320053, "when": 1774033320053,
"tag": "0021_careful_malcolm_colcord", "tag": "0021_careful_malcolm_colcord",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 22, "idx": 22,
"version": "7", "version": "7",
"when": 1748000000000, "when": 1748000000000,
"tag": "0022_import-category-mappings", "tag": "0022_import-category-mappings",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 23, "idx": 23,
"version": "7", "version": "7",
"when": 1774529878374, "when": 1774529878374,
"tag": "0023_sturdy_wolfpack", "tag": "0023_sturdy_wolfpack",
"breakpoints": true "breakpoints": true
} },
] {
"idx": 24,
"version": "7",
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
}
]
} }

View File

@@ -1,11 +1,10 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.1.0", "version": "2.1.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"dev-env": "tsx scripts/dev.ts", "db:seed": "tsx scripts/mock-data.ts",
"mockup": "tsx scripts/mock-data.ts",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "biome check .", "lint": "biome check .",
@@ -14,7 +13,7 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:enableExtensions": "tsx scripts/postgres/enable-extensions.ts", "db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"docker:up": "docker compose up --build", "docker:up": "docker compose up --build",
"docker:up:db": "docker compose up -d db", "docker:up:db": "docker compose up -d db",
@@ -61,8 +60,6 @@
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.23",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"ai": "^6.0.141", "ai": "^6.0.141",
"better-auth": "1.5.6", "better-auth": "1.5.6",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",

71
pnpm-lock.yaml generated
View File

@@ -104,12 +104,6 @@ importers:
'@tanstack/react-virtual': '@tanstack/react-virtual':
specifier: ^3.13.23 specifier: ^3.13.23
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
'@vercel/speed-insights':
specifier: ^2.0.0
version: 2.0.0(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
ai: ai:
specifier: ^6.0.141 specifier: ^6.0.141
version: 6.0.141(zod@4.3.6) version: 6.0.141(zod@4.3.6)
@@ -2666,65 +2660,10 @@ packages:
'@types/whatwg-url@13.0.0': '@types/whatwg-url@13.0.0':
resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==} resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==}
'@vercel/analytics@2.0.1':
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
peerDependencies:
'@remix-run/react': ^2
'@sveltejs/kit': ^1 || ^2
next: '>= 13'
nuxt: '>= 3'
react: ^18 || ^19 || ^19.0.0-rc
svelte: '>= 4'
vue: ^3
vue-router: ^4
peerDependenciesMeta:
'@remix-run/react':
optional: true
'@sveltejs/kit':
optional: true
next:
optional: true
nuxt:
optional: true
react:
optional: true
svelte:
optional: true
vue:
optional: true
vue-router:
optional: true
'@vercel/oidc@3.1.0': '@vercel/oidc@3.1.0':
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
'@vercel/speed-insights@2.0.0':
resolution: {integrity: sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==}
peerDependencies:
'@sveltejs/kit': ^1 || ^2
next: '>= 13'
nuxt: '>= 3'
react: ^18 || ^19 || ^19.0.0-rc
svelte: '>= 4'
vue: ^3
vue-router: ^4
peerDependenciesMeta:
'@sveltejs/kit':
optional: true
next:
optional: true
nuxt:
optional: true
react:
optional: true
svelte:
optional: true
vue:
optional: true
vue-router:
optional: true
adler-32@1.3.1: adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@@ -6557,18 +6496,8 @@ snapshots:
'@types/webidl-conversions': 7.0.3 '@types/webidl-conversions': 7.0.3
optional: true optional: true
'@vercel/analytics@2.0.1(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
optionalDependencies:
next: 16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
'@vercel/oidc@3.1.0': {} '@vercel/oidc@3.1.0': {}
'@vercel/speed-insights@2.0.0(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
optionalDependencies:
next: 16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
adler-32@1.3.1: {} adler-32@1.3.1: {}
ai@6.0.141(zod@4.3.6): ai@6.0.141(zod@4.3.6):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -21,6 +21,7 @@ const c = {
red: "\x1b[31m", red: "\x1b[31m",
yellow: "\x1b[33m", yellow: "\x1b[33m",
cyan: "\x1b[36m", cyan: "\x1b[36m",
orange: "\x1b[38;5;214m",
}; };
const sym = { const sym = {
@@ -81,10 +82,38 @@ function abort(msg) {
// ─── Header ────────────────────────────────────────────────────────────────── // ─── Header ──────────────────────────────────────────────────────────────────
console.log(` const logoLines = [
${c.bold}${c.cyan} OpenMonetis — Setup${c.reset} ".............................+@@@@@@@@@@=.............................",
${c.dim}Gestão financeira self-hosted${c.reset} ".............................@@@@@@@@@@@:.............................",
`); "...................+@@@@@@*-:@@@@@@@@@@%...=@@@@@@-...................",
"..................@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%..................",
"................=@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+................",
"...................-=+%@@@@@@@@@@@@@@@@@@@@@*:........................",
".......................#@@@@@@@@@@@@@@@@@@@@@@@+......................",
"....................%@@@@@@@@@@@@@%#@@@@@@@@@@@@*.....................",
"....................+@@@@@@@@@@@......*@@@@@@#........................",
".........................:#@@=...........+#...........................",
];
const nameLines = [
" ___ __ __ _ _ ",
" / _ \\ _ __ ___ _ __ | \\/ | ___ _ __ ___| |_(_)___ ",
" | | | | '_ \\ / _ \\ '_ \\| |\\/| |/ _ \\| '_ \\ / _ \\ __| / __|",
" | |_| | |_) | __/ | | | | | | (_) | | | | __/ |_| \\__ \\",
" \\___/| .__/ \\___|_| |_|_| |_|\\___/|_| |_|\\___|\\__|_|___/",
" |_| ",
];
const nameStart = Math.floor((logoLines.length - nameLines.length) / 2);
console.log();
for (let i = 0; i < logoLines.length; i++) {
const logoCol = c.orange + logoLines[i].replaceAll(".", " ").substring(14, 56).padEnd(42) + c.reset;
const nameIdx = i - nameStart;
const nameCol = nameIdx >= 0 && nameIdx < nameLines.length ? nameLines[nameIdx] : "";
console.log(logoCol + " " + nameCol);
}
console.log(`\n${" ".repeat(46)}${c.dim}Gestão financeira · self-hosted${c.reset}\n`);
// ─── ETAPA 1: Verificações do sistema ──────────────────────────────────────── // ─── ETAPA 1: Verificações do sistema ────────────────────────────────────────
@@ -329,7 +358,7 @@ if (useLocalDocker) {
// Extensões // Extensões
s = spinner("Habilitando extensões do banco..."); s = spinner("Habilitando extensões do banco...");
try { try {
run("pnpm db:enableExtensions", { cwd: targetDir }); run("pnpm db:extensions", { cwd: targetDir });
s.stop("Extensões habilitadas"); s.stop("Extensões habilitadas");
} catch { } catch {
s.fail("Falha ao habilitar extensões"); s.fail("Falha ao habilitar extensões");

View File

@@ -190,6 +190,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={false} allowCreate={false}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</section> </section>
</main> </main>

View File

@@ -202,6 +202,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate allowCreate
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
defaultCardId={card.id} defaultCardId={card.id}
defaultPaymentMethod="Cartão de crédito" defaultPaymentMethod="Cartão de crédito"
lockCardSelection lockCardSelection

View File

@@ -99,6 +99,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={true} allowCreate={true}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</main> </main>
); );

View File

@@ -390,6 +390,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={canEdit} allowCreate={canEdit}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
importPayerOptions={loggedUserOptionSets?.payerOptions} importPayerOptions={loggedUserOptionSets?.payerOptions}
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions} importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId} importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}

View File

@@ -1,4 +1,4 @@
import { RiArrowRightSLine } from "@remixicon/react"; import { RiAndroidLine, RiArrowRightSLine } from "@remixicon/react";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -11,6 +11,7 @@ import { UpdateNameForm } from "@/features/settings/components/update-name-form"
import { UpdatePasswordForm } from "@/features/settings/components/update-password-form"; import { UpdatePasswordForm } from "@/features/settings/components/update-password-form";
import { fetchSettingsPageData } from "@/features/settings/queries"; import { fetchSettingsPageData } from "@/features/settings/queries";
import { Card } from "@/shared/components/ui/card"; import { Card } from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator";
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
@@ -64,12 +65,13 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Preferências</h2> <h2 className="text-xl font-bold mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Personalize sua experiência no OpenMonetis ajustando as Personalize sua experiência no OpenMonetis ajustando as
configurações de acordo com suas necessidades. configurações de acordo com suas necessidades.
</p> </p>
</div> </div>
<Separator />
<PreferencesForm <PreferencesForm
statementNoteAsColumn={ statementNoteAsColumn={
userPreferences?.statementNoteAsColumn ?? false userPreferences?.statementNoteAsColumn ?? false
@@ -77,25 +79,46 @@ export default async function Page() {
transactionsColumnOrder={ transactionsColumnOrder={
userPreferences?.transactionsColumnOrder ?? null userPreferences?.transactionsColumnOrder ?? null
} }
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</div> </div>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="companion" className="mt-4"> <TabsContent value="companion" className="mt-4">
<CompanionTab tokens={userApiTokens} /> <Card className="p-6">
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<h2 className="text-xl font-bold">OpenMonetis Companion</h2>
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
<RiAndroidLine className="h-3 w-3" />
Android
</span>
</div>
<p className="text-sm text-muted-foreground">
Capture notificações de transações dos seus apps de banco
(Nubank, Itaú, Bradesco, Inter, C6 e outros) e envie para sua
caixa de entrada.
</p>
</div>
<Separator />
<CompanionTab tokens={userApiTokens} />
</div>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="nome" className="mt-4"> <TabsContent value="nome" className="mt-4">
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Alterar nome</h2> <h2 className="text-xl font-bold mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Atualize como seu nome aparece no OpenMonetis. Esse nome pode Atualize como seu nome aparece no OpenMonetis. Esse nome pode
ser exibido em diferentes seções do app e em comunicações. ser exibido em diferentes seções do app e em comunicações.
</p> </p>
</div> </div>
<Separator />
<UpdateNameForm currentName={userName} /> <UpdateNameForm currentName={userName} />
</div> </div>
</Card> </Card>
@@ -105,12 +128,13 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Alterar senha</h2> <h2 className="text-xl font-bold mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Defina uma nova senha para sua conta. Guarde-a em local Defina uma nova senha para sua conta. Guarde-a em local
seguro. seguro.
</p> </p>
</div> </div>
<Separator />
<UpdatePasswordForm authProvider={authProvider} /> <UpdatePasswordForm authProvider={authProvider} />
</div> </div>
</Card> </Card>
@@ -120,12 +144,13 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Passkeys</h2> <h2 className="text-xl font-bold mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Passkeys permitem login sem senha, usando biometria (Face ID, Passkeys permitem login sem senha, usando biometria (Face ID,
Touch ID, Windows Hello) ou chaves de segurança. Touch ID, Windows Hello) ou chaves de segurança.
</p> </p>
</div> </div>
<Separator />
<PasskeysForm /> <PasskeysForm />
</div> </div>
</Card> </Card>
@@ -135,13 +160,14 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2> <h2 className="text-xl font-bold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Atualize o e-mail associado à sua conta. Você precisará Atualize o e-mail associado à sua conta. Você precisará
confirmar os links enviados para o novo e também para o e-mail confirmar os links enviados para o novo e também para o e-mail
atual (quando aplicável) para concluir a alteração. atual (quando aplicável) para concluir a alteração.
</p> </p>
</div> </div>
<Separator />
<UpdateEmailForm <UpdateEmailForm
currentEmail={userEmail} currentEmail={userEmail}
authProvider={authProvider} authProvider={authProvider}
@@ -154,14 +180,15 @@ export default async function Page() {
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1 text-destructive"> <h2 className="text-xl font-bold mb-1 text-destructive">
Ações perigosas Ações perigosas
</h2> </h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
Você pode zerar os dados do OpenMonetis e manter seu acesso, Você pode zerar os dados do OpenMonetis e manter seu acesso,
ou excluir sua conta inteira de forma irreversível. ou excluir sua conta inteira de forma irreversível.
</p> </p>
</div> </div>
<Separator />
<DeleteAccountForm /> <DeleteAccountForm />
</div> </div>
</Card> </Card>

View File

@@ -102,6 +102,7 @@ export default async function Page({ searchParams }: PageProps) {
}} }}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</main> </main>
); );

View File

@@ -17,7 +17,6 @@ import {
extraFeatures, extraFeatures,
getMetricsItems, getMetricsItems,
mainFeatures, mainFeatures,
navbarActionClassName,
navLinks, navLinks,
pwaHighlights, pwaHighlights,
stackItems, stackItems,
@@ -27,6 +26,7 @@ import { landingImages } from "@/features/landing/images";
import { fetchGitHubStats } from "@/features/landing/queries"; import { fetchGitHubStats } from "@/features/landing/queries";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler"; import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import { Logo } from "@/shared/components/logo"; import { Logo } from "@/shared/components/logo";
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
@@ -50,65 +50,60 @@ export default async function Page() {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
{/* Navigation */} {/* Navigation */}
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center bg-primary"> <NavbarShell>
<div className="relative z-10 max-w-8xl mx-auto px-4 w-full flex h-full items-center justify-between"> {/* Center Navigation Links */}
<Logo variant="compact" invertTextOnDark={false} /> <nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
{navLinks.map(({ href, label }) => (
<a
key={href}
href={href}
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors"
>
{label}
</a>
))}
</nav>
{/* Center Navigation Links */} <nav className="ml-auto flex items-center gap-2 md:gap-3">
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2"> <AnimatedThemeToggler variant="navbar" />
{navLinks.map(({ href, label }) => ( {!isPublicDomain &&
<a (session?.user ? (
key={href} <Link prefetch href="/dashboard" className="hidden md:block">
href={href} <Button
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors" variant="outline"
> size="sm"
{label} className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
</a> >
))} Dashboard
</nav> </Button>
</Link>
<nav className="flex items-center gap-2 md:gap-3"> ) : (
<AnimatedThemeToggler className={navbarActionClassName} /> <div className="hidden md:flex items-center gap-2">
{!isPublicDomain && <Link href="/login">
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none" className="text-black/75 hover:bg-black/10 hover:text-black shadow-none"
> >
Dashboard Entrar
</Button> </Button>
</Link> </Link>
) : ( <Link href="/signup">
<div className="hidden md:flex items-center gap-2"> <Button
<Link href="/login"> size="sm"
<Button className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2"
variant="ghost" >
size="sm" Começar
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none" </Button>
> </Link>
Entrar </div>
</Button> ))}
</Link> <MobileNav
<Link href="/signup"> isPublicDomain={isPublicDomain}
<Button isLoggedIn={!!session?.user}
size="sm" />
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2" </nav>
> </NavbarShell>
Começar
</Button>
</Link>
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
isLoggedIn={!!session?.user}
triggerClassName="border border-black/10 text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20"
/>
</nav>
</div>
</header>
{/* Hero Section */} {/* Hero Section */}
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0"> <section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">

View File

@@ -1,5 +1,3 @@
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ThemeProvider } from "@/shared/components/providers/theme-provider"; import { ThemeProvider } from "@/shared/components/providers/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner"; import { Toaster } from "@/shared/components/ui/sonner";
@@ -28,14 +26,18 @@ export default function RootLayout({
> >
<head> <head>
<meta name="apple-mobile-web-app-title" content="OpenMonetis" /> <meta name="apple-mobile-web-app-title" content="OpenMonetis" />
<script
defer
src="https://umami.felipecoutinho.com/script.js"
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
data-domains="openmonetis.com"
/>
</head> </head>
<body className="subpixel-antialiased" suppressHydrationWarning> <body className="subpixel-antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light"> <ThemeProvider attribute="class" defaultTheme="light">
{children} {children}
<Toaster position="top-right" /> <Toaster position="top-right" />
</ThemeProvider> </ThemeProvider>
<Analytics />
<SpeedInsights />
</body> </body>
</html> </html>
); );

View File

@@ -135,6 +135,7 @@ export const userPreferences = pgTable("preferencias_usuario", {
transactionsColumnOrder: jsonb("lancamentos_column_order").$type< transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
string[] | null string[] | null
>(), >(),
attachmentMaxSizeMb: integer("attachment_max_size_mb").notNull().default(50),
dashboardWidgets: jsonb("dashboard_widgets").$type<{ dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[]; order: string[];
hidden: string[]; hidden: string[];

View File

@@ -125,7 +125,9 @@ export function LoginForm({ className, ...props }: DivProps) {
}); });
if (passkeyError) { if (passkeyError) {
setError((passkeyError.message as string) || "Erro ao entrar com passkey."); setError(
(passkeyError.message as string) || "Erro ao entrar com passkey.",
);
setLoadingPasskey(false); setLoadingPasskey(false);
} }
} }

View File

@@ -24,24 +24,18 @@ const navLinks = [
interface MobileNavProps { interface MobileNavProps {
isPublicDomain: boolean; isPublicDomain: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
triggerClassName?: string;
} }
export function MobileNav({ export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
isPublicDomain,
isLoggedIn,
triggerClassName,
}: MobileNavProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<div className="md:hidden"> <div className="md:hidden">
<Button <Button
variant="ghost" variant="navbar"
size="icon" size="icon-sm"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label="Abrir menu" aria-label="Abrir menu"
className={triggerClassName}
> >
<RiMenuLine className="size-5" /> <RiMenuLine className="size-5" />
</Button> </Button>

View File

@@ -35,9 +35,6 @@ export type FeatureItem = {
colorVar: string; colorVar: string;
}; };
export const navbarActionClassName =
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
export const navLinks = [ export const navLinks = [
{ href: "#telas", label: "conheça as telas" }, { href: "#telas", label: "conheça as telas" },
{ href: "#funcionalidades", label: "funcionalidades" }, { href: "#funcionalidades", label: "funcionalidades" },

View File

@@ -41,7 +41,7 @@ const baseSchema = z.object({
.string() .string()
.trim() .trim()
.email("Informe um e-mail válido.") .email("Informe um e-mail válido.")
.optional() .nullish()
.transform((value) => normalizeOptionalString(value)), .transform((value) => normalizeOptionalString(value)),
status: statusEnum, status: statusEnum,
note: noteSchema, note: noteSchema,

View File

@@ -65,6 +65,7 @@ const resetAccountSchema = z.object({
const updatePreferencesSchema = z.object({ const updatePreferencesSchema = z.object({
statementNoteAsColumn: z.boolean(), statementNoteAsColumn: z.boolean(),
transactionsColumnOrder: z.array(z.string()).nullable(), transactionsColumnOrder: z.array(z.string()).nullable(),
attachmentMaxSizeMb: z.number().int().min(1).max(100),
}); });
type ResettableUser = { type ResettableUser = {
@@ -561,6 +562,7 @@ export async function updatePreferencesAction(
.set({ .set({
statementNoteAsColumn: validated.statementNoteAsColumn, statementNoteAsColumn: validated.statementNoteAsColumn,
transactionsColumnOrder: validated.transactionsColumnOrder, transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(schema.userPreferences.userId, session.user.id)); .where(eq(schema.userPreferences.userId, session.user.id));
@@ -570,6 +572,7 @@ export async function updatePreferencesAction(
userId: session.user.id, userId: session.user.id,
statementNoteAsColumn: validated.statementNoteAsColumn, statementNoteAsColumn: validated.statementNoteAsColumn,
transactionsColumnOrder: validated.transactionsColumnOrder, transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
}); });
} }

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
RiAndroidLine,
RiDownload2Line, RiDownload2Line,
RiExternalLinkLine, RiExternalLinkLine,
RiNotification3Line, RiNotification3Line,
@@ -9,7 +8,6 @@ import {
RiShieldCheckLine, RiShieldCheckLine,
} from "@remixicon/react"; } from "@remixicon/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Card } from "@/shared/components/ui/card";
import { ApiTokensForm } from "./api-tokens-form"; import { ApiTokensForm } from "./api-tokens-form";
interface ApiToken { interface ApiToken {
@@ -69,49 +67,28 @@ const steps: {
export function CompanionTab({ tokens }: CompanionTabProps) { export function CompanionTab({ tokens }: CompanionTabProps) {
return ( return (
<Card className="p-6"> <div className="space-y-6">
<div className="space-y-6"> {/* Steps */}
{/* Header */} <div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
<div> {steps.map((step, index) => (
<div className="flex items-center gap-2 mb-1"> <div key={step.title} className="flex items-start gap-2">
<h2 className="text-lg font-bold">OpenMonetis Companion</h2> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10"> <step.icon className="h-4 w-4" />
<RiAndroidLine className="h-3 w-3" />
Android
</span>
</div>
<p className="text-sm text-muted-foreground">
Capture notificações de transações dos seus apps de banco (Nubank,
Itaú, Bradesco, Inter, C6 e outros) e envie para sua caixa de
entrada.
</p>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
{steps.map((step, index) => (
<div key={step.title} className="flex items-start gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<step.icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">
{index + 1}. {step.title}
</p>
<p className="text-xs text-muted-foreground">
{step.description}
</p>
</div>
</div> </div>
))} <div className="min-w-0">
</div> <p className="text-sm font-medium leading-tight">
{index + 1}. {step.title}
{/* Divider */} </p>
<div className="border-t" /> <p className="text-xs text-muted-foreground">
{step.description}
{/* Devices */} </p>
<ApiTokensForm tokens={tokens} /> </div>
</div>
))}
</div> </div>
</Card>
{/* Devices */}
<ApiTokensForm tokens={tokens} />
</div>
); );
} }

View File

@@ -73,7 +73,9 @@ export function PasskeysForm() {
const { data, error: fetchError } = const { data, error: fetchError } =
await authClient.passkey.listUserPasskeys(); await authClient.passkey.listUserPasskeys();
if (fetchError) { if (fetchError) {
setError((fetchError.message as string) || "Erro ao carregar passkeys."); setError(
(fetchError.message as string) || "Erro ao carregar passkeys.",
);
return; return;
} }
setPasskeys( setPasskeys(
@@ -134,7 +136,9 @@ export function PasskeysForm() {
name: editName.trim(), name: editName.trim(),
}); });
if (renameError) { if (renameError) {
setError((renameError.message as string) || "Erro ao renomear passkey."); setError(
(renameError.message as string) || "Erro ao renomear passkey.",
);
return; return;
} }
setEditingId(null); setEditingId(null);

View File

@@ -21,17 +21,27 @@ import { useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { updatePreferencesAction } from "@/features/settings/actions"; import { updatePreferencesAction } from "@/features/settings/actions";
import {
ATTACHMENT_SIZE_OPTIONS,
type AttachmentSizeOption,
} from "@/features/transactions/attachments-config";
import { import {
DEFAULT_LANCAMENTOS_COLUMN_ORDER, DEFAULT_LANCAMENTOS_COLUMN_ORDER,
LANCAMENTOS_COLUMN_LABELS, LANCAMENTOS_COLUMN_LABELS,
} from "@/features/transactions/column-order"; } from "@/features/transactions/column-order";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { Separator } from "@/shared/components/ui/separator";
import { Switch } from "@/shared/components/ui/switch"; import { Switch } from "@/shared/components/ui/switch";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/shared/components/ui/toggle-group";
interface PreferencesFormProps { interface PreferencesFormProps {
statementNoteAsColumn: boolean; statementNoteAsColumn: boolean;
transactionsColumnOrder: string[] | null; transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
} }
function SortableColumnItem({ id }: { id: string }) { function SortableColumnItem({ id }: { id: string }) {
@@ -74,6 +84,7 @@ function SortableColumnItem({ id }: { id: string }) {
export function PreferencesForm({ export function PreferencesForm({
statementNoteAsColumn: initialExtratoNoteAsColumn, statementNoteAsColumn: initialExtratoNoteAsColumn,
transactionsColumnOrder: initialColumnOrder, transactionsColumnOrder: initialColumnOrder,
attachmentMaxSizeMb: initialAttachmentMaxSizeMb,
}: PreferencesFormProps) { }: PreferencesFormProps) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -85,6 +96,14 @@ export function PreferencesForm({
? initialColumnOrder ? initialColumnOrder
: DEFAULT_LANCAMENTOS_COLUMN_ORDER, : DEFAULT_LANCAMENTOS_COLUMN_ORDER,
); );
const [attachmentMaxSizeMb, setAttachmentMaxSizeMb] =
useState<AttachmentSizeOption>(
(ATTACHMENT_SIZE_OPTIONS.includes(
initialAttachmentMaxSizeMb as AttachmentSizeOption,
)
? initialAttachmentMaxSizeMb
: 50) as AttachmentSizeOption,
);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -109,6 +128,7 @@ export function PreferencesForm({
const result = await updatePreferencesAction({ const result = await updatePreferencesAction({
statementNoteAsColumn, statementNoteAsColumn,
transactionsColumnOrder: columnOrder, transactionsColumnOrder: columnOrder,
attachmentMaxSizeMb,
}); });
if (result.success) { if (result.success) {
@@ -122,19 +142,18 @@ export function PreferencesForm({
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col gap-8"> <form onSubmit={handleSubmit} className="flex flex-col gap-8">
{/* Seção: Extrato / Lançamentos */} {/* Seção: Lançamentos */}
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<h3 className="text-base font-semibold">Extrato e lançamentos</h3> <h3 className="text-base font-semibold">Lançamentos</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Como exibir anotações e a ordem das colunas na tabela de Configurações de exibição da tabela de movimentações.
movimentações.
</p> </p>
</div> </div>
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md"> <section className="flex items-center justify-between max-w-md">
<div className="space-y-0.5"> <div className="space-y-2">
<Label htmlFor="extrato-note-column" className="text-base"> <Label htmlFor="extrato-note-column" className="text-sm">
Anotações em coluna Anotações em coluna
</Label> </Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -149,10 +168,12 @@ export function PreferencesForm({
onCheckedChange={setExtratoNoteAsColumn} onCheckedChange={setExtratoNoteAsColumn}
disabled={isPending} disabled={isPending}
/> />
</div> </section>
<div className="space-y-2 max-w-md"> <Separator />
<Label className="text-base">Ordem das colunas</Label>
<section className="space-y-2 max-w-md">
<Label className="text-sm">Ordem das colunas</Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Arraste os itens para definir a ordem em que as colunas aparecem na Arraste os itens para definir a ordem em que as colunas aparecem na
tabela do extrato e dos lançamentos. tabela do extrato e dos lançamentos.
@@ -173,7 +194,43 @@ export function PreferencesForm({
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</div> </section>
<Separator />
<section className="space-y-2">
<Label className="text-sm">Anexos</Label>
<p className="text-sm text-muted-foreground">
Configurações de upload de arquivos nos lançamentos.
</p>
<div className="space-y-2 max-w-md mt-4">
<Label>Tamanho máximo por arquivo</Label>
<p className="text-sm text-muted-foreground">
Limite aplicado ao upload de PDFs e imagens.
</p>
<ToggleGroup
type="single"
value={String(attachmentMaxSizeMb)}
onValueChange={(val) => {
if (val)
setAttachmentMaxSizeMb(Number(val) as AttachmentSizeOption);
}}
className="flex flex-wrap gap-2 justify-start"
>
{ATTACHMENT_SIZE_OPTIONS.map((size) => (
<ToggleGroupItem
key={size}
value={String(size)}
aria-label={`${size} MB`}
className="min-w-14"
>
{size} MB
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
</section>
</section> </section>
<div className="flex justify-end"> <div className="flex justify-end">

View File

@@ -5,6 +5,7 @@ import { db, schema } from "@/shared/lib/db";
export interface UserPreferences { export interface UserPreferences {
statementNoteAsColumn: boolean; statementNoteAsColumn: boolean;
transactionsColumnOrder: string[] | null; transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
} }
export interface ApiToken { export interface ApiToken {
@@ -32,6 +33,7 @@ export async function fetchUserPreferences(
.select({ .select({
statementNoteAsColumn: schema.userPreferences.statementNoteAsColumn, statementNoteAsColumn: schema.userPreferences.statementNoteAsColumn,
transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder, transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder,
attachmentMaxSizeMb: schema.userPreferences.attachmentMaxSizeMb,
}) })
.from(schema.userPreferences) .from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId)) .where(eq(schema.userPreferences.userId, userId))

View File

@@ -33,7 +33,7 @@ const presignSchema = z.object({
const confirmSchema = z.object({ const confirmSchema = z.object({
uploadToken: z.string().min(1), uploadToken: z.string().min(1),
applyToSeries: z.boolean().default(false), scope: z.enum(["current", "period", "future", "all"]).default("current"),
}); });
const detachSchema = z.object({ const detachSchema = z.object({
@@ -183,7 +183,7 @@ export async function getPresignedUploadUrlAction(input: {
export async function confirmAttachmentUploadAction(input: { export async function confirmAttachmentUploadAction(input: {
uploadToken: string; uploadToken: string;
applyToSeries?: boolean; scope?: "current" | "period" | "future" | "all";
}): Promise<ActionResult> { }): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -195,7 +195,11 @@ export async function confirmAttachmentUploadAction(input: {
} }
const [transaction] = await db const [transaction] = await db
.select({ id: transactions.id, seriesId: transactions.seriesId }) .select({
id: transactions.id,
seriesId: transactions.seriesId,
period: transactions.period,
})
.from(transactions) .from(transactions)
.where( .where(
and( and(
@@ -253,9 +257,9 @@ export async function confirmAttachmentUploadAction(input: {
let transactionIds: string[] = [uploadPayload.transactionId]; let transactionIds: string[] = [uploadPayload.transactionId];
if (data.applyToSeries && transaction.seriesId) { if (data.scope !== "current" && transaction.seriesId) {
const seriesRows = await db const seriesRows = await db
.select({ id: transactions.id }) .select({ id: transactions.id, period: transactions.period })
.from(transactions) .from(transactions)
.where( .where(
and( and(
@@ -263,7 +267,18 @@ export async function confirmAttachmentUploadAction(input: {
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
), ),
); );
transactionIds = seriesRows.map((t) => t.id);
if (data.scope === "period") {
transactionIds = seriesRows
.filter((r) => r.period === transaction.period)
.map((r) => r.id);
} else if (data.scope === "future") {
transactionIds = seriesRows
.filter((r) => (r.period ?? "") >= (transaction.period ?? ""))
.map((r) => r.id);
} else {
transactionIds = seriesRows.map((r) => r.id);
}
} }
await db.insert(transactionAttachments).values( await db.insert(transactionAttachments).values(
@@ -407,6 +422,110 @@ export async function fetchTransactionAttachmentsAction(
); );
} }
const detachBulkSchema = z.object({
attachmentId: z.string().uuid(),
transactionId: z.string().uuid(),
scope: z.enum(["current", "period", "future", "all"]),
});
export async function detachAttachmentBulkAction(input: {
attachmentId: string;
transactionId: string;
scope: "current" | "period" | "future" | "all";
}): Promise<ActionResult> {
try {
const user = await getUser();
const data = detachBulkSchema.parse(input);
const [baseTransaction] = await db
.select({
id: transactions.id,
seriesId: transactions.seriesId,
period: transactions.period,
})
.from(transactions)
.where(
and(
eq(transactions.id, data.transactionId),
eq(transactions.userId, user.id),
),
);
if (!baseTransaction) {
return { success: false, error: "Lançamento não encontrado." };
}
const [attachment] = await db
.select({ id: attachments.id, fileKey: attachments.fileKey })
.from(attachments)
.where(
and(
eq(attachments.id, data.attachmentId),
eq(attachments.userId, user.id),
),
);
if (!attachment) {
return { success: false, error: "Anexo não encontrado." };
}
let targetTransactionIds: string[];
if (data.scope === "current" || !baseTransaction.seriesId) {
targetTransactionIds = [data.transactionId];
} else {
const seriesRows = await db
.select({ id: transactions.id, period: transactions.period })
.from(transactions)
.where(
and(
eq(transactions.seriesId, baseTransaction.seriesId),
eq(transactions.userId, user.id),
),
);
if (data.scope === "period") {
targetTransactionIds = seriesRows
.filter((r) => r.period === baseTransaction.period)
.map((r) => r.id);
} else if (data.scope === "future") {
targetTransactionIds = seriesRows
.filter((r) => (r.period ?? "") >= (baseTransaction.period ?? ""))
.map((r) => r.id);
} else {
targetTransactionIds = seriesRows.map((r) => r.id);
}
}
if (targetTransactionIds.length > 0) {
await db
.delete(transactionAttachments)
.where(
and(
inArray(transactionAttachments.transactionId, targetTransactionIds),
eq(transactionAttachments.attachmentId, data.attachmentId),
),
);
}
const [remaining] = await db
.select({ total: count() })
.from(transactionAttachments)
.where(eq(transactionAttachments.attachmentId, data.attachmentId));
if (!remaining || remaining.total === 0) {
await deleteS3Object(attachment.fileKey);
await db.delete(attachments).where(eq(attachments.id, data.attachmentId));
}
revalidateForEntity("transactions", user.id);
return { success: true, message: "Anexo removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
/** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */ /** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */
export async function cleanupAttachmentsAfterTransactionDelete( export async function cleanupAttachmentsAfterTransactionDelete(
attachmentData: Array<{ id: string; fileKey: string }>, attachmentData: Array<{ id: string; fileKey: string }>,

View File

@@ -1,6 +1,6 @@
"use server"; "use server";
import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
import { transactions } from "@/db/schema"; import { transactions } from "@/db/schema";
import { import {
PAYMENT_METHODS, PAYMENT_METHODS,
@@ -80,6 +80,24 @@ export async function deleteTransactionBulkAction(
return { success: true, message: "Lançamento removido com sucesso." }; return { success: true, message: "Lançamento removido com sucesso." };
} }
if (data.scope === "period") {
await db
.delete(transactions)
.where(
and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
eq(transactions.period, existing.period ?? ""),
),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos do período foram removidos.",
};
}
if (data.scope === "future") { if (data.scope === "future") {
await db await db
.delete(transactions) .delete(transactions)
@@ -147,6 +165,7 @@ export async function updateTransactionBulkAction(
condition: true, condition: true,
transactionType: true, transactionType: true,
purchaseDate: true, purchaseDate: true,
payerId: true,
}, },
where: and( where: and(
eq(transactions.id, data.id), eq(transactions.id, data.id),
@@ -169,7 +188,8 @@ export async function updateTransactionBulkAction(
name: data.name, name: data.name,
categoryId: data.categoryId ?? null, categoryId: data.categoryId ?? null,
note: data.note ?? null, note: data.note ?? null,
payerId: data.payerId ?? null, // "period" atualiza todos os pagadores do mês — preserva o payerId de cada linha
...(data.scope !== "period" && { payerId: data.payerId ?? null }),
accountId: data.accountId ?? null, accountId: data.accountId ?? null,
cardId: data.cardId ?? null, cardId: data.cardId ?? null,
...(data.isSettled !== undefined && { isSettled: data.isSettled }), ...(data.isSettled !== undefined && { isSettled: data.isSettled }),
@@ -309,6 +329,42 @@ export async function updateTransactionBulkAction(
return { success: true, message: "Lançamento atualizado com sucesso." }; return { success: true, message: "Lançamento atualizado com sucesso." };
} }
if (data.scope === "period") {
if (!existing.period) {
return {
success: false,
error: "Período do lançamento não encontrado.",
};
}
const periodLancamentos = await db.query.transactions.findMany({
columns: { id: true, purchaseDate: true },
where: and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
eq(transactions.period, existing.period),
),
orderBy: asc(transactions.purchaseDate),
});
await applyUpdates(
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
})),
);
revalidate(user.id);
return {
success: true,
message: "Todos os lançamentos do período foram atualizados.",
};
}
const payerIdFilter = existing.payerId
? eq(transactions.payerId, existing.payerId)
: isNull(transactions.payerId);
if (data.scope === "future") { if (data.scope === "future") {
const futureLancamentos = await db.query.transactions.findMany({ const futureLancamentos = await db.query.transactions.findMany({
columns: { columns: {
@@ -319,6 +375,7 @@ export async function updateTransactionBulkAction(
eq(transactions.seriesId, existing.seriesId), eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
sql`${transactions.period} >= ${existing.period}`, sql`${transactions.period} >= ${existing.period}`,
payerIdFilter,
), ),
orderBy: asc(transactions.purchaseDate), orderBy: asc(transactions.purchaseDate),
}); });
@@ -346,6 +403,7 @@ export async function updateTransactionBulkAction(
where: and( where: and(
eq(transactions.seriesId, existing.seriesId), eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id), eq(transactions.userId, user.id),
payerIdFilter,
), ),
orderBy: asc(transactions.purchaseDate), orderBy: asc(transactions.purchaseDate),
}); });

View File

@@ -664,7 +664,7 @@ export const buildLancamentoRecords = ({
export const deleteBulkSchema = z.object({ export const deleteBulkSchema = z.object({
id: uuidSchema("Lançamento"), id: uuidSchema("Lançamento"),
scope: z.enum(["current", "future", "all"], { scope: z.enum(["current", "period", "future", "all"], {
message: "Escopo de ação inválido.", message: "Escopo de ação inválido.",
}), }),
}); });
@@ -673,7 +673,7 @@ export type DeleteBulkInput = z.infer<typeof deleteBulkSchema>;
export const updateBulkSchema = z.object({ export const updateBulkSchema = z.object({
id: uuidSchema("Lançamento"), id: uuidSchema("Lançamento"),
scope: z.enum(["current", "future", "all"], { scope: z.enum(["current", "period", "future", "all"], {
message: "Escopo de ação inválido.", message: "Escopo de ação inválido.",
}), }),
name: z name: z

View File

@@ -5,4 +5,9 @@ export const ALLOWED_MIME_TYPES = [
"image/webp", "image/webp",
] as const; ] as const;
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB export const DEFAULT_MAX_FILE_SIZE_MB = 50;
export const MAX_FILE_SIZE = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; // 50MB (fallback)
export const ATTACHMENT_SIZE_OPTIONS = [5, 10, 25, 50, 100] as const;
export type AttachmentSizeOption = (typeof ATTACHMENT_SIZE_OPTIONS)[number];

View File

@@ -5,19 +5,22 @@ import { useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
ALLOWED_MIME_TYPES, ALLOWED_MIME_TYPES,
MAX_FILE_SIZE, DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/attachments-config"; } from "@/features/transactions/attachments-config";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
interface AttachmentFilePickerProps { interface AttachmentFilePickerProps {
file: File | null; file: File | null;
onChange: (file: File | null) => void; onChange: (file: File | null) => void;
maxSizeMb?: number;
} }
export function AttachmentFilePicker({ export function AttachmentFilePicker({
file, file,
onChange, onChange,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentFilePickerProps) { }: AttachmentFilePickerProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -37,8 +40,8 @@ export function AttachmentFilePicker({
return; return;
} }
if (selected.size > MAX_FILE_SIZE) { if (selected.size > maxFileSizeBytes) {
toast.error("O arquivo deve ter no máximo 50MB."); toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
return; return;
} }
@@ -83,7 +86,7 @@ export function AttachmentFilePicker({
Adicionar anexo Adicionar anexo
</span> </span>
<span className="text-[11px]"> <span className="text-[11px]">
PDF, JPEG, PNG ou WebP · máx. 50 MB PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
</span> </span>
</button> </button>
)} )}

View File

@@ -125,6 +125,9 @@ interface AttachmentItemProps {
url: string; url: string;
onDeleted: () => void; onDeleted: () => void;
readonly?: boolean; readonly?: boolean;
isPendingDelete?: boolean;
onPendingDelete?: (attachmentId: string) => void;
onUndoPendingDelete?: (attachmentId: string) => void;
} }
export function AttachmentItem({ export function AttachmentItem({
@@ -136,6 +139,9 @@ export function AttachmentItem({
url, url,
onDeleted, onDeleted,
readonly = false, readonly = false,
isPendingDelete = false,
onPendingDelete,
onUndoPendingDelete,
}: AttachmentItemProps) { }: AttachmentItemProps) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
@@ -145,6 +151,11 @@ export function AttachmentItem({
mimeType === "application/pdf" || mimeType.startsWith("image/"); mimeType === "application/pdf" || mimeType.startsWith("image/");
function handleDelete() { function handleDelete() {
if (onPendingDelete) {
onPendingDelete(attachmentId);
setConfirmOpen(false);
return;
}
startTransition(async () => { startTransition(async () => {
const result = await detachTransactionAttachmentAction({ const result = await detachTransactionAttachmentAction({
attachmentId, attachmentId,
@@ -162,9 +173,18 @@ export function AttachmentItem({
return ( return (
<> <>
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm"> <div
className={`flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm transition-opacity ${isPendingDelete ? "opacity-50 border-dashed" : ""}`}
>
<AttachmentIcon mimeType={mimeType} /> <AttachmentIcon mimeType={mimeType} />
{canPreview ? ( {isPendingDelete ? (
<div className="flex-1 min-w-0">
<p className="truncate font-medium line-through">{fileName}</p>
<p className="text-xs text-muted-foreground">
Será removido ao salvar
</p>
</div>
) : canPreview ? (
<button <button
type="button" type="button"
className="min-w-0 flex-1 text-left" className="min-w-0 flex-1 text-left"
@@ -184,29 +204,42 @@ export function AttachmentItem({
</p> </p>
</div> </div>
)} )}
<Button {!isPendingDelete && (
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
asChild
>
<a href={url} target="_blank" rel="noreferrer" download={fileName}>
<RiDownloadLine className="size-4" />
</a>
</Button>
{!readonly && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7 shrink-0 text-destructive hover:text-destructive" className="size-7 shrink-0"
onClick={() => setConfirmOpen(true)} asChild
disabled={isPending}
> >
<RiDeleteBinLine className="size-4" /> <a href={url} target="_blank" rel="noreferrer" download={fileName}>
<RiDownloadLine className="size-4" />
</a>
</Button> </Button>
)} )}
{!readonly &&
(isPendingDelete ? (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-xs h-7 px-2"
onClick={() => onUndoPendingDelete?.(attachmentId)}
>
Desfazer
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
disabled={isPending}
>
<RiDeleteBinLine className="size-4" />
</Button>
))}
</div> </div>
{canPreview && ( {canPreview && (

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { RiFileAddLine } from "@remixicon/react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments"; import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
import { Button } from "@/shared/components/ui/button";
import { AttachmentItem } from "./attachment-item"; import { AttachmentItem } from "./attachment-item";
import { AttachmentUpload } from "./attachment-upload"; import { AttachmentUpload } from "./attachment-upload";
@@ -16,16 +18,28 @@ type AttachmentRow = {
interface AttachmentSectionProps { interface AttachmentSectionProps {
transactionId: string; transactionId: string;
seriesId: string | null;
readonly?: boolean; readonly?: boolean;
onLoaded?: (count: number) => void; onLoaded?: (count: number) => void;
pendingDetachIds?: string[];
onPendingDetach?: (attachmentId: string) => void;
onUndoPendingDetach?: (attachmentId: string) => void;
pendingUploadFiles?: File[];
onPendingUpload?: (file: File) => void;
onCancelPendingUpload?: (file: File) => void;
maxSizeMb?: number;
} }
export function AttachmentSection({ export function AttachmentSection({
transactionId, transactionId,
seriesId,
readonly = false, readonly = false,
onLoaded, onLoaded,
pendingDetachIds,
onPendingDetach,
onUndoPendingDetach,
pendingUploadFiles,
onPendingUpload,
onCancelPendingUpload,
maxSizeMb,
}: AttachmentSectionProps) { }: AttachmentSectionProps) {
const [items, setItems] = useState<AttachmentRow[]>([]); const [items, setItems] = useState<AttachmentRow[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -45,42 +59,70 @@ export function AttachmentSection({
load(); load();
}, [load]); }, [load]);
if (isLoading) {
return <p className="text-xs text-muted-foreground">Carregando...</p>;
}
const hasPendingUploads = (pendingUploadFiles?.length ?? 0) > 0;
return ( return (
<div className="min-w-0 space-y-2 overflow-hidden"> <div className="min-w-0 space-y-2 overflow-hidden">
{isLoading ? ( {items.length === 0 && !hasPendingUploads && readonly && (
<p className="text-xs text-muted-foreground">Carregando...</p> <p className="text-xs text-muted-foreground">Nenhum anexo.</p>
) : ( )}
<>
{items.length > 0 ? (
<div className="min-w-0 space-y-1.5">
{items.map((item) => (
<AttachmentItem
key={item.attachmentId}
attachmentId={item.attachmentId}
transactionId={transactionId}
fileName={item.fileName}
fileSize={item.fileSize}
mimeType={item.mimeType}
url={item.url}
onDeleted={load}
readonly={readonly}
/>
))}
</div>
) : (
readonly && (
<p className="text-xs text-muted-foreground">Nenhum anexo.</p>
)
)}
{!readonly && ( {(items.length > 0 || hasPendingUploads) && (
<AttachmentUpload <div className="min-w-0 space-y-1.5">
{items.map((item) => (
<AttachmentItem
key={item.attachmentId}
attachmentId={item.attachmentId}
transactionId={transactionId} transactionId={transactionId}
seriesId={seriesId} fileName={item.fileName}
onUploaded={load} fileSize={item.fileSize}
mimeType={item.mimeType}
url={item.url}
onDeleted={load}
readonly={readonly}
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
onPendingDelete={onPendingDetach}
onUndoPendingDelete={onUndoPendingDetach}
/> />
)} ))}
</>
{pendingUploadFiles?.map((file) => (
<div
key={`${file.name}-${file.size}`}
className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border border-dashed px-3 py-2 text-sm opacity-60"
>
<RiFileAddLine className="size-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
Será adicionado ao salvar
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-xs h-7 px-2"
onClick={() => onCancelPendingUpload?.(file)}
>
Cancelar
</Button>
</div>
))}
</div>
)}
{!readonly && (
<AttachmentUpload
transactionId={transactionId}
onUploaded={load}
onPendingUpload={onPendingUpload}
maxSizeMb={maxSizeMb}
/>
)} )}
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { RiAttachment2 } from "@remixicon/react"; import { RiAttachment2 } from "@remixicon/react";
import { useRef, useState, useTransition } from "react"; import { useRef, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
confirmAttachmentUploadAction, confirmAttachmentUploadAction,
@@ -9,27 +9,25 @@ import {
} from "@/features/transactions/actions/attachments"; } from "@/features/transactions/actions/attachments";
import { import {
ALLOWED_MIME_TYPES, ALLOWED_MIME_TYPES,
MAX_FILE_SIZE, DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/attachments-config"; } from "@/features/transactions/attachments-config";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import { Label } from "@/shared/components/ui/label";
interface AttachmentUploadProps { interface AttachmentUploadProps {
transactionId: string; transactionId: string;
seriesId: string | null;
onUploaded: () => void; onUploaded: () => void;
onPendingUpload?: (file: File) => void;
maxSizeMb?: number;
} }
export function AttachmentUpload({ export function AttachmentUpload({
transactionId, transactionId,
seriesId,
onUploaded, onUploaded,
onPendingUpload,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentUploadProps) { }: AttachmentUploadProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [applyToSeries, setApplyToSeries] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -49,19 +47,16 @@ export function AttachmentUpload({
return; return;
} }
if (file.size > MAX_FILE_SIZE) { if (file.size > maxFileSizeBytes) {
toast.error("O arquivo deve ter no máximo 50MB."); toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
return; return;
} }
if (seriesId) { if (onPendingUpload) {
setPendingFile(file); onPendingUpload(file);
} else { return;
uploadFile(file, false);
} }
}
function uploadFile(file: File, toSeries: boolean) {
startTransition(async () => { startTransition(async () => {
const presignResult = await getPresignedUploadUrlAction({ const presignResult = await getPresignedUploadUrlAction({
fileName: file.name, fileName: file.name,
@@ -88,13 +83,10 @@ export function AttachmentUpload({
const confirmResult = await confirmAttachmentUploadAction({ const confirmResult = await confirmAttachmentUploadAction({
uploadToken: presignResult.uploadToken, uploadToken: presignResult.uploadToken,
applyToSeries: toSeries,
}); });
if (confirmResult.success) { if (confirmResult.success) {
toast.success(confirmResult.message); toast.success(confirmResult.message);
setPendingFile(null);
setApplyToSeries(false);
onUploaded(); onUploaded();
} else { } else {
toast.error(confirmResult.error); toast.error(confirmResult.error);
@@ -102,56 +94,6 @@ export function AttachmentUpload({
}); });
} }
function handleConfirmPending() {
if (pendingFile) uploadFile(pendingFile, applyToSeries);
}
function handleCancelPending() {
setPendingFile(null);
setApplyToSeries(false);
}
if (pendingFile) {
return (
<div className="min-w-0 space-y-2 rounded-md border border-dashed p-3 text-sm">
<div className="min-w-0 overflow-hidden">
<p className="truncate font-medium" title={pendingFile.name}>
{pendingFile.name}
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="apply-series"
checked={applyToSeries}
onCheckedChange={(v) => setApplyToSeries(Boolean(v))}
/>
<Label htmlFor="apply-series" className="cursor-pointer text-xs">
Aplicar a todas as parcelas da série
</Label>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
onClick={handleConfirmPending}
disabled={isPending}
>
{isPending ? "Enviando..." : "Confirmar"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCancelPending}
disabled={isPending}
>
Cancelar
</Button>
</div>
</div>
);
}
return ( return (
<> <>
<input <input
@@ -172,7 +114,9 @@ export function AttachmentUpload({
{isPending ? "Enviando..." : "Adicionar anexo"} {isPending ? "Enviando..." : "Adicionar anexo"}
</span> </span>
{!isPending && ( {!isPending && (
<span className="text-xs">PDF, JPEG, PNG ou WebP · máx. 50 MB</span> <span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
</span>
)} )}
</button> </button>
</> </>

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { RiErrorWarningLine } from "@remixicon/react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { import {
@@ -13,7 +14,7 @@ import {
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
export type BulkActionScope = "current" | "future" | "all"; export type BulkActionScope = "current" | "period" | "future" | "all";
type BulkActionDialogProps = { type BulkActionDialogProps = {
open: boolean; open: boolean;
@@ -108,6 +109,30 @@ export function BulkActionDialog({
</div> </div>
</div> </div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="period" id="period" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="period"
className="text-sm cursor-pointer font-medium"
>
Todos os pagadores deste período
</Label>
<p className="text-xs text-muted-foreground">
Aplica a todos os lançamentos deste mesmo mês na série
</p>
{scope === "period" && actionType === "edit" && (
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
<RiErrorWarningLine className="mt-0.5 size-3.5 shrink-0" />
<p className="text-xs">
Atenção: os valores individuais de cada pagador serão
substituídos pelos valores deste lançamento.
</p>
</div>
)}
</div>
</div>
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<RadioGroupItem value="future" id="future" className="mt-0.5" /> <RadioGroupItem value="future" id="future" className="mt-0.5" />
<div className="flex-1"> <div className="flex-1">

View File

@@ -223,7 +223,6 @@ export function TransactionDetailsDialog({
<div className="min-w-0"> <div className="min-w-0">
<AttachmentSection <AttachmentSection
transactionId={transaction.id} transactionId={transaction.id}
seriesId={transaction.seriesId}
readonly readonly
onLoaded={setAttachmentCount} onLoaded={setAttachmentCount}
/> />

View File

@@ -27,7 +27,7 @@ export function BoletoFieldsSection({
/> />
</div> </div>
{showPaymentDate ? ( {showPaymentDate ? (
<div className="space-y-2 w-full md:w-1/2"> <div className="space-y-1 w-full md:w-1/2">
<Label htmlFor="boletoPaymentDate">Pagamento do boleto</Label> <Label htmlFor="boletoPaymentDate">Pagamento do boleto</Label>
<DatePicker <DatePicker
id="boletoPaymentDate" id="boletoPaymentDate"

View File

@@ -30,6 +30,8 @@ export interface TransactionDialogProps {
forceShowTransactionType?: boolean; forceShowTransactionType?: boolean;
/** Called after successful create/update. Receives the action result. */ /** Called after successful create/update. Receives the action result. */
onSuccess?: () => void; onSuccess?: () => void;
/** Max attachment file size in MB for this user */
maxSizeMb?: number;
onBulkEditRequest?: (data: { onBulkEditRequest?: (data: {
id: string; id: string;
name: string; name: string;
@@ -42,6 +44,8 @@ export interface TransactionDialogProps {
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null; isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
}) => void; }) => void;
} }

View File

@@ -8,6 +8,7 @@ import {
} from "@/features/transactions/actions"; } from "@/features/transactions/actions";
import { import {
confirmAttachmentUploadAction, confirmAttachmentUploadAction,
detachTransactionAttachmentAction,
getPresignedUploadUrlAction, getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments"; } from "@/features/transactions/actions/attachments";
import { import {
@@ -72,10 +73,11 @@ export function TransactionDialog({
defaultAmount, defaultAmount,
lockCardSelection, lockCardSelection,
lockPaymentMethod, lockPaymentMethod,
isImporting = false, isImporting,
defaultTransactionType, defaultTransactionType,
forceShowTransactionType = false, forceShowTransactionType,
onSuccess, onSuccess,
maxSizeMb,
onBulkEditRequest, onBulkEditRequest,
}: TransactionDialogProps) { }: TransactionDialogProps) {
const [dialogOpen, setDialogOpen] = useControlledState( const [dialogOpen, setDialogOpen] = useControlledState(
@@ -98,6 +100,8 @@ export function TransactionDialog({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null); const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
useEffect(() => { useEffect(() => {
if (dialogOpen) { if (dialogOpen) {
@@ -116,8 +120,9 @@ export function TransactionDialog({
}, },
); );
// Derive credit card period on open when cardId is pre-filled // Derive credit card period on open when cardId is pre-filled (create only)
if ( if (
mode !== "update" &&
initial.paymentMethod === "Cartão de crédito" && initial.paymentMethod === "Cartão de crédito" &&
initial.cardId && initial.cardId &&
initial.purchaseDate initial.purchaseDate
@@ -135,6 +140,8 @@ export function TransactionDialog({
setFormState(initial); setFormState(initial);
setErrorMessage(null); setErrorMessage(null);
setPendingFile(null); setPendingFile(null);
setPendingDetachIds([]);
setPendingUploadFiles([]);
} }
}, [ }, [
dialogOpen, dialogOpen,
@@ -341,7 +348,7 @@ export function TransactionDialog({
}); });
await confirmAttachmentUploadAction({ await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken, uploadToken: presign.uploadToken,
applyToSeries: isNewSeries, scope: isNewSeries ? "all" : "current",
}); });
} }
} }
@@ -356,11 +363,11 @@ export function TransactionDialog({
return; return;
} }
// Update mode
const hasSeriesId = Boolean(transaction?.seriesId); const hasSeriesId = Boolean(transaction?.seriesId);
if (hasSeriesId && onBulkEditRequest) { if (hasSeriesId && onBulkEditRequest) {
// Para lançamentos em série, abre o diálogo de bulk action // Para lançamentos em série, passa os arquivos para a página confirmar
// o upload após o escopo ser escolhido (sem upload antecipado ao S3)
onBulkEditRequest({ onBulkEditRequest({
id: transaction?.id ?? "", id: transaction?.id ?? "",
name: formState.name.trim(), name: formState.name.trim(),
@@ -382,11 +389,13 @@ export function TransactionDialog({
formState.paymentMethod === "Cartão de crédito" formState.paymentMethod === "Cartão de crédito"
? null ? null
: Boolean(formState.isSettled), : Boolean(formState.isSettled),
pendingDetachIds,
pendingUploadFiles,
}); });
return; return;
} }
// Atualização normal para lançamentos únicos ou todos os campos // Atualização normal para lançamentos únicos
const updatePayload: UpdateTransactionInput = { const updatePayload: UpdateTransactionInput = {
id: transaction?.id ?? "", id: transaction?.id ?? "",
...payload, ...payload,
@@ -395,6 +404,31 @@ export function TransactionDialog({
const result = await updateTransactionAction(updatePayload); const result = await updateTransactionAction(updatePayload);
if (result.success) { if (result.success) {
for (const attachmentId of pendingDetachIds) {
await detachTransactionAttachmentAction({
attachmentId,
transactionId: transaction?.id ?? "",
});
}
for (const file of pendingUploadFiles) {
const presign = await getPresignedUploadUrlAction({
fileName: file.name,
mimeType: file.type,
fileSize: file.size,
transactionId: transaction?.id ?? "",
});
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope: "current",
});
}
}
toast.success(result.message); toast.success(result.message);
onSuccess?.(); onSuccess?.();
setDialogOpen(false); setDialogOpen(false);
@@ -520,7 +554,40 @@ export function TransactionDialog({
</Label> </Label>
<AttachmentSection <AttachmentSection
transactionId={transaction?.id ?? ""} transactionId={transaction?.id ?? ""}
seriesId={transaction?.seriesId ?? null} maxSizeMb={maxSizeMb}
pendingDetachIds={
transaction?.seriesId ? pendingDetachIds : undefined
}
onPendingDetach={
transaction?.seriesId
? (id) => setPendingDetachIds((prev) => [...prev, id])
: undefined
}
onUndoPendingDetach={
transaction?.seriesId
? (id) =>
setPendingDetachIds((prev) =>
prev.filter((x) => x !== id),
)
: undefined
}
pendingUploadFiles={
transaction?.seriesId ? pendingUploadFiles : undefined
}
onPendingUpload={
transaction?.seriesId
? (file) =>
setPendingUploadFiles((prev) => [...prev, file])
: undefined
}
onCancelPendingUpload={
transaction?.seriesId
? (file) =>
setPendingUploadFiles((prev) =>
prev.filter((f) => f !== file),
)
: undefined
}
/> />
</div> </div>
</> </>
@@ -547,6 +614,7 @@ export function TransactionDialog({
<AttachmentFilePicker <AttachmentFilePicker
file={pendingFile} file={pendingFile}
onChange={setPendingFile} onChange={setPendingFile}
maxSizeMb={maxSizeMb}
/> />
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>

View File

@@ -10,6 +10,11 @@ import {
toggleTransactionSettlementAction, toggleTransactionSettlementAction,
updateTransactionBulkAction, updateTransactionBulkAction,
} from "@/features/transactions/actions"; } from "@/features/transactions/actions";
import {
confirmAttachmentUploadAction,
detachAttachmentBulkAction,
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import type { import type {
TransactionsExportContext, TransactionsExportContext,
@@ -59,6 +64,7 @@ interface TransactionsPageProps {
lockPaymentMethod?: boolean; lockPaymentMethod?: boolean;
pagination?: TransactionsPaginationState; pagination?: TransactionsPaginationState;
exportContext?: TransactionsExportContext; exportContext?: TransactionsExportContext;
attachmentMaxSizeMb?: number;
// Opções específicas para o dialog de importação (quando visualizando dados de outro usuário) // Opções específicas para o dialog de importação (quando visualizando dados de outro usuário)
importPayerOptions?: SelectOption[]; importPayerOptions?: SelectOption[];
importSplitPayerOptions?: SelectOption[]; importSplitPayerOptions?: SelectOption[];
@@ -91,6 +97,7 @@ export function TransactionsPage({
lockPaymentMethod, lockPaymentMethod,
pagination, pagination,
exportContext, exportContext,
attachmentMaxSizeMb,
importPayerOptions, importPayerOptions,
importSplitPayerOptions, importSplitPayerOptions,
importDefaultPayerId, importDefaultPayerId,
@@ -130,6 +137,8 @@ export function TransactionsPage({
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null; isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
transaction: TransactionItem; transaction: TransactionItem;
} | null>(null); } | null>(null);
const [pendingDeleteData, setPendingDeleteData] = const [pendingDeleteData, setPendingDeleteData] =
@@ -246,6 +255,8 @@ export function TransactionsPage({
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null; isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
}) => { }) => {
if (!selectedTransaction) { if (!selectedTransaction) {
return; return;
@@ -284,6 +295,36 @@ export function TransactionsPage({
throw new Error(result.error); throw new Error(result.error);
} }
// Propaga remoções de anexo pendentes com o mesmo escopo
for (const attachmentId of pendingEditData.pendingDetachIds) {
await detachAttachmentBulkAction({
attachmentId,
transactionId: pendingEditData.id,
scope,
});
}
// Faz upload dos arquivos pendentes e confirma com o escopo escolhido
for (const file of pendingEditData.pendingUploadFiles) {
const presign = await getPresignedUploadUrlAction({
fileName: file.name,
mimeType: file.type,
fileSize: file.size,
transactionId: pendingEditData.id,
});
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope,
});
}
}
toast.success(result.message); toast.success(result.message);
setBulkEditOpen(false); setBulkEditOpen(false);
setPendingEditData(null); setPendingEditData(null);
@@ -438,6 +479,7 @@ export function TransactionsPage({
lockCardSelection={lockCardSelection} lockCardSelection={lockCardSelection}
lockPaymentMethod={lockPaymentMethod} lockPaymentMethod={lockPaymentMethod}
defaultTransactionType={transactionTypeForCreate ?? undefined} defaultTransactionType={transactionTypeForCreate ?? undefined}
maxSizeMb={attachmentMaxSizeMb}
/> />
) : null} ) : null}
@@ -459,6 +501,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
transaction={transactionToCopy ?? undefined} transaction={transactionToCopy ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
maxSizeMb={attachmentMaxSizeMb}
/> />
<TransactionDialog <TransactionDialog
@@ -480,6 +523,7 @@ export function TransactionsPage({
transaction={transactionToImport ?? undefined} transaction={transactionToImport ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
isImporting={true} isImporting={true}
maxSizeMb={attachmentMaxSizeMb}
/> />
<BulkImportDialog <BulkImportDialog
@@ -507,6 +551,7 @@ export function TransactionsPage({
transaction={selectedTransaction ?? undefined} transaction={selectedTransaction ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
onBulkEditRequest={handleBulkEditRequest} onBulkEditRequest={handleBulkEditRequest}
maxSizeMb={attachmentMaxSizeMb}
/> />
<TransactionDetailsDialog <TransactionDetailsDialog

View File

@@ -220,111 +220,117 @@ const buildColumns = ({
<span className="text-primary">{dueDateLabel}</span> <span className="text-primary">{dueDateLabel}</span>
) : null} ) : null}
</span> </span>
<Tooltip> <span className="flex items-center gap-1">
<TooltipTrigger asChild> <Tooltip>
<span className="line-clamp-2 max-w-[180px] font-bold truncate"> <TooltipTrigger asChild>
<span className="line-clamp-2 max-w-[180px] font-bold truncate">
{name}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{name} {name}
</span> </TooltipContent>
</TooltipTrigger> </Tooltip>
<TooltipContent side="top" className="max-w-xs">
{name} {isDivided && (
</TooltipContent> <Tooltip>
</Tooltip> <TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiGroupLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">
Dividido entre pagadores
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Dividido entre pagadores
</TooltipContent>
</Tooltip>
)}
{isLastInstallment ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icons/party.svg"
alt="Última parcela"
width={16}
height={16}
className="h-4 w-4"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
{installmentBadge ? (
<Badge variant="outline" className="px-2 text-xs">
{installmentBadge}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiTimeLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Parcela antecipada</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Parcela antecipada
</TooltipContent>
</Tooltip>
)}
{!noteAsColumn && hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-accent transition-colors duration-300">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{note}
</TooltipContent>
</Tooltip>
) : null}
{hasAttachments ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiAttachment2
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Possui anexos</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Possui anexos</TooltipContent>
</Tooltip>
) : null}
</span>
</span> </span>
{isDivided && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiGroupLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Dividido entre pagadores</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Dividido entre pagadores
</TooltipContent>
</Tooltip>
)}
{isLastInstallment ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icons/party.svg"
alt="Última parcela"
width={16}
height={16}
className="h-4 w-4"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
{installmentBadge ? (
<Badge variant="outline" className="px-2 text-xs">
{installmentBadge}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiTimeLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Parcela antecipada</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Parcela antecipada</TooltipContent>
</Tooltip>
)}
{!noteAsColumn && hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-accent transition-colors duration-300">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{note}
</TooltipContent>
</Tooltip>
) : null}
{hasAttachments ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiAttachment2
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Possui anexos</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Possui anexos</TooltipContent>
</Tooltip>
) : null}
</span> </span>
); );
}, },

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { RiMoonClearLine, RiSunLine } from "@remixicon/react"; import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
import type { VariantProps } from "class-variance-authority";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { buttonVariants } from "@/shared/components/ui/button"; import { buttonVariants } from "@/shared/components/ui/button";
@@ -13,11 +14,13 @@ import { cn } from "@/shared/utils/ui";
interface AnimatedThemeTogglerProps interface AnimatedThemeTogglerProps
extends React.ComponentPropsWithoutRef<"button"> { extends React.ComponentPropsWithoutRef<"button"> {
duration?: number; duration?: number;
variant?: VariantProps<typeof buttonVariants>["variant"];
} }
export const AnimatedThemeToggler = ({ export const AnimatedThemeToggler = ({
className, className,
duration = 400, duration = 400,
variant = "ghost",
...props ...props
}: AnimatedThemeTogglerProps) => { }: AnimatedThemeTogglerProps) => {
const [isDark, setIsDark] = useState(false); const [isDark, setIsDark] = useState(false);
@@ -84,10 +87,10 @@ export const AnimatedThemeToggler = ({
onClick={toggleTheme} onClick={toggleTheme}
data-state={isDark ? "dark" : "light"} data-state={isDark ? "dark" : "light"}
className={cn( className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }), buttonVariants({ variant, size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200", "group relative transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40", variant === "ghost" &&
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground", "text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 data-[state=open]:bg-accent/60 data-[state=open]:text-foreground",
className, className,
)} )}
{...props} {...props}

View File

@@ -1,20 +1,20 @@
import Image from "next/image"; import Image from "next/image";
import { version } from "@/package.json";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
interface LogoProps { interface LogoProps {
variant?: "full" | "small" | "compact"; variant?: "full" | "small" | "compact";
className?: string; className?: string;
showVersion?: boolean; /** Apenas nos variants "full" e "compact" */
invertTextOnDark?: boolean; invertTextOnDark?: boolean;
/** Exibe o ícone na cor original, sem filtro preto */ /** Exibe o ícone na cor original, sem filtro preto. Apenas nos variants "full" e "compact" */
colorIcon?: boolean; colorIcon?: boolean;
} }
const iconFilterClass = "brightness-0 saturate-0";
export function Logo({ export function Logo({
variant = "full", variant = "full",
className, className,
showVersion = false,
invertTextOnDark = true, invertTextOnDark = true,
colorIcon = false, colorIcon = false,
}: LogoProps) { }: LogoProps) {
@@ -26,10 +26,7 @@ export function Logo({
alt="OpenMonetis" alt="OpenMonetis"
width={32} width={32}
height={32} height={32}
className={cn( className={cn("object-contain", !colorIcon && iconFilterClass)}
"object-contain",
!colorIcon && "brightness-0 saturate-0",
)}
priority priority
/> />
<Image <Image
@@ -67,7 +64,7 @@ export function Logo({
alt="OpenMonetis" alt="OpenMonetis"
width={28} width={28}
height={28} height={28}
className="object-contain" className={cn("object-contain", !colorIcon && iconFilterClass)}
priority priority
/> />
<Image <Image
@@ -78,11 +75,6 @@ export function Logo({
className={cn("object-contain", invertTextOnDark && "dark:invert")} className={cn("object-contain", invertTextOnDark && "dark:invert")}
priority priority
/> />
{showVersion && (
<span className="text-[9px] font-medium text-muted-foreground">
{version}
</span>
)}
</div> </div>
); );
} }

View File

@@ -1,10 +1,9 @@
import Link from "next/link";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler"; import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import { Logo } from "@/shared/components/logo";
import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell"; import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell";
import { RefreshPageButton } from "@/shared/components/refresh-page-button"; import { RefreshPageButton } from "@/shared/components/refresh-page-button";
import type { DashboardNotificationsSnapshot } from "@/shared/lib/types/notifications"; import type { DashboardNotificationsSnapshot } from "@/shared/lib/types/notifications";
import { NavMenu } from "./nav-menu"; import { NavMenu } from "./nav-menu";
import { NavbarShell } from "./navbar-shell";
import { NavbarUser } from "./navbar-user"; import { NavbarUser } from "./navbar-user";
type AppNavbarProps = { type AppNavbarProps = {
@@ -19,9 +18,6 @@ type AppNavbarProps = {
notificationsSnapshot: DashboardNotificationsSnapshot; notificationsSnapshot: DashboardNotificationsSnapshot;
}; };
const navbarActionClassName =
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
export function AppNavbar({ export function AppNavbar({
user, user,
pagadorAvatarUrl, pagadorAvatarUrl,
@@ -29,28 +25,20 @@ export function AppNavbar({
notificationsSnapshot, notificationsSnapshot,
}: AppNavbarProps) { }: AppNavbarProps) {
return ( return (
<header className="fixed top-0 left-0 right-0 z-50 flex h-16 shrink-0 items-center bg-primary"> <NavbarShell logoHref="/dashboard" fixed>
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4"> <NavMenu />
<Link href="/dashboard" className="shrink-0 mr-1"> <div className="ml-auto flex items-center gap-2">
<Logo variant="compact" invertTextOnDark={false} /> <NotificationBell
</Link> notifications={notificationsSnapshot.notifications}
unreadCount={notificationsSnapshot.unreadCount}
<NavMenu /> visibleCount={notificationsSnapshot.visibleCount}
budgetNotifications={notificationsSnapshot.budgetNotifications}
<div className="ml-auto flex items-center gap-2"> preLancamentosCount={preLancamentosCount}
<NotificationBell />
notifications={notificationsSnapshot.notifications} <RefreshPageButton variant="navbar" />
unreadCount={notificationsSnapshot.unreadCount} <AnimatedThemeToggler variant="navbar" />
visibleCount={notificationsSnapshot.visibleCount}
budgetNotifications={notificationsSnapshot.budgetNotifications}
preLancamentosCount={preLancamentosCount}
/>
<RefreshPageButton className={navbarActionClassName} />
<AnimatedThemeToggler className={navbarActionClassName} />
</div>
<NavbarUser user={user} pagadorAvatarUrl={pagadorAvatarUrl} />
</div> </div>
</header> <NavbarUser user={user} pagadorAvatarUrl={pagadorAvatarUrl} />
</NavbarShell>
); );
} }

View File

@@ -20,13 +20,18 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@/shared/components/ui/sheet"; } from "@/shared/components/ui/sheet";
import { cn } from "@/shared/utils/ui";
import { MobileLink, MobileSectionLabel } from "./mobile-link"; import { MobileLink, MobileSectionLabel } from "./mobile-link";
import { NavDropdown } from "./nav-dropdown"; import { NavDropdown } from "./nav-dropdown";
import { NAV_SECTIONS } from "./nav-items"; import { NAV_SECTIONS } from "./nav-items";
import { NavPill } from "./nav-pill"; import { NavPill } from "./nav-pill";
import { triggerActiveClass, triggerClass } from "./nav-styles";
import { MobileTools, NavToolsDropdown } from "./nav-tools"; import { MobileTools, NavToolsDropdown } from "./nav-tools";
const triggerClass =
"h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! lowercase! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10!";
const triggerActiveClass = "bg-black/15! text-black!";
export function NavMenu() { export function NavMenu() {
const pathname = usePathname(); const pathname = usePathname();
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
@@ -55,7 +60,10 @@ export function NavMenu() {
return ( return (
<NavigationMenuItem key={section.label}> <NavigationMenuItem key={section.label}>
<NavigationMenuTrigger <NavigationMenuTrigger
className={`${triggerClass} ${isSectionActive ? triggerActiveClass : ""}`} className={cn(
triggerClass,
isSectionActive && triggerActiveClass,
)}
> >
{section.label} {section.label}
</NavigationMenuTrigger> </NavigationMenuTrigger>
@@ -82,9 +90,9 @@ export function NavMenu() {
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}> <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button <Button
variant="ghost" variant="navbar"
size="icon" size="icon-sm"
className="-order-1 border border-black/10 text-black/75 shadow-none md:hidden hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20" className="-order-1 md:hidden"
> >
<RiMenuLine className="size-5" /> <RiMenuLine className="size-5" />
<span className="sr-only">Abrir menu</span> <span className="sr-only">Abrir menu</span>

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { buttonVariants } from "@/shared/components/ui/button";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
import { NavLink } from "./nav-link"; import { NavLink } from "./nav-link";
import { linkActive, linkBase, linkIdle } from "./nav-styles";
type NavPillProps = { type NavPillProps = {
href: string; href: string;
@@ -23,7 +23,11 @@ export function NavPill({ href, preservePeriod, children }: NavPillProps) {
<NavLink <NavLink
href={href} href={href}
preservePeriod={preservePeriod} preservePeriod={preservePeriod}
className={cn(linkBase, isActive ? linkActive : linkIdle)} className={cn(
buttonVariants({ variant: "navbar", size: "sm" }),
"lowercase",
isActive && "bg-black/15 text-black",
)}
> >
{children} {children}
</NavLink> </NavLink>

View File

@@ -1,29 +0,0 @@
export const linkBase =
"inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all lowercase";
export const linkIdle = "text-black/75 hover:bg-black/10 hover:text-black";
export const linkActive = "bg-black/15 text-black";
export const triggerActiveClass = ["bg-black/15!", "text-black!"].join(" ");
export const triggerClass = [
"h-8!",
"rounded-md!",
"px-2!",
"py-0!",
"text-sm!",
"font-medium!",
"bg-transparent!",
"text-black/75!",
"hover:text-black!",
"hover:bg-black/10!",
"focus:text-black!",
"focus:bg-black/10!",
"focus-visible:ring-black/20!",
"data-[state=open]:text-black!",
"data-[state=open]:bg-black/10!",
"shadow-none!",
"[&_svg]:text-current!",
"lowercase!",
].join(" ");

View File

@@ -0,0 +1,33 @@
import Link from "next/link";
import { Logo } from "@/shared/components/logo";
type NavbarShellProps = {
logoHref?: string;
fixed?: boolean;
children: React.ReactNode;
};
export function NavbarShell({
logoHref,
fixed = false,
children,
}: NavbarShellProps) {
const positionClass = fixed ? "fixed top-0 left-0 right-0" : "sticky top-0";
return (
<header
className={`${positionClass} z-50 flex h-16 shrink-0 items-center bg-primary`}
>
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4">
{logoHref ? (
<Link href={logoHref} className="shrink-0">
<Logo variant="compact" invertTextOnDark={false} />
</Link>
) : (
<Logo variant="compact" invertTextOnDark={false} />
)}
{children}
</div>
</header>
);
}

View File

@@ -77,7 +77,5 @@ function LogoContent() {
const { state } = useSidebar(); const { state } = useSidebar();
const isCollapsed = state === "collapsed"; const isCollapsed = state === "collapsed";
return ( return <Logo variant={isCollapsed ? "small" : "full"} />;
<Logo variant={isCollapsed ? "small" : "full"} showVersion={!isCollapsed} />
);
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { RiRefreshLine } from "@remixicon/react"; import { RiRefreshLine } from "@remixicon/react";
import type { VariantProps } from "class-variance-authority";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTransition } from "react"; import { useTransition } from "react";
import { buttonVariants } from "@/shared/components/ui/button"; import { buttonVariants } from "@/shared/components/ui/button";
@@ -11,10 +12,12 @@ import {
} from "@/shared/components/ui/tooltip"; } from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
type RefreshPageButtonProps = React.ComponentPropsWithoutRef<"button">; type RefreshPageButtonProps = React.ComponentPropsWithoutRef<"button"> &
Pick<VariantProps<typeof buttonVariants>, "variant">;
export function RefreshPageButton({ export function RefreshPageButton({
className, className,
variant = "ghost",
...props ...props
}: RefreshPageButtonProps) { }: RefreshPageButtonProps) {
const router = useRouter(); const router = useRouter();
@@ -36,10 +39,10 @@ export function RefreshPageButton({
aria-label="Atualizar página" aria-label="Atualizar página"
title="Atualizar página" title="Atualizar página"
className={cn( className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }), buttonVariants({ variant, size: "icon-sm" }),
"size-8 text-muted-foreground transition-all duration-200", "transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40", variant === "ghost" &&
"disabled:pointer-events-none disabled:opacity-50", "text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
className, className,
)} )}
{...props} {...props}

View File

@@ -19,6 +19,8 @@ const buttonVariants = cva(
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
navbar:
"bg-transparent text-black/75 shadow-none hover:bg-black/10 hover:text-black focus-visible:ring-black/20",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",