mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d01bc8a669 | ||
|
|
e024e0d54e | ||
|
|
c44089169f | ||
|
|
d04e30e3c9 | ||
|
|
229b6c5bc0 | ||
|
|
c3b133d8d9 | ||
|
|
e9a2ab1782 | ||
|
|
c7d6e23398 | ||
|
|
0514efb1c4 | ||
|
|
e32fb85006 | ||
|
|
96df6a1798 | ||
|
|
1f8a97bd16 | ||
|
|
0ab3298cef | ||
|
|
cad41680eb | ||
|
|
3b00f328c5 |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -7,6 +7,32 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.2.1] - 2026-04-01
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Docker: imagem de produção deixa de executar `chown -R /app` no stage final; as permissões passam a ser definidas nos `COPY --chown`, reduzindo o risco de travamento e lentidão excessiva no build/push da GitHub Action
|
||||||
|
|
||||||
|
## [2.2.0] - 2026-04-01
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Anexos: nova página de galeria em `/attachments` com miniaturas, visualização inline de imagem e PDF, download direto e acesso a partir do lançamento
|
||||||
|
- Anexos: suporte a visualização de PDF diretamente no app via `pdfjs-dist`
|
||||||
|
- Autenticação: sidebar redesenhado com mockup de faturas e três itens de funcionalidade; páginas de login e cadastro ganham gradiente decorativo e logo visível no mobile
|
||||||
|
- Notificações: alertas de vencimento para boletos e faturas do período seguinte exibidos quando o vencimento está dentro de 5 dias
|
||||||
|
- Documentação: novo arquivo público `public/llms.txt` com resumo do projeto e links curados para documentação, setup e arquitetura
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Performance: queries de cache do dashboard migradas de `unstable_cache` para a diretiva `use cache` com `cacheTag` e `cacheLife`; todas as páginas do dashboard passam a chamar `connection()` para renderização dinâmica; `next.config.ts` adota `cacheComponents: true`
|
||||||
|
- Tipografia: adicionada fonte America Medium (weight 500); pesos tipográficos padronizados para `font-medium` em títulos, valores e rótulos em todos os componentes
|
||||||
|
- Anexos: `AttachmentPreview` foi simplificado para exibir apenas nome da transação, nome do arquivo, navegação entre anexos e ações de download, abrir em nova aba e fechar com ícone `X`
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Lançamentos: uploads e remoções de anexo agora funcionam para todos os lançamentos, não apenas os pertencentes a séries
|
||||||
|
|
||||||
## [2.1.2] - 2026-03-30
|
## [2.1.2] - 2026-03-30
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ Use esta pergunta:
|
|||||||
|
|
||||||
Se um contrato cruza dominios, ele deve morar em `src/shared/`.
|
Se um contrato cruza dominios, ele deve morar em `src/shared/`.
|
||||||
|
|
||||||
|
**Excecao intencional: `attachments` depende de `transactions`**
|
||||||
|
|
||||||
|
`src/features/attachments` importa `TransactionDialog`, `TransactionDetailsDialog` e `TransactionItem` diretamente de `src/features/transactions`. Isso e uma dependencia explicita e aceita: anexos sao semanticamente uma extensao de lancamentos — existem por causa deles e nao fazem sentido sem esse contexto. Mover esses componentes para `shared/` seria errado (eles pertencem a transactions). Nao tratar isso como bug a corrigir.
|
||||||
|
|
||||||
Exemplos comuns:
|
Exemplos comuns:
|
||||||
|
|
||||||
- auth: `src/shared/lib/auth/*`
|
- auth: `src/shared/lib/auth/*`
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -13,6 +13,9 @@ WORKDIR /app
|
|||||||
# Copiar apenas arquivos de dependências para aproveitar cache
|
# Copiar apenas arquivos de dependências para aproveitar cache
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# Criar pasta public para o postinstall do pdfjs-dist
|
||||||
|
RUN mkdir -p public
|
||||||
|
|
||||||
# Instalar dependências (production + dev para o build)
|
# Instalar dependências (production + dev para o build)
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -56,9 +59,9 @@ RUN addgroup --system --gid 1001 nodejs && \
|
|||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copiar apenas arquivos necessários para produção
|
# Copiar apenas arquivos necessários para produção
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
COPY --from=builder /app/package.json ./package.json
|
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=builder --chown=nextjs:nodejs /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
|
|
||||||
# Copiar arquivos de build do Next.js
|
# Copiar arquivos de build do Next.js
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
@@ -81,9 +84,6 @@ ENV NODE_ENV=production \
|
|||||||
# Expor porta
|
# Expor porta
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Ajustar permissões para o usuário nextjs
|
|
||||||
RUN chown -R nextjs:nodejs /app
|
|
||||||
|
|
||||||
# Mudar para usuário não-root
|
# Mudar para usuário não-root
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.9/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,181 +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,
|
"idx": 24,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1774891206703,
|
"when": 1774891206703,
|
||||||
"tag": "0024_petite_lucky_pierre",
|
"tag": "0024_petite_lucky_pierre",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ dotenv.config();
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
experimental: {
|
cacheComponents: true,
|
||||||
turbopackFileSystemCacheForDev: true,
|
|
||||||
},
|
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
|
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.1.2",
|
"version": "2.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"db:extensions": "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",
|
||||||
|
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
||||||
"docker:up:db": "docker compose up -d db",
|
"docker:up:db": "docker compose up -d db",
|
||||||
"docker:up:d": "docker compose up --build -d",
|
"docker:up:d": "docker compose up --build -d",
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
@@ -72,6 +73,7 @@
|
|||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
|
"pdfjs-dist": "^5.6.205",
|
||||||
"pg": "8.20.0",
|
"pg": "8.20.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
141
pnpm-lock.yaml
generated
141
pnpm-lock.yaml
generated
@@ -140,6 +140,9 @@ importers:
|
|||||||
next-themes:
|
next-themes:
|
||||||
specifier: 0.4.6
|
specifier: 0.4.6
|
||||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
pdfjs-dist:
|
||||||
|
specifier: ^5.6.205
|
||||||
|
version: 5.6.205
|
||||||
pg:
|
pg:
|
||||||
specifier: 8.20.0
|
specifier: 8.20.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
@@ -1291,6 +1294,81 @@ packages:
|
|||||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
'@napi-rs/canvas-android-arm64@0.1.97':
|
||||||
|
resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-arm64@0.1.97':
|
||||||
|
resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-x64@0.1.97':
|
||||||
|
resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
|
||||||
|
resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
|
||||||
|
resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
|
||||||
|
resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
|
||||||
|
resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
|
||||||
|
resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-musl@0.1.97':
|
||||||
|
resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
|
||||||
|
resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
|
||||||
|
resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/canvas@0.1.97':
|
||||||
|
resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@next/env@16.1.7':
|
'@next/env@16.1.7':
|
||||||
resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==}
|
resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==}
|
||||||
|
|
||||||
@@ -3366,6 +3444,9 @@ packages:
|
|||||||
node-fetch-native@1.6.7:
|
node-fetch-native@1.6.7:
|
||||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||||
|
|
||||||
|
node-readable-to-web-readable-stream@0.4.2:
|
||||||
|
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
|
||||||
|
|
||||||
nypm@0.6.5:
|
nypm@0.6.5:
|
||||||
resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==}
|
resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3388,6 +3469,10 @@ packages:
|
|||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
pdfjs-dist@5.6.205:
|
||||||
|
resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==}
|
||||||
|
engines: {node: '>=20.19.0 || >=22.13.0 || >=24'}
|
||||||
|
|
||||||
perfect-debounce@1.0.0:
|
perfect-debounce@1.0.0:
|
||||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||||
|
|
||||||
@@ -4936,6 +5021,54 @@ snapshots:
|
|||||||
lilconfig: 2.1.0
|
lilconfig: 2.1.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-android-arm64@0.1.97':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-arm64@0.1.97':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-x64@0.1.97':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-musl@0.1.97':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas@0.1.97':
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas-android-arm64': 0.1.97
|
||||||
|
'@napi-rs/canvas-darwin-arm64': 0.1.97
|
||||||
|
'@napi-rs/canvas-darwin-x64': 0.1.97
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.97
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu': 0.1.97
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl': 0.1.97
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.97
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu': 0.1.97
|
||||||
|
'@napi-rs/canvas-linux-x64-musl': 0.1.97
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc': 0.1.97
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc': 0.1.97
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@next/env@16.1.7': {}
|
'@next/env@16.1.7': {}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.1.7':
|
'@next/swc-darwin-arm64@16.1.7':
|
||||||
@@ -7141,6 +7274,9 @@ snapshots:
|
|||||||
node-fetch-native@1.6.7:
|
node-fetch-native@1.6.7:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
node-readable-to-web-readable-stream@0.4.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
nypm@0.6.5:
|
nypm@0.6.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
citty: 0.2.1
|
citty: 0.2.1
|
||||||
@@ -7161,6 +7297,11 @@ snapshots:
|
|||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
pdfjs-dist@5.6.205:
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas': 0.1.97
|
||||||
|
node-readable-to-web-readable-stream: 0.4.2
|
||||||
|
|
||||||
perfect-debounce@1.0.0:
|
perfect-debounce@1.0.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
BIN
public/fonts/america-bold.woff2
Normal file
BIN
public/fonts/america-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/america-medium.woff2
Normal file
BIN
public/fonts/america-medium.woff2
Normal file
Binary file not shown.
@@ -7,6 +7,11 @@ export const america = localFont({
|
|||||||
weight: "400",
|
weight: "400",
|
||||||
style: "normal",
|
style: "normal",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "./america-medium.woff2",
|
||||||
|
weight: "500",
|
||||||
|
style: "normal",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
variable: "--font-america",
|
variable: "--font-america",
|
||||||
|
|||||||
37
public/llms.txt
Normal file
37
public/llms.txt
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# OpenMonetis
|
||||||
|
|
||||||
|
> OpenMonetis is a self-hosted personal finance web app for manual financial control. It helps users manage accounts, cards, invoices, budgets, notes, reports, attachments, and AI-generated insights. The product UI is in Brazilian Portuguese, the codebase uses English folder and import names, and there is no hosted SaaS version.
|
||||||
|
>
|
||||||
|
> **Stack:** Next.js 16, React 19, PostgreSQL, Drizzle ORM, Better Auth, Tailwind CSS 4, shadcn/ui. Package manager: pnpm. Linter: Biome.
|
||||||
|
|
||||||
|
OpenMonetis is meant to be deployed by the user on their own machine or server.
|
||||||
|
There is no Open Finance or automatic bank synchronization.
|
||||||
|
Transactions can be entered manually or imported from OFX and XLS/XLSX files.
|
||||||
|
Attachments are optional and require S3-compatible storage.
|
||||||
|
The public website is mainly a landing page; the main technical documentation lives in the GitHub repository.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- [Landing page](/): Public homepage and high-level product overview
|
||||||
|
- [README](https://github.com/felipegcoutinho/openmonetis/blob/main/README.md): Main project documentation covering features, installation, Docker, environment variables, architecture, contributing, and license
|
||||||
|
- [CHANGELOG](https://github.com/felipegcoutinho/openmonetis/blob/main/CHANGELOG.md): Release history and notable changes
|
||||||
|
- [LICENSE](https://github.com/felipegcoutinho/openmonetis/blob/main/LICENSE): CC BY-NC-SA 4.0 license terms
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- [Setup script](https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs): Interactive installer for local or self-hosted setup
|
||||||
|
- [Environment example](https://github.com/felipegcoutinho/openmonetis/blob/main/.env.example): Required and optional environment variables
|
||||||
|
- [Docker Compose](https://github.com/felipegcoutinho/openmonetis/blob/main/docker-compose.yml): Local app and PostgreSQL stack definition
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- [CLAUDE.md](https://github.com/felipegcoutinho/openmonetis/blob/main/CLAUDE.md): Project architecture, naming rules, query rules, and feature checklist
|
||||||
|
|
||||||
|
## Optional
|
||||||
|
|
||||||
|
- [robots.txt](/robots.txt): Crawl policy for the public site
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
- [OpenMonetis Companion](https://github.com/felipegcoutinho/openmonetis-companion): Android app that captures bank notifications and sends them to the OpenMonetis inbox for review
|
||||||
|
|
||||||
28
public/pdf.worker.min.mjs
Normal file
28
public/pdf.worker.min.mjs
Normal file
File diff suppressed because one or more lines are too long
@@ -1,9 +1,19 @@
|
|||||||
import { LoginForm } from "@/features/auth/components/login-form";
|
import { LoginForm } from "@/features/auth/components/login-form";
|
||||||
|
import { Logo } from "@/shared/components/logo";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh flex-col items-center justify-center bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
||||||
<div className="w-full max-w-sm md:max-w-5xl">
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
|
||||||
|
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-6 flex md:hidden">
|
||||||
|
<Logo variant="compact" colorIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-sm md:max-w-5xl">
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { SignupForm } from "@/features/auth/components/signup-form";
|
import { SignupForm } from "@/features/auth/components/signup-form";
|
||||||
|
import { Logo } from "@/shared/components/logo";
|
||||||
|
|
||||||
export default function Page() {
|
export default function SignupPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh flex-col items-center justify-center bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
||||||
<div className="w-full max-w-sm md:max-w-5xl">
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
|
||||||
|
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-6 flex md:hidden">
|
||||||
|
<Logo variant="compact" colorIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-sm md:max-w-5xl">
|
||||||
<SignupForm />
|
<SignupForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RiPencilLine } from "@remixicon/react";
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
import { AccountDialog } from "@/features/accounts/components/account-dialog";
|
import { AccountDialog } from "@/features/accounts/components/account-dialog";
|
||||||
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
|
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
|
||||||
import type { Account } from "@/features/accounts/components/types";
|
import type { Account } from "@/features/accounts/components/types";
|
||||||
@@ -42,6 +43,7 @@ const capitalize = (value: string) =>
|
|||||||
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
||||||
|
|
||||||
export default async function Page({ params, searchParams }: PageProps) {
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const { accountId } = await params;
|
const { accountId } = await params;
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { AccountsPage } from "@/features/accounts/components/accounts-page";
|
import { AccountsPage } from "@/features/accounts/components/accounts-page";
|
||||||
import { fetchAllAccountsForUser } from "@/features/accounts/queries";
|
import { fetchAllAccountsForUser } from "@/features/accounts/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const { activeAccounts, archivedAccounts, logoOptions } =
|
const { activeAccounts, archivedAccounts, logoOptions } =
|
||||||
await fetchAllAccountsForUser(userId);
|
await fetchAllAccountsForUser(userId);
|
||||||
|
|||||||
38
src/app/(dashboard)/attachments/loading.tsx
Normal file
38
src/app/(dashboard)/attachments/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function AnexosLoading() {
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-6">
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<Skeleton className="h-10 w-40 rounded-md bg-foreground/10" />
|
||||||
|
|
||||||
|
{/* Month navigation */}
|
||||||
|
<Skeleton className="h-10 w-64 rounded-md bg-foreground/10" />
|
||||||
|
|
||||||
|
{/* Count */}
|
||||||
|
<Skeleton className="h-4 w-20 rounded-md bg-foreground/10" />
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex flex-col overflow-hidden rounded-lg border"
|
||||||
|
>
|
||||||
|
<Skeleton className="aspect-square w-full bg-foreground/10" />
|
||||||
|
<div className="space-y-1.5 p-2.5">
|
||||||
|
<Skeleton className="h-3 w-3/4 rounded bg-foreground/10" />
|
||||||
|
<Skeleton className="h-3 w-full rounded bg-foreground/10" />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Skeleton className="h-3 w-16 rounded bg-foreground/10" />
|
||||||
|
<Skeleton className="h-3 w-12 rounded bg-foreground/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/app/(dashboard)/attachments/page.tsx
Normal file
36
src/app/(dashboard)/attachments/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
|
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
|
||||||
|
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
import { parsePeriodParam } from "@/shared/utils/period";
|
||||||
|
|
||||||
|
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
searchParams?: PageSearchParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSingleParam = (
|
||||||
|
params: Record<string, string | string[] | undefined> | undefined,
|
||||||
|
key: string,
|
||||||
|
) => {
|
||||||
|
const value = params?.[key];
|
||||||
|
if (!value) return null;
|
||||||
|
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
|
const userId = await getUserId();
|
||||||
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
const { period } = parsePeriodParam(periodoParam);
|
||||||
|
|
||||||
|
const attachments = await fetchAttachmentsForPeriod(userId, period);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-6">
|
||||||
|
<AttachmentsPage attachments={attachments} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { BudgetsPage } from "@/features/budgets/components/budgets-page";
|
import { BudgetsPage } from "@/features/budgets/components/budgets-page";
|
||||||
import { fetchBudgetsForUser } from "@/features/budgets/queries";
|
import { fetchBudgetsForUser } from "@/features/budgets/queries";
|
||||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
@@ -23,6 +24,7 @@ const capitalize = (value: string) =>
|
|||||||
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
|
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { MonthlyCalendar } from "@/features/calendar/components/monthly-calendar";
|
import { MonthlyCalendar } from "@/features/calendar/components/monthly-calendar";
|
||||||
import { fetchCalendarData } from "@/features/calendar/queries";
|
import { fetchCalendarData } from "@/features/calendar/queries";
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +17,7 @@ type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedParams = searchParams ? await searchParams : undefined;
|
const resolvedParams = searchParams ? await searchParams : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RiPencilLine } from "@remixicon/react";
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
import type { FinancialAccount } from "@/db/schema";
|
import type { FinancialAccount } from "@/db/schema";
|
||||||
import { CardDialog } from "@/features/cards/components/card-dialog";
|
import { CardDialog } from "@/features/cards/components/card-dialog";
|
||||||
import type { Card } from "@/features/cards/components/types";
|
import type { Card } from "@/features/cards/components/types";
|
||||||
@@ -39,6 +40,7 @@ type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params, searchParams }: PageProps) {
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const { cardId } = await params;
|
const { cardId } = await params;
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { CardsPage } from "@/features/cards/components/cards-page";
|
import { CardsPage } from "@/features/cards/components/cards-page";
|
||||||
import { fetchAllCardsForUser } from "@/features/cards/queries";
|
import { fetchAllCardsForUser } from "@/features/cards/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const { activeCards, archivedCards, accounts, logoOptions } =
|
const { activeCards, archivedCards, accounts, logoOptions } =
|
||||||
await fetchAllCardsForUser(userId);
|
await fetchAllCardsForUser(userId);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header";
|
import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header";
|
||||||
import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries";
|
import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries";
|
||||||
import { fetchUserPreferences } from "@/features/settings/queries";
|
import { fetchUserPreferences } from "@/features/settings/queries";
|
||||||
@@ -32,6 +33,7 @@ const getSingleParam = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ params, searchParams }: PageProps) {
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const { categoryId } = await params;
|
const { categoryId } = await params;
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
|
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
|
||||||
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget";
|
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { getCurrentPeriod } from "@/shared/utils/period";
|
import { getCurrentPeriod } from "@/shared/utils/period";
|
||||||
|
|
||||||
export default async function HistoricoCategoriasPage() {
|
export default async function HistoricoCategoriasPage() {
|
||||||
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const currentPeriod = getCurrentPeriod();
|
const currentPeriod = getCurrentPeriod();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { CategoriesPage } from "@/features/categories/components/categories-page";
|
import { CategoriesPage } from "@/features/categories/components/categories-page";
|
||||||
import { fetchCategoriesForUser } from "@/features/categories/queries";
|
import { fetchCategoriesForUser } from "@/features/categories/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const categories = await fetchCategoriesForUser(userId);
|
const categories = await fetchCategoriesForUser(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
|
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
|
||||||
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
|
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
|
||||||
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
||||||
@@ -14,6 +15,7 @@ type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { InboxPage } from "@/features/inbox/components/inbox-page";
|
import { InboxPage } from "@/features/inbox/components/inbox-page";
|
||||||
import {
|
import {
|
||||||
type ResolvedInboxSearchParams,
|
type ResolvedInboxSearchParams,
|
||||||
@@ -31,6 +32,7 @@ const EMPTY_DIALOG_DATA = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const activeStatus = resolveInboxStatus(resolvedSearchParams);
|
const activeStatus = resolveInboxStatus(resolvedSearchParams);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { InsightsPage } from "@/features/insights/components/insights-page";
|
import { InsightsPage } from "@/features/insights/components/insights-page";
|
||||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
import { parsePeriodParam } from "@/shared/utils/period";
|
import { parsePeriodParam } from "@/shared/utils/period";
|
||||||
@@ -18,6 +19,7 @@ const getSingleParam = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
|
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
|
||||||
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
|
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
|
||||||
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
||||||
@@ -9,6 +10,7 @@ export default async function DashboardLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
await connection();
|
||||||
const session = await getUserSession();
|
const session = await getUserSession();
|
||||||
const navbarData = await fetchDashboardNavbarData(session.user.id);
|
const navbarData = await fetchDashboardNavbarData(session.user.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { NotesPage } from "@/features/notes/components/notes-page";
|
import { NotesPage } from "@/features/notes/components/notes-page";
|
||||||
import { fetchAllNotesForUser } from "@/features/notes/queries";
|
import { fetchAllNotesForUser } from "@/features/notes/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);
|
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
RiWallet3Line,
|
RiWallet3Line,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
import { PayerCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card";
|
import { PayerCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card";
|
||||||
import { PayerHeaderCard } from "@/features/payers/components/details/payer-header-card";
|
import { PayerHeaderCard } from "@/features/payers/components/details/payer-header-card";
|
||||||
import { PayerHistoryCard } from "@/features/payers/components/details/payer-history-card";
|
import { PayerHistoryCard } from "@/features/payers/components/details/payer-history-card";
|
||||||
@@ -91,6 +92,7 @@ const createEmptySlugMaps = (): SlugMaps => ({
|
|||||||
type OptionSet = ReturnType<typeof buildOptionSets>;
|
type OptionSet = ReturnType<typeof buildOptionSets>;
|
||||||
|
|
||||||
export default async function Page({ params, searchParams }: PageProps) {
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const { payerId } = await params;
|
const { payerId } = await params;
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { PayersPage } from "@/features/payers/components/payers-page";
|
import { PayersPage } from "@/features/payers/components/payers-page";
|
||||||
import { fetchPayersForUser } from "@/features/payers/queries";
|
import { fetchPayersForUser } from "@/features/payers/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const { payers, avatarOptions } = await fetchPayersForUser(userId);
|
const { payers, avatarOptions } = await fetchPayersForUser(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { RiBankCard2Line } from "@remixicon/react";
|
import { RiBankCard2Line } from "@remixicon/react";
|
||||||
|
import { connection } from "next/server";
|
||||||
import { fetchCartoesReportData } from "@/features/reports/cards-report-queries";
|
import { fetchCartoesReportData } from "@/features/reports/cards-report-queries";
|
||||||
import { CardCategoryBreakdown } from "@/features/reports/components/cards/card-category-breakdown";
|
import { CardCategoryBreakdown } from "@/features/reports/components/cards/card-category-breakdown";
|
||||||
import { CardInvoiceStatus } from "@/features/reports/components/cards/card-invoice-status";
|
import { CardInvoiceStatus } from "@/features/reports/components/cards/card-invoice-status";
|
||||||
@@ -28,6 +29,7 @@ const getSingleParam = (
|
|||||||
export default async function RelatorioCartoesPage({
|
export default async function RelatorioCartoesPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
import type { Category } from "@/db/schema";
|
import type { Category } from "@/db/schema";
|
||||||
import { fetchCategoryChartData } from "@/features/reports/category-chart-queries";
|
import { fetchCategoryChartData } from "@/features/reports/category-chart-queries";
|
||||||
import { fetchCategoryReport } from "@/features/reports/category-report-queries";
|
import { fetchCategoryReport } from "@/features/reports/category-report-queries";
|
||||||
@@ -29,6 +30,7 @@ const getSingleParam = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
// Get authenticated user
|
// Get authenticated user
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { EstablishmentsList } from "@/features/reports/components/establishments/establishments-list";
|
import { EstablishmentsList } from "@/features/reports/components/establishments/establishments-list";
|
||||||
import { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards";
|
import { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards";
|
||||||
import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter";
|
import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter";
|
||||||
@@ -36,6 +37,7 @@ const validatePeriodFilter = (value: string | null): PeriodFilter => {
|
|||||||
export default async function TopEstabelecimentosPage({
|
export default async function TopEstabelecimentosPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
||||||
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const data = await fetchInstallmentAnalysis(user.id);
|
const data = await fetchInstallmentAnalysis(user.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RiAndroidLine, 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";
|
||||||
|
import { connection } from "next/server";
|
||||||
|
|
||||||
import { CompanionTab } from "@/features/settings/components/companion-tab";
|
import { CompanionTab } from "@/features/settings/components/companion-tab";
|
||||||
import { DeleteAccountForm } from "@/features/settings/components/delete-account-form";
|
import { DeleteAccountForm } from "@/features/settings/components/delete-account-form";
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
import { auth } from "@/shared/lib/auth/config";
|
import { auth } from "@/shared/lib/auth/config";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
});
|
});
|
||||||
@@ -65,7 +67,7 @@ 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-xl font-bold mb-1">Preferências</h2>
|
<h2 className="text-xl font-medium mb-1">Preferências</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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.
|
||||||
@@ -90,7 +92,7 @@ export default async function Page() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h2 className="text-xl font-bold">OpenMonetis Companion</h2>
|
<h2 className="text-xl font-medium">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">
|
<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" />
|
<RiAndroidLine className="h-3 w-3" />
|
||||||
Android
|
Android
|
||||||
@@ -112,7 +114,7 @@ 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-xl font-bold mb-1">Alterar nome</h2>
|
<h2 className="text-xl font-medium mb-1">Alterar nome</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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.
|
||||||
@@ -128,7 +130,7 @@ 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-xl font-bold mb-1">Alterar senha</h2>
|
<h2 className="text-xl font-medium mb-1">Alterar senha</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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.
|
||||||
@@ -144,7 +146,7 @@ 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-xl font-bold mb-1">Passkeys</h2>
|
<h2 className="text-xl font-medium mb-1">Passkeys</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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.
|
||||||
@@ -160,7 +162,7 @@ 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-xl font-bold mb-1">Alterar e-mail</h2>
|
<h2 className="text-xl font-medium mb-1">Alterar e-mail</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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
|
||||||
@@ -180,7 +182,7 @@ 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-xl font-bold mb-1 text-destructive">
|
<h2 className="text-xl font-medium mb-1 text-destructive">
|
||||||
Ações perigosas
|
Ações perigosas
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { ImportPage } from "@/features/transactions/components/import/import-page";
|
import { ImportPage } from "@/features/transactions/components/import/import-page";
|
||||||
import {
|
import {
|
||||||
buildOptionSets,
|
buildOptionSets,
|
||||||
@@ -7,6 +8,7 @@ import { fetchTransactionFilterSources } from "@/features/transactions/queries";
|
|||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const filterSources = await fetchTransactionFilterSources(userId);
|
const filterSources = await fetchTransactionFilterSources(userId);
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { connection } from "next/server";
|
||||||
import { fetchUserPreferences } from "@/features/settings/queries";
|
import { fetchUserPreferences } from "@/features/settings/queries";
|
||||||
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
|
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +28,7 @@ type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default async function Page() {
|
|||||||
Projeto Open Source
|
Projeto Open Source
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight">
|
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-medium tracking-tight">
|
||||||
Suas finanças,
|
Suas finanças,
|
||||||
<span className="text-primary"> do seu jeito</span>
|
<span className="text-primary"> do seu jeito</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -207,7 +207,7 @@ export default async function Page() {
|
|||||||
className="flex flex-col items-center text-center gap-1.5"
|
className="flex flex-col items-center text-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Icon className="size-5" style={{ color: colorVar }} />
|
<Icon className="size-5" style={{ color: colorVar }} />
|
||||||
<span className="text-2xl md:text-3xl font-bold">
|
<span className="text-2xl md:text-3xl font-medium">
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs md:text-sm text-muted-foreground">
|
<span className="text-xs md:text-sm text-muted-foreground">
|
||||||
@@ -229,7 +229,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Conheça as telas
|
Conheça as telas
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Veja o que você pode fazer
|
Veja o que você pode fazer
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -254,7 +254,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
O que tem aqui
|
O que tem aqui
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Funcionalidades que importam
|
Funcionalidades que importam
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -282,7 +282,7 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
|
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
@@ -346,7 +346,7 @@ export default async function Page() {
|
|||||||
<RiSmartphoneLine className="size-3.5 mr-1" />
|
<RiSmartphoneLine className="size-3.5 mr-1" />
|
||||||
Mobile
|
Mobile
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Use o OpenMonetis no celular sem perder o fluxo
|
Use o OpenMonetis no celular sem perder o fluxo
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -384,7 +384,7 @@ export default async function Page() {
|
|||||||
<RiSmartphoneLine className="size-3.5 mr-1" />
|
<RiSmartphoneLine className="size-3.5 mr-1" />
|
||||||
PWA instalável
|
PWA instalável
|
||||||
</Badge>
|
</Badge>
|
||||||
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3">
|
<h3 className="text-2xl md:text-3xl font-medium tracking-tight mb-3">
|
||||||
Leve o OpenMonetis para a tela inicial
|
Leve o OpenMonetis para a tela inicial
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||||
@@ -430,7 +430,7 @@ export default async function Page() {
|
|||||||
Companion Android
|
Companion Android
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3">
|
<h3 className="text-2xl md:text-3xl font-medium tracking-tight mb-3">
|
||||||
Capture, envie e revise no mesmo fluxo
|
Capture, envie e revise no mesmo fluxo
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||||
@@ -441,7 +441,7 @@ export default async function Page() {
|
|||||||
{companionSteps.map((step, index) => (
|
{companionSteps.map((step, index) => (
|
||||||
<li key={step.title} className="flex items-start gap-3">
|
<li key={step.title} className="flex items-start gap-3">
|
||||||
<span
|
<span
|
||||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
|
||||||
color: step.colorVar,
|
color: step.colorVar,
|
||||||
@@ -529,7 +529,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Stack técnica
|
Stack técnica
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
O que roda por baixo
|
O que roda por baixo
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -556,7 +556,7 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
|
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
|
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
|
||||||
@@ -582,7 +582,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Como usar
|
Como usar
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Rode no seu computador
|
Rode no seu computador
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
||||||
@@ -617,7 +617,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Para quem é?
|
Para quem é?
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Feito para quem gosta de controle
|
Feito para quem gosta de controle
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
||||||
@@ -644,7 +644,7 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-1">{item.title}</h3>
|
<h3 className="font-medium mb-1">{item.title}</h3>
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -664,7 +664,7 @@ export default async function Page() {
|
|||||||
<div className="max-w-8xl mx-auto px-4">
|
<div className="max-w-8xl mx-auto px-4">
|
||||||
<AnimateOnScroll>
|
<AnimateOnScroll>
|
||||||
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
|
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||||
Pronto para testar?
|
Pronto para testar?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
|
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
|
||||||
@@ -715,7 +715,7 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-3 md:mb-4">Projeto</h3>
|
<h3 className="font-medium mb-3 md:mb-4">Projeto</h3>
|
||||||
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
@@ -749,7 +749,7 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-3 md:mb-4">Companion</h3>
|
<h3 className="font-medium mb-3 md:mb-4">Companion</h3>
|
||||||
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
27
src/app/api/attachments/[attachmentId]/presign/route.ts
Normal file
27
src/app/api/attachments/[attachmentId]/presign/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { attachments } from "@/db/schema";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ attachmentId: string }> },
|
||||||
|
) {
|
||||||
|
const [userId, { attachmentId }] = await Promise.all([getUserId(), params]);
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ fileKey: attachments.fileKey })
|
||||||
|
.from(attachments)
|
||||||
|
.where(
|
||||||
|
and(eq(attachments.id, attachmentId), eq(attachments.userId, userId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await createPresignedGetUrl(row.fileKey);
|
||||||
|
return NextResponse.json({ url });
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
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";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -33,9 +34,9 @@ export default function RootLayout({
|
|||||||
data-domains="openmonetis.com"
|
data-domains="openmonetis.com"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className="subpixel-antialiased" suppressHydrationWarning>
|
<body className="antialiased" suppressHydrationWarning>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light">
|
<ThemeProvider attribute="class" defaultTheme="light">
|
||||||
{children}
|
<Suspense>{children}</Suspense>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
const BASE_URL = process.env.PUBLIC_DOMAIN
|
|
||||||
? `https://${process.env.PUBLIC_DOMAIN}`
|
|
||||||
: "https://openmonetis.com";
|
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
rules: [
|
rules: [
|
||||||
@@ -21,15 +17,15 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
"/notes",
|
"/notes",
|
||||||
"/insights",
|
"/insights",
|
||||||
"/calendar",
|
"/calendar",
|
||||||
"/consultor",
|
"/attachments",
|
||||||
"/settings",
|
"/settings",
|
||||||
"/reports",
|
"/reports",
|
||||||
"/inbox",
|
"/inbox",
|
||||||
"/login",
|
"/login",
|
||||||
|
"/signup",
|
||||||
"/api/",
|
"/api/",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sitemap: `${BASE_URL}/sitemap.xml`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,9 +88,7 @@ export function AccountCard({
|
|||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-medium text-foreground">{accountName}</h2>
|
||||||
{accountName}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function AccountStatementCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-sm font-semibold text-foreground">
|
<h2 className="truncate text-sm font-medium text-foreground">
|
||||||
{accountName}
|
{accountName}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -86,12 +86,12 @@ export function AccountStatementCard({
|
|||||||
</p>
|
</p>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={currentBalance}
|
amount={currentBalance}
|
||||||
className="text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]"
|
className="text-3xl leading-none font-medium tracking-tight sm:text-[2rem]"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant={getAccountStatusBadgeVariant(status)}
|
variant={getAccountStatusBadgeVariant(status)}
|
||||||
className="text-[11px]"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -107,7 +107,7 @@ export function AccountStatementCard({
|
|||||||
label="Saldo inicial"
|
label="Saldo inicial"
|
||||||
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
|
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
|
||||||
>
|
>
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{formatCurrency(openingBalance)}
|
{formatCurrency(openingBalance)}
|
||||||
</span>
|
</span>
|
||||||
</MetaItem>
|
</MetaItem>
|
||||||
@@ -116,7 +116,7 @@ export function AccountStatementCard({
|
|||||||
label="Entradas"
|
label="Entradas"
|
||||||
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
|
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
|
||||||
>
|
>
|
||||||
<span className="text-sm font-semibold text-success">
|
<span className="text-sm font-medium text-success">
|
||||||
{formatCurrency(totalIncomes)}
|
{formatCurrency(totalIncomes)}
|
||||||
</span>
|
</span>
|
||||||
</MetaItem>
|
</MetaItem>
|
||||||
@@ -125,7 +125,7 @@ export function AccountStatementCard({
|
|||||||
label="Saídas"
|
label="Saídas"
|
||||||
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
|
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
|
||||||
>
|
>
|
||||||
<span className="text-sm font-semibold text-destructive">
|
<span className="text-sm font-medium text-destructive">
|
||||||
{formatCurrency(totalExpenses)}
|
{formatCurrency(totalExpenses)}
|
||||||
</span>
|
</span>
|
||||||
</MetaItem>
|
</MetaItem>
|
||||||
@@ -136,7 +136,7 @@ export function AccountStatementCard({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold",
|
"text-sm font-medium",
|
||||||
resultado >= 0 ? "text-success" : "text-destructive",
|
resultado >= 0 ? "text-success" : "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
208
src/features/attachments/components/attachment-grid-item.tsx
Normal file
208
src/features/attachments/components/attachment-grid-item.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RiFileLine, RiFilePdf2Line, RiImageLine } from "@remixicon/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
|
import { formatDate } from "@/shared/utils/date";
|
||||||
|
import { formatBytes } from "@/shared/utils/number";
|
||||||
|
|
||||||
|
interface PdfCanvasProps {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfCanvas({ url }: PdfCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [locked, setLocked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLocked(false);
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
const pdfjsLib = await import("pdfjs-dist");
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
||||||
|
|
||||||
|
let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;
|
||||||
|
try {
|
||||||
|
pdf = await pdfjsLib.getDocument(url).promise;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { name?: string }).name === "PasswordException") {
|
||||||
|
if (!cancelled) setLocked(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await pdf.getPage(1);
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || cancelled) return;
|
||||||
|
|
||||||
|
const containerWidth = canvas.parentElement?.offsetWidth ?? 200;
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const scale = containerWidth / viewport.width;
|
||||||
|
const scaled = page.getViewport({ scale });
|
||||||
|
|
||||||
|
canvas.width = scaled.width;
|
||||||
|
canvas.height = scaled.height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
await page.render({ canvasContext: ctx, canvas, viewport: scaled })
|
||||||
|
.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
render().catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
|
||||||
|
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground/60">
|
||||||
|
PDF Protegido
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentGridItemProps {
|
||||||
|
attachment: AttachmentForPeriod;
|
||||||
|
url?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
onDetails: () => void;
|
||||||
|
isLoadingDetails?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentGridItem({
|
||||||
|
attachment,
|
||||||
|
url,
|
||||||
|
onClick,
|
||||||
|
onDetails,
|
||||||
|
isLoadingDetails = false,
|
||||||
|
}: AttachmentGridItemProps) {
|
||||||
|
const isPdf = attachment.mimeType === "application/pdf";
|
||||||
|
const isImage = attachment.mimeType.startsWith("image/");
|
||||||
|
const amount = Number.parseFloat(attachment.transactionAmount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group flex flex-col overflow-hidden rounded-lg border bg-card transition-all duration-200 hover:border-primary">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="relative aspect-4/3 w-full border-b overflow-hidden bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* Conteúdo do thumbnail */}
|
||||||
|
{isImage && url && (
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
alt={attachment.fileName}
|
||||||
|
fill
|
||||||
|
unoptimized
|
||||||
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isImage && !url && (
|
||||||
|
<div className="h-full w-full animate-pulse bg-muted-foreground/10" />
|
||||||
|
)}
|
||||||
|
{isPdf && url && <PdfCanvas url={url} />}
|
||||||
|
{isPdf && !url && (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-red-50 dark:bg-red-950/20">
|
||||||
|
<RiFilePdf2Line className="size-14 text-red-400/60" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isImage && !isPdf && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||||
|
<RiFileLine className="size-14 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay no hover */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors duration-200 group-hover:bg-black/10" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Informações */}
|
||||||
|
<div className="flex flex-1 flex-col gap-3 px-4 py-3">
|
||||||
|
{/* Nome do arquivo + tipo */}
|
||||||
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
<div className="shrink-0 gap-0.5 text-xs opacity-60">
|
||||||
|
{isPdf && <RiFilePdf2Line className="size-4 text-red-500" />}
|
||||||
|
{isImage && <RiImageLine className="size-4 text-blue-500" />}
|
||||||
|
{!isPdf && !isImage && <RiFileLine className="size-4" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className="truncate text-sm font-medium leading-tight text-foreground">
|
||||||
|
{attachment.fileName}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
{attachment.fileName}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data */}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(attachment.purchaseDate)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Transação e Valor */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
|
{attachment.transactionName}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{attachment.transactionName}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: Tamanho + Botão Detalhes */}
|
||||||
|
<div className="mt-auto flex items-center justify-between border-t pt-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground/70">
|
||||||
|
{formatBytes(attachment.fileSize)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDetails}
|
||||||
|
disabled={isLoadingDetails}
|
||||||
|
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoadingDetails ? "Carregando..." : "Detalhes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/features/attachments/components/attachment-preview.tsx
Normal file
201
src/features/attachments/components/attachment-preview.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiArrowLeftSLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
RiCloseLine,
|
||||||
|
RiDownloadLine,
|
||||||
|
RiExternalLinkLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
|
||||||
|
interface AttachmentPreviewProps {
|
||||||
|
attachments: AttachmentForPeriod[];
|
||||||
|
selectedIndex: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentPreview({
|
||||||
|
attachments,
|
||||||
|
selectedIndex,
|
||||||
|
onClose,
|
||||||
|
}: AttachmentPreviewProps) {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const open = selectedIndex >= 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIndex >= 0) setCurrentIndex(selectedIndex);
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowLeft") setCurrentIndex((i) => Math.max(0, i - 1));
|
||||||
|
if (e.key === "ArrowRight")
|
||||||
|
setCurrentIndex((i) => Math.min(attachments.length - 1, i + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [open, attachments.length]);
|
||||||
|
|
||||||
|
const attachment = attachments[currentIndex];
|
||||||
|
const attachmentId = attachment?.attachmentId;
|
||||||
|
|
||||||
|
// Busca URL fresca a cada troca de anexo
|
||||||
|
useEffect(() => {
|
||||||
|
if (!attachmentId) return;
|
||||||
|
setPreviewUrl(null);
|
||||||
|
|
||||||
|
fetch(`/api/attachments/${attachmentId}/presign`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { url: string }) => setPreviewUrl(data.url))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [attachmentId]);
|
||||||
|
|
||||||
|
if (!attachment) return null;
|
||||||
|
|
||||||
|
const isPdf = attachment.mimeType === "application/pdf";
|
||||||
|
const isImage = attachment.mimeType.startsWith("image/");
|
||||||
|
const hasPrev = currentIndex > 0;
|
||||||
|
const hasNext = currentIndex < attachments.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="flex h-[92vh] w-[min(96vw,1400px)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:p-0"
|
||||||
|
>
|
||||||
|
<DialogHeader className="flex-row items-start justify-between gap-3 border-b px-4 py-3 sm:px-5">
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
<DialogTitle
|
||||||
|
className="truncate text-sm font-medium"
|
||||||
|
title={attachment.transactionName}
|
||||||
|
>
|
||||||
|
{attachment.transactionName}
|
||||||
|
</DialogTitle>
|
||||||
|
<p
|
||||||
|
className="truncate text-xs text-muted-foreground"
|
||||||
|
title={attachment.fileName}
|
||||||
|
>
|
||||||
|
{attachment.fileName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{attachments.length > 1 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setCurrentIndex((i) => i - 1)}
|
||||||
|
title="Anterior (←)"
|
||||||
|
>
|
||||||
|
<RiArrowLeftSLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="select-none text-xs text-muted-foreground tabular-nums">
|
||||||
|
{currentIndex + 1} / {attachments.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setCurrentIndex((i) => i + 1)}
|
||||||
|
title="Próximo (→)"
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!previewUrl}
|
||||||
|
asChild={!!previewUrl}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<a
|
||||||
|
href={previewUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
download={attachment.fileName}
|
||||||
|
>
|
||||||
|
<RiDownloadLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<RiDownloadLine className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!previewUrl}
|
||||||
|
asChild={!!previewUrl}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<a href={previewUrl} target="_blank" rel="noreferrer">
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="ghost" size="icon">
|
||||||
|
<RiCloseLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 min-w-0 flex-1">
|
||||||
|
{!previewUrl && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPdf && previewUrl && (
|
||||||
|
<iframe
|
||||||
|
key={attachment.attachmentId}
|
||||||
|
src={previewUrl}
|
||||||
|
className="h-full w-full border-0 bg-background"
|
||||||
|
title={attachment.fileName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isImage && previewUrl && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-black/85 p-4 sm:p-6">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
key={attachment.attachmentId}
|
||||||
|
src={previewUrl}
|
||||||
|
alt={attachment.fileName}
|
||||||
|
className="max-h-full max-w-full rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
src/features/attachments/components/attachments-page.tsx
Normal file
275
src/features/attachments/components/attachments-page.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiAttachmentLine,
|
||||||
|
RiFilePdf2Line,
|
||||||
|
RiImageLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type React from "react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { AttachmentGridItem } from "@/features/attachments/components/attachment-grid-item";
|
||||||
|
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
|
||||||
|
import { useAttachmentUrl } from "@/features/attachments/hooks/use-attachment-url";
|
||||||
|
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||||
|
import { fetchTransactionByIdAction } from "@/features/transactions/actions/fetch-by-id";
|
||||||
|
import type { TransactionDialogOptions } from "@/features/transactions/actions/fetch-dialog-options";
|
||||||
|
import { fetchTransactionDialogOptionsAction } from "@/features/transactions/actions/fetch-dialog-options";
|
||||||
|
import { TransactionDetailsDialog } from "@/features/transactions/components/dialogs/transaction-details-dialog";
|
||||||
|
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||||
|
import type { TransactionItem } from "@/features/transactions/components/types";
|
||||||
|
import { EmptyState } from "@/shared/components/empty-state";
|
||||||
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
|
import PageDescription from "@/shared/components/page-description";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
|
type FilterType = "all" | "images" | "pdfs";
|
||||||
|
|
||||||
|
function AttachmentGridItemWithUrl({
|
||||||
|
attachment,
|
||||||
|
onClick,
|
||||||
|
onDetails,
|
||||||
|
isLoadingDetails,
|
||||||
|
}: {
|
||||||
|
attachment: AttachmentForPeriod;
|
||||||
|
onClick: () => void;
|
||||||
|
onDetails: () => void;
|
||||||
|
isLoadingDetails: boolean;
|
||||||
|
}) {
|
||||||
|
const { url, containerRef } = useAttachmentUrl(attachment.attachmentId);
|
||||||
|
return (
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<AttachmentGridItem
|
||||||
|
attachment={attachment}
|
||||||
|
url={url ?? undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
onDetails={onDetails}
|
||||||
|
isLoadingDetails={isLoadingDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS: {
|
||||||
|
value: FilterType;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "all",
|
||||||
|
label: "Todos",
|
||||||
|
icon: <RiAttachmentLine className="size-3.5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "images",
|
||||||
|
label: "Imagens",
|
||||||
|
icon: <RiImageLine className="size-3.5 text-blue-500" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "pdfs",
|
||||||
|
label: "PDFs",
|
||||||
|
icon: <RiFilePdf2Line className="size-3.5 text-red-500" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AttachmentsPageProps {
|
||||||
|
attachments: AttachmentForPeriod[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [filter, setFilter] = useState<FilterType>("all");
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
const [transactionDetails, setTransactionDetails] =
|
||||||
|
useState<TransactionItem | null>(null);
|
||||||
|
const [loadingTransactionId, setLoadingTransactionId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Edit dialog state
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [transactionToEdit, setTransactionToEdit] =
|
||||||
|
useState<TransactionItem | null>(null);
|
||||||
|
const [dialogOptions, setDialogOptions] =
|
||||||
|
useState<TransactionDialogOptions | null>(null);
|
||||||
|
|
||||||
|
const filteredAttachments = attachments.filter((a) => {
|
||||||
|
if (filter === "images") return a.mimeType.startsWith("image/");
|
||||||
|
if (filter === "pdfs") return a.mimeType === "application/pdf";
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageCount = attachments.filter((a) =>
|
||||||
|
a.mimeType.startsWith("image/"),
|
||||||
|
).length;
|
||||||
|
const pdfCount = attachments.filter(
|
||||||
|
(a) => a.mimeType === "application/pdf",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const counts: Record<FilterType, number> = {
|
||||||
|
all: attachments.length,
|
||||||
|
images: imageCount,
|
||||||
|
pdfs: pdfCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleSelect(attachment: AttachmentForPeriod) {
|
||||||
|
const idx = filteredAttachments.findIndex(
|
||||||
|
(a) =>
|
||||||
|
a.attachmentId === attachment.attachmentId &&
|
||||||
|
a.transactionId === attachment.transactionId,
|
||||||
|
);
|
||||||
|
setSelectedIndex(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDetails(transactionId: string) {
|
||||||
|
setLoadingTransactionId(transactionId);
|
||||||
|
startTransition(async () => {
|
||||||
|
const transaction = await fetchTransactionByIdAction(transactionId);
|
||||||
|
setLoadingTransactionId(null);
|
||||||
|
if (transaction) setTransactionDetails(transaction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(transaction: TransactionItem) {
|
||||||
|
setTransactionToEdit(transaction);
|
||||||
|
startTransition(async () => {
|
||||||
|
const options = await fetchTransactionDialogOptionsAction();
|
||||||
|
setDialogOptions(options);
|
||||||
|
setEditOpen(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
<PageDescription
|
||||||
|
icon={<RiAttachmentLine className="size-5" />}
|
||||||
|
title="Anexos"
|
||||||
|
subtitle="Comprovantes e documentos dos seus lançamentos no mês."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MonthNavigation />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
{attachments.length === 0 ? (
|
||||||
|
<div className="flex w-full items-center justify-center py-12">
|
||||||
|
<EmptyState
|
||||||
|
media={<RiAttachmentLine className="size-6 text-primary" />}
|
||||||
|
title="Nenhum anexo neste mês"
|
||||||
|
description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header: filtros + contagem */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{filteredAttachments.length}{" "}
|
||||||
|
{filteredAttachments.length === 1 ? "anexo" : "anexos"}
|
||||||
|
{filter !== "all" &&
|
||||||
|
` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border p-1">
|
||||||
|
{FILTERS.map(({ value, label, icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFilter(value);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
filter === value
|
||||||
|
? "bg-primary text-primary-foreground [&_svg]:opacity-100"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(filter !== value && "opacity-60")}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
{label}{" "}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"tabular-nums",
|
||||||
|
filter === value ? "opacity-80" : "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
({counts[value]})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredAttachments.length === 0 ? (
|
||||||
|
<div className="flex w-full items-center justify-center py-12">
|
||||||
|
<EmptyState
|
||||||
|
media={<RiAttachmentLine className="size-6 text-primary" />}
|
||||||
|
title="Nenhum anexo encontrado"
|
||||||
|
description="Não há anexos do tipo selecionado neste mês."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{filteredAttachments.map((attachment) => (
|
||||||
|
<AttachmentGridItemWithUrl
|
||||||
|
key={`${attachment.attachmentId}-${attachment.transactionId}`}
|
||||||
|
attachment={attachment}
|
||||||
|
onClick={() => handleSelect(attachment)}
|
||||||
|
onDetails={() => handleDetails(attachment.transactionId)}
|
||||||
|
isLoadingDetails={
|
||||||
|
isPending &&
|
||||||
|
loadingTransactionId === attachment.transactionId
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AttachmentPreview
|
||||||
|
attachments={filteredAttachments}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onClose={() => setSelectedIndex(-1)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransactionDetailsDialog
|
||||||
|
open={!!transactionDetails}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setTransactionDetails(null);
|
||||||
|
}}
|
||||||
|
transaction={transactionDetails}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{dialogOptions && transactionToEdit && (
|
||||||
|
<TransactionDialog
|
||||||
|
mode="update"
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setEditOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setTransactionToEdit(null);
|
||||||
|
setDialogOptions(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
transaction={transactionToEdit}
|
||||||
|
payerOptions={dialogOptions.payerOptions}
|
||||||
|
splitPayerOptions={dialogOptions.splitPayerOptions}
|
||||||
|
defaultPayerId={dialogOptions.defaultPayerId}
|
||||||
|
accountOptions={dialogOptions.accountOptions}
|
||||||
|
cardOptions={dialogOptions.cardOptions}
|
||||||
|
categoryOptions={dialogOptions.categoryOptions}
|
||||||
|
estabelecimentos={dialogOptions.estabelecimentos}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/features/attachments/hooks/use-attachment-url.ts
Normal file
31
src/features/attachments/hooks/use-attachment-url.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function useAttachmentUrl(attachmentId: string) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUrl(null);
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries[0].isIntersecting) return;
|
||||||
|
observer.disconnect();
|
||||||
|
fetch(`/api/attachments/${attachmentId}/presign`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { url: string }) => setUrl(data.url))
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
{ rootMargin: "150px" },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [attachmentId]);
|
||||||
|
|
||||||
|
return { url, containerRef };
|
||||||
|
}
|
||||||
70
src/features/attachments/queries.ts
Normal file
70
src/features/attachments/queries.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
|
import {
|
||||||
|
attachments,
|
||||||
|
categories,
|
||||||
|
transactionAttachments,
|
||||||
|
transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
|
||||||
|
export type AttachmentForPeriod = {
|
||||||
|
attachmentId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
mimeType: string;
|
||||||
|
transactionId: string;
|
||||||
|
transactionName: string;
|
||||||
|
transactionAmount: string;
|
||||||
|
transactionPeriod: string;
|
||||||
|
purchaseDate: Date;
|
||||||
|
categoryName: string | null;
|
||||||
|
categoryIcon: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAttachmentsForPeriod(
|
||||||
|
userId: string,
|
||||||
|
period: string,
|
||||||
|
): Promise<AttachmentForPeriod[]> {
|
||||||
|
"use cache";
|
||||||
|
cacheTag(`dashboard-${userId}`);
|
||||||
|
cacheLife({ revalidate: 3 });
|
||||||
|
|
||||||
|
const adminPayerId = await getAdminPayerId(userId);
|
||||||
|
if (!adminPayerId) return [];
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
attachmentId: attachments.id,
|
||||||
|
fileName: attachments.fileName,
|
||||||
|
fileSize: attachments.fileSize,
|
||||||
|
mimeType: attachments.mimeType,
|
||||||
|
transactionId: transactions.id,
|
||||||
|
transactionName: transactions.name,
|
||||||
|
transactionAmount: transactions.amount,
|
||||||
|
transactionPeriod: transactions.period,
|
||||||
|
purchaseDate: transactions.purchaseDate,
|
||||||
|
categoryName: categories.name,
|
||||||
|
categoryIcon: categories.icon,
|
||||||
|
})
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.innerJoin(
|
||||||
|
attachments,
|
||||||
|
and(
|
||||||
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
|
eq(attachments.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
transactions,
|
||||||
|
and(
|
||||||
|
eq(transactionAttachments.transactionId, transactions.id),
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
eq(transactions.payerId, adminPayerId),
|
||||||
|
eq(transactions.period, period),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.leftJoin(categories, eq(transactions.categoryId, categories.id))
|
||||||
|
.orderBy(desc(transactions.purchaseDate), desc(attachments.id));
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ interface AuthHeaderProps {
|
|||||||
|
|
||||||
export function AuthHeader({ title, description }: AuthHeaderProps) {
|
export function AuthHeader({ title, description }: AuthHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-2")}>
|
<div className={cn("flex flex-col gap-2.5")}>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-card-foreground">
|
<h1 className="text-2xl font-medium tracking-tight text-card-foreground">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{description ? (
|
{description ? (
|
||||||
|
|||||||
89
src/features/auth/components/auth-sidebar-invoices-mock.tsx
Normal file
89
src/features/auth/components/auth-sidebar-invoices-mock.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
|
|
||||||
|
type MockInvoice = {
|
||||||
|
cardName: string;
|
||||||
|
logo: string;
|
||||||
|
amount: number;
|
||||||
|
dueLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_INVOICES: MockInvoice[] = [
|
||||||
|
{
|
||||||
|
cardName: "Nubank",
|
||||||
|
logo: "nubank.png",
|
||||||
|
amount: 1898,
|
||||||
|
dueLabel: "Vence em 3 dias",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cardName: "Itaú",
|
||||||
|
logo: "itau.png",
|
||||||
|
amount: 1923,
|
||||||
|
dueLabel: "Vence em 8 dias",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function MockInvoiceItem({
|
||||||
|
invoice,
|
||||||
|
divider,
|
||||||
|
}: {
|
||||||
|
invoice: MockInvoice;
|
||||||
|
divider: boolean;
|
||||||
|
}) {
|
||||||
|
const logoSrc = resolveLogoSrc(invoice.logo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={divider ? "border-b border-border/60" : undefined}>
|
||||||
|
<div className="flex items-center justify-between py-2.5">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-0.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full">
|
||||||
|
{logoSrc && (
|
||||||
|
<Image
|
||||||
|
src={logoSrc}
|
||||||
|
alt={`Logo ${invoice.cardName}`}
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{invoice.cardName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{invoice.dueLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||||
|
<span className="text-sm font-medium tracking-tighter text-foreground">
|
||||||
|
{formatCurrency(invoice.amount)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-primary">Pagar</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthSidebarInvoicesMock() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card shadow-sm">
|
||||||
|
<div className="border-b px-4 py-3">
|
||||||
|
<span className="text-sm font-medium text-foreground">Faturas</span>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Resumo das faturas do período
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4">
|
||||||
|
{MOCK_INVOICES.map((invoice, index) => (
|
||||||
|
<MockInvoiceItem
|
||||||
|
key={invoice.cardName}
|
||||||
|
invoice={invoice}
|
||||||
|
divider={index < MOCK_INVOICES.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,28 @@
|
|||||||
|
import {
|
||||||
|
RiBankCardLine,
|
||||||
|
RiBarChart2Line,
|
||||||
|
RiShieldCheckLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
import { Logo } from "@/shared/components/logo";
|
import { Logo } from "@/shared/components/logo";
|
||||||
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
||||||
|
import { AuthSidebarInvoicesMock } from "./auth-sidebar-invoices-mock";
|
||||||
|
|
||||||
|
function FeatureItem({
|
||||||
|
icon: Icon,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
text: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-black/12">
|
||||||
|
<Icon className="h-3.5 w-3.5 text-black/55" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-black/68">{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AuthSidebar() {
|
function AuthSidebar() {
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +38,7 @@ function AuthSidebar() {
|
|||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-linear-to-br from-white/9 via-transparent to-black/7" />
|
<div className="absolute inset-0 bg-linear-to-br from-white/9 via-transparent to-black/7" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex flex-1 flex-col justify-between p-10 lg:p-12">
|
<div className="relative flex flex-1 flex-col justify-between p-10 lg:p-12">
|
||||||
<Logo
|
<Logo
|
||||||
variant="compact"
|
variant="compact"
|
||||||
@@ -22,14 +46,25 @@ function AuthSidebar() {
|
|||||||
className="opacity-92 [&_img]:brightness-0 [&_img]:saturate-0"
|
className="opacity-92 [&_img]:brightness-0 [&_img]:saturate-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="max-w-sm space-y-4.5">
|
<div className="flex flex-1 items-center justify-center py-10">
|
||||||
<h2 className="text-[2rem] font-semibold leading-[1.04] tracking-[-0.03em] text-black/84 lg:text-[2.35rem]">
|
<div className="w-full rotate-[1.5deg]">
|
||||||
Controle suas finanças com clareza e foco diário.
|
<AuthSidebarInvoicesMock />
|
||||||
</h2>
|
</div>
|
||||||
<p className="max-w-2xs text-sm leading-6 text-black/68">
|
</div>
|
||||||
Centralize despesas, organize cartões e acompanhe metas mensais em
|
|
||||||
um painel inteligente feito para o seu dia a dia.
|
<div className="space-y-3">
|
||||||
</p>
|
<FeatureItem
|
||||||
|
icon={RiBarChart2Line}
|
||||||
|
text="Controle de gastos por categoria"
|
||||||
|
/>
|
||||||
|
<FeatureItem
|
||||||
|
icon={RiBankCardLine}
|
||||||
|
text="Faturas e cartões centralizados"
|
||||||
|
/>
|
||||||
|
<FeatureItem
|
||||||
|
icon={RiShieldCheckLine}
|
||||||
|
text="Seus dados, sem rastreamento"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export function LoginForm({ className, ...props }: DivProps) {
|
|||||||
</a>
|
</a>
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
|
||||||
<FieldDescription className="text-center text-[13px] text-muted-foreground">
|
<FieldDescription className="text-center text-sm text-muted-foreground">
|
||||||
<a href="/" className={authLinkClassName}>
|
<a href="/" className={authLinkClassName}>
|
||||||
Voltar para a página inicial
|
Voltar para a página inicial
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ export function SignupForm({ className, ...props }: DivProps) {
|
|||||||
</a>
|
</a>
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
|
||||||
<FieldDescription className="text-center text-[13px] text-muted-foreground">
|
<FieldDescription className="text-center text-sm text-muted-foreground">
|
||||||
<a href="/" className={authLinkClassName}>
|
<a href="/" className={authLinkClassName}>
|
||||||
Voltar para a página inicial
|
Voltar para a página inicial
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function BudgetCard({
|
|||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-base font-semibold leading-tight">
|
<h3 className="text-base font-medium leading-tight">
|
||||||
{formatCategoryName(budget)}
|
{formatCategoryName(budget)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export function BudgetDialog({
|
|||||||
<div className="space-y-3 rounded-md border p-3">
|
<div className="space-y-3 rounded-md border p-3">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Limite atual</span>
|
<span className="text-muted-foreground">Limite atual</span>
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{formatCurrency(sliderValue)}
|
{formatCurrency(sliderValue)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function CalendarGrid({
|
|||||||
}: CalendarGridProps) {
|
}: CalendarGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
|
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
|
||||||
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<div className="grid grid-cols-7 text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{WEEK_DAYS_SHORT.map((dayName) => (
|
{WEEK_DAYS_SHORT.map((dayName) => (
|
||||||
<span key={dayName} className="px-3 py-2 text-center text-primary">
|
<span key={dayName} className="px-3 py-2 text-center text-primary">
|
||||||
{dayName}
|
{dayName}
|
||||||
|
|||||||
@@ -110,9 +110,7 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
|||||||
<span className="truncate">{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{complement ? (
|
{complement ? (
|
||||||
<span
|
<span className={cn("shrink-0 font-medium", style.accent ?? "text-xs")}>
|
||||||
className={cn("shrink-0 font-semibold", style.accent ?? "text-xs")}
|
|
||||||
>
|
|
||||||
{complement}
|
{complement}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -153,7 +151,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold leading-none",
|
"text-sm font-medium leading-none",
|
||||||
day.isToday
|
day.isToday
|
||||||
? "text-primary-foreground bg-primary size-5 rounded-full flex items-center justify-center"
|
? "text-primary-foreground bg-primary size-5 rounded-full flex items-center justify-center"
|
||||||
: "text-foreground/90",
|
: "text-foreground/90",
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const renderLancamento = (
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-semibold leading-tight ${
|
className={`text-sm font-medium leading-tight ${
|
||||||
isPagamentoFatura && "text-success"
|
isPagamentoFatura && "text-success"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -74,7 +74,7 @@ const renderLancamento = (
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold whitespace-nowrap",
|
"text-sm font-medium whitespace-nowrap",
|
||||||
isReceita ? "text-success" : "text-foreground",
|
isReceita ? "text-success" : "text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -103,7 +103,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
<span className="text-sm font-semibold leading-tight">
|
<span className="text-sm font-medium leading-tight">
|
||||||
{event.transaction.name}
|
{event.transaction.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
|||||||
|
|
||||||
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
|
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">
|
<span className="font-medium">
|
||||||
<MoneyValues amount={event.transaction.amount} />
|
<MoneyValues amount={event.transaction.amount} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,7 +129,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
<span className="text-sm font-semibold leading-tight">
|
<span className="text-sm font-medium leading-tight">
|
||||||
Vencimento Invoice - {event.card.name}
|
Vencimento Invoice - {event.card.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +137,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
|||||||
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge>
|
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{event.card.totalDue !== null ? (
|
{event.card.totalDue !== null ? (
|
||||||
<span className="font-semibold">
|
<span className="font-medium">
|
||||||
<MoneyValues amount={event.card.totalDue} />
|
<MoneyValues amount={event.card.totalDue} />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function CardItem({
|
|||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
<h3 className="truncate text-sm font-medium text-foreground sm:text-base">
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
{note ? (
|
{note ? (
|
||||||
@@ -188,13 +188,13 @@ export function CardItem({
|
|||||||
<div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm">
|
<div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm">
|
||||||
<span>
|
<span>
|
||||||
Fecha dia{" "}
|
Fecha dia{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{formatDay(closingDay)}
|
{formatDay(closingDay)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Vence dia{" "}
|
Vence dia{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{formatDay(dueDay)}
|
{formatDay(dueDay)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -206,7 +206,7 @@ export function CardItem({
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
<MoneyValues amount={metrics[0].value} />
|
<MoneyValues amount={metrics[0].value} />
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
@@ -215,7 +215,7 @@ export function CardItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
<span className="size-2 rounded-full bg-primary" />
|
<span className="size-2 rounded-full bg-primary" />
|
||||||
<MoneyValues amount={metrics[1].value} />
|
<MoneyValues amount={metrics[1].value} />
|
||||||
</p>
|
</p>
|
||||||
@@ -225,7 +225,7 @@ export function CardItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
<MoneyValues amount={metrics[2].value} />
|
<MoneyValues amount={metrics[2].value} />
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function CategoryDetailHeader({
|
|||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-xl font-semibold leading-tight">
|
<h1 className="text-xl font-medium leading-tight">
|
||||||
{category.name}
|
{category.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
@@ -99,7 +99,7 @@ export function CategoryDetailHeader({
|
|||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Total em {currentPeriodLabel}
|
Total em {currentPeriodLabel}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-2xl font-semibold">
|
<p className="mt-1 text-2xl font-medium">
|
||||||
{currencyFormatter.format(currentTotal)}
|
{currencyFormatter.format(currentTotal)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +117,7 @@ export function CategoryDetailHeader({
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 flex items-center gap-1 text-xl font-semibold",
|
"mt-1 flex items-center gap-1 text-xl font-medium",
|
||||||
variationColor,
|
variationColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function BillPaymentDialog({
|
|||||||
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Boleto
|
Boleto
|
||||||
</p>
|
</p>
|
||||||
<p className="text-base font-semibold text-foreground">
|
<p className="text-base font-medium text-foreground">
|
||||||
{bill.name}
|
{bill.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,24 +107,24 @@ export function BillPaymentDialog({
|
|||||||
<div className="rounded-xl border p-3">
|
<div className="rounded-xl border p-3">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||||
<RiMoneyDollarCircleLine className="size-3.5" />
|
<RiMoneyDollarCircleLine className="size-3.5" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide">
|
<span className="text-xs font-medium uppercase tracking-wide">
|
||||||
Valor
|
Valor
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={bill.amount}
|
amount={bill.amount}
|
||||||
className="text-lg font-bold"
|
className="text-lg font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border p-3">
|
<div className="rounded-xl border p-3">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||||
<RiCalendarLine className="size-3.5" />
|
<RiCalendarLine className="size-3.5" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide">
|
<span className="text-xs font-medium uppercase tracking-wide">
|
||||||
Vencimento
|
Vencimento
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
{dueLabel?.replace("Vencimento: ", "") ?? "—"}
|
{dueLabel?.replace("Vencimento: ", "") ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -343,10 +343,10 @@ export function CategoryBreakdownWidgetView({
|
|||||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
<span className="text-xs uppercase text-muted-foreground">
|
||||||
{entry.name}
|
{entry.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-bold text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{formatCurrency(entry.value)}
|
{formatCurrency(entry.value)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export function DashboardGridEditable({
|
|||||||
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
|
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<RiDragMove2Line className="size-8 text-primary" />
|
<RiDragMove2Line className="size-8 text-primary" />
|
||||||
<span className="text-xs font-bold">
|
<span className="text-xs font-medium">
|
||||||
Arraste para mover
|
Arraste para mover
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -134,12 +134,12 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
|||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
|
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
className="text-[1.55rem] leading-none font-medium"
|
className="text-2xl leading-none"
|
||||||
amount={metric.current}
|
amount={metric.current}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 text-xs font-medium",
|
"inline-flex items-center gap-1 text-xs ",
|
||||||
trendBadgeClass,
|
trendBadgeClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -150,7 +150,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
|||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
className="inline text-xs font-medium text-muted-foreground"
|
className="inline text-xs text-muted-foreground"
|
||||||
amount={metric.previous}
|
amount={metric.previous}
|
||||||
/>
|
/>
|
||||||
<span className="ml-1">no mês anterior</span>
|
<span className="ml-1">no mês anterior</span>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function DashboardWelcome({ name }: { name?: string | null }) {
|
|||||||
return (
|
return (
|
||||||
<section className="py-4">
|
<section className="py-4">
|
||||||
<div className="tracking-tight">
|
<div className="tracking-tight">
|
||||||
<h1 className="text-xl">
|
<h1 className="text-xl font-medium">
|
||||||
{greeting}, {displayName}
|
{greeting}, {displayName}
|
||||||
</h1>
|
</h1>
|
||||||
<h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2>
|
<h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2>
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function InstallmentAnalysisPage({
|
|||||||
</p>
|
</p>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={grandTotal}
|
amount={grandTotal}
|
||||||
className="text-3xl font-bold text-primary"
|
className="text-3xl font-medium text-primary"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
|
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function InstallmentGroupCard({
|
|||||||
<span className="text-xs text-muted-foreground">Total:</span>
|
<span className="text-xs text-muted-foreground">Total:</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={totalAmount}
|
amount={totalAmount}
|
||||||
className="text-base font-bold"
|
className="text-base font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-semibold text-foreground">
|
<div className="text-sm font-medium text-foreground">
|
||||||
<MoneyValues amount={share.amount} />
|
<MoneyValues amount={share.amount} />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function InvoiceLogo({
|
|||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold uppercase text-muted-foreground",
|
"text-sm font-medium uppercase text-muted-foreground",
|
||||||
tone === "accent" && "text-primary",
|
tone === "accent" && "text-primary",
|
||||||
fallbackClassName,
|
fallbackClassName,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ export function InvoicePaymentDialog({
|
|||||||
fallbackClassName="text-xs"
|
fallbackClassName="text-xs"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Cartão
|
Cartão
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-base font-semibold text-foreground">
|
<p className="truncate text-base font-medium text-foreground">
|
||||||
{invoice.cardName}
|
{invoice.cardName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,26 +124,26 @@ export function InvoicePaymentDialog({
|
|||||||
<div className="rounded-xl border p-3">
|
<div className="rounded-xl border p-3">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||||
<RiMoneyDollarCircleLine className="size-3.5" />
|
<RiMoneyDollarCircleLine className="size-3.5" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide">
|
<span className="text-xs font-medium uppercase tracking-wide">
|
||||||
Total da fatura
|
Total da fatura
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={Math.abs(invoice.totalAmount)}
|
amount={Math.abs(invoice.totalAmount)}
|
||||||
className="text-lg font-bold"
|
className="text-lg font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border p-3">
|
<div className="rounded-xl border p-3">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||||
<RiCalendarLine className="size-3.5" />
|
<RiCalendarLine className="size-3.5" />
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide">
|
<span className="text-xs font-medium uppercase tracking-wide">
|
||||||
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
||||||
? "Pago em"
|
? "Pago em"
|
||||||
: "Vencimento"}
|
: "Vencimento"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
||||||
? (paymentInfo?.label ?? "—")
|
? (paymentInfo?.label ?? "—")
|
||||||
: (dueInfo?.label ?? "—")}
|
: (dueInfo?.label ?? "—")}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function NoteListItem({
|
|||||||
{getNoteTasksSummary(note)}
|
{getNoteTasksSummary(note)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{createdAtLabel ? (
|
{createdAtLabel ? (
|
||||||
<p className="truncate text-[11px] text-muted-foreground">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
{createdAtLabel}
|
{createdAtLabel}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function PurchasesByCategoryWidget({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.entries(categoriesByType).map(([type, categories]) => (
|
{Object.entries(categoriesByType).map(([type, categories]) => (
|
||||||
<div key={type}>
|
<div key={type}>
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
{CATEGORY_TYPE_LABEL[
|
{CATEGORY_TYPE_LABEL[
|
||||||
type as keyof typeof CATEGORY_TYPE_LABEL
|
type as keyof typeof CATEGORY_TYPE_LABEL
|
||||||
] ?? type}
|
] ?? type}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { unstable_cache } from "next/cache";
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
import { fetchDashboardAccounts } from "./accounts-queries";
|
import { fetchDashboardAccounts } from "./accounts-queries";
|
||||||
import { fetchDashboardCategoryOverview } from "./category-overview-queries";
|
import { fetchDashboardCategoryOverview } from "./category-overview-queries";
|
||||||
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
|
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
|
||||||
@@ -51,18 +51,14 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Cached dashboard data fetcher.
|
* Cached dashboard data fetcher.
|
||||||
* Uses unstable_cache with tags for revalidation on mutations.
|
* Uses "use cache" with tags for revalidation on mutations.
|
||||||
* Cache is keyed by userId + period, and invalidated via user-scoped tags.
|
* Cache is keyed by userId + period, and invalidated via user-scoped tags.
|
||||||
*/
|
*/
|
||||||
export function fetchDashboardData(userId: string, period: string) {
|
export async function fetchDashboardData(userId: string, period: string) {
|
||||||
return unstable_cache(
|
"use cache";
|
||||||
() => fetchDashboardDataInternal(userId, period),
|
cacheTag(`dashboard-${userId}`);
|
||||||
[`dashboard-${userId}-${period}`],
|
cacheLife({ revalidate: 3 });
|
||||||
{
|
return fetchDashboardDataInternal(userId, period);
|
||||||
tags: [`dashboard-${userId}`],
|
|
||||||
revalidate: 60,
|
|
||||||
},
|
|
||||||
)();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;
|
export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export async function fetchDashboardInvoices(
|
|||||||
const paymentMap = new Map<string, string>();
|
const paymentMap = new Map<string, string>();
|
||||||
for (const row of paymentRows) {
|
for (const row of paymentRows) {
|
||||||
const note = row.note;
|
const note = row.note;
|
||||||
if (!note || !note.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
if (!note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parts = note.split(":");
|
const parts = note.split(":");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { unstable_cache } from "next/cache";
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
import { payers } from "@/db/schema";
|
import { payers } from "@/db/schema";
|
||||||
import { fetchPendingInboxCount } from "@/features/inbox/queries";
|
import { fetchPendingInboxCount } from "@/features/inbox/queries";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
@@ -53,15 +53,9 @@ async function fetchDashboardNavbarDataInternal(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDashboardNavbarData(userId: string) {
|
export async function fetchDashboardNavbarData(userId: string) {
|
||||||
const currentPeriod = getBusinessDateString().slice(0, 7);
|
"use cache";
|
||||||
|
cacheTag(`dashboard-${userId}`);
|
||||||
return unstable_cache(
|
cacheLife({ revalidate: 3 });
|
||||||
() => fetchDashboardNavbarDataInternal(userId),
|
return fetchDashboardNavbarDataInternal(userId);
|
||||||
[`dashboard-navbar-${userId}-${currentPeriod}`],
|
|
||||||
{
|
|
||||||
tags: [`dashboard-${userId}`],
|
|
||||||
revalidate: 60,
|
|
||||||
},
|
|
||||||
)();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
"use server";
|
import { and, eq, gte, inArray, isNotNull, lt, ne, sql } from "drizzle-orm";
|
||||||
|
|
||||||
import { and, eq, inArray, lt, ne, sql } from "drizzle-orm";
|
|
||||||
import {
|
import {
|
||||||
budgets,
|
budgets,
|
||||||
cards,
|
cards,
|
||||||
@@ -27,7 +25,11 @@ import {
|
|||||||
toDateOnlyString,
|
toDateOnlyString,
|
||||||
} from "@/shared/utils/date";
|
} from "@/shared/utils/date";
|
||||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
import {
|
||||||
|
addMonthsToPeriod,
|
||||||
|
formatPeriodForUrl,
|
||||||
|
getNextPeriod,
|
||||||
|
} from "@/shared/utils/period";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
BudgetNotification,
|
BudgetNotification,
|
||||||
@@ -98,6 +100,7 @@ export async function fetchDashboardNotifications(
|
|||||||
): Promise<DashboardNotificationsSnapshot> {
|
): Promise<DashboardNotificationsSnapshot> {
|
||||||
const today = getBusinessDateString();
|
const today = getBusinessDateString();
|
||||||
const DAYS_THRESHOLD = 5;
|
const DAYS_THRESHOLD = 5;
|
||||||
|
const nextPeriod = getNextPeriod(currentPeriod);
|
||||||
|
|
||||||
const adminPayerId = await getAdminPayerId(userId);
|
const adminPayerId = await getAdminPayerId(userId);
|
||||||
|
|
||||||
@@ -110,6 +113,10 @@ export async function fetchDashboardNotifications(
|
|||||||
if (adminPayerId) {
|
if (adminPayerId) {
|
||||||
boletosConditions.push(eq(transactions.payerId, adminPayerId));
|
boletosConditions.push(eq(transactions.payerId, adminPayerId));
|
||||||
}
|
}
|
||||||
|
boletosConditions.push(isNotNull(transactions.dueDate));
|
||||||
|
boletosConditions.push(
|
||||||
|
gte(transactions.period, addMonthsToPeriod(currentPeriod, -12)),
|
||||||
|
);
|
||||||
|
|
||||||
const budgetJoinConditions = [
|
const budgetJoinConditions = [
|
||||||
eq(transactions.categoryId, budgets.categoryId),
|
eq(transactions.categoryId, budgets.categoryId),
|
||||||
@@ -122,118 +129,126 @@ export async function fetchDashboardNotifications(
|
|||||||
budgetJoinConditions.push(eq(transactions.payerId, adminPayerId));
|
budgetJoinConditions.push(eq(transactions.payerId, adminPayerId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- All 4 queries are independent — run in parallel ---
|
// Helper: monta a query de faturas por período (reutilizada para período atual e próximo)
|
||||||
const [overdueInvoices, currentInvoices, boletosRows, budgetRows] =
|
const buildPeriodInvoicesQuery = (period: string) =>
|
||||||
await Promise.all([
|
db
|
||||||
// Faturas atrasadas (períodos anteriores)
|
.select({
|
||||||
db
|
invoiceId: invoices.id,
|
||||||
.select({
|
cardId: cards.id,
|
||||||
invoiceId: invoices.id,
|
cardName: cards.name,
|
||||||
cardId: cards.id,
|
cardLogo: cards.logo,
|
||||||
cardName: cards.name,
|
dueDay: cards.dueDay,
|
||||||
cardLogo: cards.logo,
|
period: sql<string>`COALESCE(${invoices.period}, ${period})`,
|
||||||
dueDay: cards.dueDay,
|
paymentStatus: invoices.paymentStatus,
|
||||||
period: invoices.period,
|
totalAmount: sql<number | null>`
|
||||||
totalAmount: sql<
|
|
||||||
number | null
|
|
||||||
>`COALESCE(SUM(${transactions.amount}), 0)`,
|
|
||||||
})
|
|
||||||
.from(invoices)
|
|
||||||
.innerJoin(cards, eq(invoices.cardId, cards.id))
|
|
||||||
.leftJoin(
|
|
||||||
transactions,
|
|
||||||
and(
|
|
||||||
eq(transactions.cardId, invoices.cardId),
|
|
||||||
eq(transactions.period, invoices.period),
|
|
||||||
eq(transactions.userId, invoices.userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(invoices.userId, userId),
|
|
||||||
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
|
|
||||||
lt(invoices.period, currentPeriod),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(
|
|
||||||
invoices.id,
|
|
||||||
cards.id,
|
|
||||||
cards.name,
|
|
||||||
cards.logo,
|
|
||||||
cards.dueDay,
|
|
||||||
invoices.period,
|
|
||||||
),
|
|
||||||
// Faturas do período atual
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
invoiceId: invoices.id,
|
|
||||||
cardId: cards.id,
|
|
||||||
cardName: cards.name,
|
|
||||||
cardLogo: cards.logo,
|
|
||||||
dueDay: cards.dueDay,
|
|
||||||
period: sql<string>`COALESCE(${invoices.period}, ${currentPeriod})`,
|
|
||||||
paymentStatus: invoices.paymentStatus,
|
|
||||||
totalAmount: sql<number | null>`
|
|
||||||
COALESCE(SUM(${transactions.amount}), 0)
|
COALESCE(SUM(${transactions.amount}), 0)
|
||||||
`,
|
`,
|
||||||
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
|
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
|
||||||
})
|
})
|
||||||
.from(cards)
|
.from(cards)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
invoices,
|
invoices,
|
||||||
and(
|
and(
|
||||||
eq(invoices.cardId, cards.id),
|
eq(invoices.cardId, cards.id),
|
||||||
eq(invoices.userId, userId),
|
eq(invoices.userId, userId),
|
||||||
eq(invoices.period, currentPeriod),
|
eq(invoices.period, period),
|
||||||
),
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
transactions,
|
|
||||||
and(
|
|
||||||
eq(transactions.cardId, cards.id),
|
|
||||||
eq(transactions.userId, userId),
|
|
||||||
eq(transactions.period, currentPeriod),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.where(eq(cards.userId, userId))
|
|
||||||
.groupBy(
|
|
||||||
invoices.id,
|
|
||||||
cards.id,
|
|
||||||
cards.name,
|
|
||||||
cards.logo,
|
|
||||||
cards.dueDay,
|
|
||||||
invoices.period,
|
|
||||||
invoices.paymentStatus,
|
|
||||||
),
|
),
|
||||||
// Boletos não pagos
|
)
|
||||||
db
|
.leftJoin(
|
||||||
.select({
|
transactions,
|
||||||
id: transactions.id,
|
and(
|
||||||
name: transactions.name,
|
eq(transactions.cardId, cards.id),
|
||||||
amount: transactions.amount,
|
eq(transactions.userId, userId),
|
||||||
dueDate: transactions.dueDate,
|
eq(transactions.period, period),
|
||||||
period: transactions.period,
|
),
|
||||||
})
|
)
|
||||||
.from(transactions)
|
.where(eq(cards.userId, userId))
|
||||||
.where(and(...boletosConditions)),
|
.groupBy(
|
||||||
// Orçamentos do período atual
|
invoices.id,
|
||||||
db
|
cards.id,
|
||||||
.select({
|
cards.name,
|
||||||
orcamentoId: budgets.id,
|
cards.logo,
|
||||||
categoryId: budgets.categoryId,
|
cards.dueDay,
|
||||||
budgetAmount: budgets.amount,
|
invoices.period,
|
||||||
period: budgets.period,
|
invoices.paymentStatus,
|
||||||
categoriaName: categories.name,
|
);
|
||||||
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
|
|
||||||
})
|
// --- All 5 queries are independent — run in parallel ---
|
||||||
.from(budgets)
|
const [
|
||||||
.innerJoin(categories, eq(budgets.categoryId, categories.id))
|
overdueInvoices,
|
||||||
.leftJoin(transactions, and(...budgetJoinConditions))
|
currentInvoices,
|
||||||
.where(
|
nextPeriodInvoices,
|
||||||
and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)),
|
boletosRows,
|
||||||
)
|
budgetRows,
|
||||||
.groupBy(budgets.id, budgets.amount, categories.name),
|
] = await Promise.all([
|
||||||
]);
|
// Faturas atrasadas (períodos anteriores)
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
invoiceId: invoices.id,
|
||||||
|
cardId: cards.id,
|
||||||
|
cardName: cards.name,
|
||||||
|
cardLogo: cards.logo,
|
||||||
|
dueDay: cards.dueDay,
|
||||||
|
period: invoices.period,
|
||||||
|
totalAmount: sql<
|
||||||
|
number | null
|
||||||
|
>`COALESCE(SUM(${transactions.amount}), 0)`,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.innerJoin(cards, eq(invoices.cardId, cards.id))
|
||||||
|
.leftJoin(
|
||||||
|
transactions,
|
||||||
|
and(
|
||||||
|
eq(transactions.cardId, invoices.cardId),
|
||||||
|
eq(transactions.period, invoices.period),
|
||||||
|
eq(transactions.userId, invoices.userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invoices.userId, userId),
|
||||||
|
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
|
||||||
|
lt(invoices.period, currentPeriod),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(
|
||||||
|
invoices.id,
|
||||||
|
cards.id,
|
||||||
|
cards.name,
|
||||||
|
cards.logo,
|
||||||
|
cards.dueDay,
|
||||||
|
invoices.period,
|
||||||
|
),
|
||||||
|
// Faturas do período atual e próximo
|
||||||
|
buildPeriodInvoicesQuery(currentPeriod),
|
||||||
|
buildPeriodInvoicesQuery(nextPeriod),
|
||||||
|
// Boletos não pagos
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: transactions.id,
|
||||||
|
name: transactions.name,
|
||||||
|
amount: transactions.amount,
|
||||||
|
dueDate: transactions.dueDate,
|
||||||
|
period: transactions.period,
|
||||||
|
})
|
||||||
|
.from(transactions)
|
||||||
|
.where(and(...boletosConditions)),
|
||||||
|
// Orçamentos do período atual
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
orcamentoId: budgets.id,
|
||||||
|
categoryId: budgets.categoryId,
|
||||||
|
budgetAmount: budgets.amount,
|
||||||
|
period: budgets.period,
|
||||||
|
categoriaName: categories.name,
|
||||||
|
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
|
||||||
|
})
|
||||||
|
.from(budgets)
|
||||||
|
.innerJoin(categories, eq(budgets.categoryId, categories.id))
|
||||||
|
.leftJoin(transactions, and(...budgetJoinConditions))
|
||||||
|
.where(and(eq(budgets.userId, userId), eq(budgets.period, currentPeriod)))
|
||||||
|
.groupBy(budgets.id, budgets.amount, categories.name),
|
||||||
|
]);
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// Processar notificações
|
// Processar notificações
|
||||||
@@ -327,6 +342,53 @@ export async function fetchDashboardNotifications(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Faturas do próximo período com vencimento próximo
|
||||||
|
const addedNotificationKeys = new Set(
|
||||||
|
notifications.map((n) => n.notificationKey),
|
||||||
|
);
|
||||||
|
for (const invoice of nextPeriodInvoices) {
|
||||||
|
if (!invoice.dueDay) continue;
|
||||||
|
const dueDate = buildDateOnlyStringFromPeriodDay(
|
||||||
|
nextPeriod,
|
||||||
|
invoice.dueDay,
|
||||||
|
);
|
||||||
|
if (!dueDate) continue;
|
||||||
|
if (invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
|
||||||
|
|
||||||
|
const invoiceIsDueSoon = isDateOnlyWithinDays(
|
||||||
|
dueDate,
|
||||||
|
DAYS_THRESHOLD,
|
||||||
|
today,
|
||||||
|
);
|
||||||
|
if (!invoiceIsDueSoon) continue;
|
||||||
|
|
||||||
|
const notificationKey = buildInvoiceNotificationKey(
|
||||||
|
invoice.cardId,
|
||||||
|
nextPeriod,
|
||||||
|
);
|
||||||
|
// Evitar duplicata se já foi adicionado via currentInvoices
|
||||||
|
if (addedNotificationKeys.has(notificationKey)) continue;
|
||||||
|
|
||||||
|
const amount = toNumber(invoice.totalAmount);
|
||||||
|
notifications.push({
|
||||||
|
type: "invoice",
|
||||||
|
name: invoice.cardName,
|
||||||
|
dueDate,
|
||||||
|
status: "due_soon",
|
||||||
|
amount: Math.abs(amount),
|
||||||
|
period: nextPeriod,
|
||||||
|
showAmount: false,
|
||||||
|
cardLogo: invoice.cardLogo,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: "due_soon",
|
||||||
|
href: buildInvoiceDetailsHref(invoice.cardId, nextPeriod),
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Boletos
|
// Boletos
|
||||||
for (const boleto of boletosRows) {
|
for (const boleto of boletosRows) {
|
||||||
const dueDate = toDateOnlyString(boleto.dueDate);
|
const dueDate = toDateOnlyString(boleto.dueDate);
|
||||||
@@ -340,6 +402,7 @@ export async function fetchDashboardNotifications(
|
|||||||
);
|
);
|
||||||
const isOldPeriod = boleto.period < currentPeriod;
|
const isOldPeriod = boleto.period < currentPeriod;
|
||||||
const isCurrentPeriod = boleto.period === currentPeriod;
|
const isCurrentPeriod = boleto.period === currentPeriod;
|
||||||
|
const isNextPeriod = boleto.period === nextPeriod;
|
||||||
const amount = toNumber(boleto.amount);
|
const amount = toNumber(boleto.amount);
|
||||||
const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`;
|
const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`;
|
||||||
const notificationKey = buildBoletoNotificationKey(boleto.id);
|
const notificationKey = buildBoletoNotificationKey(boleto.id);
|
||||||
@@ -380,6 +443,23 @@ export async function fetchDashboardNotifications(
|
|||||||
readAt: null,
|
readAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
});
|
});
|
||||||
|
} else if (isNextPeriod && boletoIsDueSoon) {
|
||||||
|
notifications.push({
|
||||||
|
type: "boleto",
|
||||||
|
name: boleto.name,
|
||||||
|
dueDate,
|
||||||
|
status: "due_soon",
|
||||||
|
amount: Math.abs(amount),
|
||||||
|
period: boleto.period,
|
||||||
|
showAmount: false,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: "due_soon",
|
||||||
|
href,
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { unstable_cache } from "next/cache";
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
import { fetchDashboardData } from "@/features/dashboard/fetch-dashboard-data";
|
||||||
import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries";
|
import { fetchUserDashboardPreferences } from "@/features/dashboard/preferences-queries";
|
||||||
import {
|
import {
|
||||||
@@ -52,15 +52,11 @@ async function fetchDashboardQuickActionOptionsInternal(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDashboardQuickActionOptions(userId: string) {
|
export async function fetchDashboardQuickActionOptions(userId: string) {
|
||||||
return unstable_cache(
|
"use cache";
|
||||||
() => fetchDashboardQuickActionOptionsInternal(userId),
|
cacheTag(`dashboard-${userId}`);
|
||||||
[`dashboard-quick-actions-${userId}`],
|
cacheLife({ revalidate: 3 });
|
||||||
{
|
return fetchDashboardQuickActionOptionsInternal(userId);
|
||||||
tags: [`dashboard-${userId}`],
|
|
||||||
revalidate: 60,
|
|
||||||
},
|
|
||||||
)();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDashboardPageData(userId: string, period: string) {
|
export async function fetchDashboardPageData(userId: string, period: string) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
|
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
|
||||||
import { db, schema } from "@/shared/lib/db";
|
import { db, schema } from "@/shared/lib/db";
|
||||||
|
|
||||||
@@ -9,6 +10,10 @@ export interface UserDashboardPreferences {
|
|||||||
export async function fetchUserDashboardPreferences(
|
export async function fetchUserDashboardPreferences(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<UserDashboardPreferences> {
|
): Promise<UserDashboardPreferences> {
|
||||||
|
"use cache";
|
||||||
|
cacheTag(`dashboard-${userId}`);
|
||||||
|
cacheLife({ revalidate: 3 });
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({
|
.select({
|
||||||
dashboardWidgets: schema.userPreferences.dashboardWidgets,
|
dashboardWidgets: schema.userPreferences.dashboardWidgets,
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export const InboxCard = memo(function InboxCard({
|
|||||||
|
|
||||||
<CardContent className="flex-1 py-2">
|
<CardContent className="flex-1 py-2">
|
||||||
{item.originalTitle && (
|
{item.originalTitle && (
|
||||||
<p className="mb-1 text-sm font-bold">{item.originalTitle}</p>
|
<p className="mb-1 text-sm font-medium">{item.originalTitle}</p>
|
||||||
)}
|
)}
|
||||||
<p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground">
|
<p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||||
{item.originalText}
|
{item.originalText}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getDay } from "date-fns";
|
import { getDay } from "date-fns";
|
||||||
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
||||||
import { unstable_cache } from "next/cache";
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
import {
|
import {
|
||||||
budgets,
|
budgets,
|
||||||
cards,
|
cards,
|
||||||
@@ -481,13 +481,9 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function aggregateMonthData(userId: string, period: string) {
|
export async function aggregateMonthData(userId: string, period: string) {
|
||||||
return unstable_cache(
|
"use cache";
|
||||||
() => aggregateMonthDataInternal(userId, period),
|
cacheTag(`dashboard-${userId}`);
|
||||||
[`insights-aggregate-${userId}-${period}`],
|
cacheLife({ revalidate: 3 });
|
||||||
{
|
return aggregateMonthDataInternal(userId, period);
|
||||||
tags: [`dashboard-${userId}`],
|
|
||||||
revalidate: 60,
|
|
||||||
},
|
|
||||||
)();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function InsightsGrid({ insights }: InsightsGridProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className={cn("size-5", colors.chatAiIcon)} />
|
<Icon className={cn("size-5", colors.chatAiIcon)} />
|
||||||
<CardTitle className={cn("font-semibold", colors.titleText)}>
|
<CardTitle className={cn("font-medium", colors.titleText)}>
|
||||||
{categoryConfig.title}
|
{categoryConfig.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ function ErrorState({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-12 px-4 text-center">
|
<div className="flex flex-col items-center justify-center gap-4 py-12 px-4 text-center">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="text-lg font-semibold text-destructive">
|
<h3 className="text-lg font-medium text-destructive">
|
||||||
Erro ao gerar insights
|
Erro ao gerar insights
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
|
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function ModelSelector({
|
|||||||
<Card className="grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start p-6">
|
<Card className="grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start p-6">
|
||||||
{/* Descrição */}
|
{/* Descrição */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-lg font-semibold">Definir modelo de análise</h3>
|
<h3 className="text-lg font-medium">Definir modelo de análise</h3>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
Escolha o provedor de IA e o modelo específico que será utilizado para
|
Escolha o provedor de IA e o modelo específico que será utilizado para
|
||||||
gerar insights sobre seus dados financeiros. <br />
|
gerar insights sobre seus dados financeiros. <br />
|
||||||
|
|||||||
@@ -171,12 +171,12 @@ export function InvoiceSummaryCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : cardBrand ? (
|
) : cardBrand ? (
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-background text-xs font-semibold text-muted-foreground">
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-background text-xs font-medium text-muted-foreground">
|
||||||
{cardBrand.slice(0, 2).toUpperCase()}
|
{cardBrand.slice(0, 2).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-sm font-semibold text-foreground">
|
<h2 className="truncate text-sm font-medium text-foreground">
|
||||||
{cardName}
|
{cardName}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -195,14 +195,14 @@ export function InvoiceSummaryCard({
|
|||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={totalAmount}
|
amount={totalAmount}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]",
|
"text-3xl leading-none font-medium tracking-tight sm:text-[2rem]",
|
||||||
isPaid ? "text-success" : "text-foreground",
|
isPaid ? "text-success" : "text-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
|
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
|
||||||
className="text-[11px]"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{INVOICE_STATUS_LABEL[invoiceStatus]}
|
{INVOICE_STATUS_LABEL[invoiceStatus]}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -218,20 +218,20 @@ export function InvoiceSummaryCard({
|
|||||||
{/* Linha 3 — metadados do cartão */}
|
{/* Linha 3 — metadados do cartão */}
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
<MetaItem label="Vencimento">
|
<MetaItem label="Vencimento">
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
Dia {formatDay(dueDay)}
|
Dia {formatDay(dueDay)}
|
||||||
</span>
|
</span>
|
||||||
</MetaItem>
|
</MetaItem>
|
||||||
|
|
||||||
<MetaItem label="Fechamento">
|
<MetaItem label="Fechamento">
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
Dia {formatDay(closingDay)}
|
Dia {formatDay(closingDay)}
|
||||||
</span>
|
</span>
|
||||||
</MetaItem>
|
</MetaItem>
|
||||||
|
|
||||||
{typeof limitAmount === "number" ? (
|
{typeof limitAmount === "number" ? (
|
||||||
<MetaItem label="Limite">
|
<MetaItem label="Limite">
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{formatCurrency(limitAmount)}
|
{formatCurrency(limitAmount)}
|
||||||
</span>
|
</span>
|
||||||
</MetaItem>
|
</MetaItem>
|
||||||
@@ -249,7 +249,7 @@ export function InvoiceSummaryCard({
|
|||||||
className="h-4 w-auto shrink-0"
|
className="h-4 w-auto shrink-0"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="text-sm font-semibold text-foreground truncate">
|
<span className="text-sm font-medium text-foreground truncate">
|
||||||
{cardBrand}
|
{cardBrand}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ function StepCard({
|
|||||||
<Card className="border">
|
<Card className="border">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex gap-3 md:gap-4">
|
<div className="flex gap-3 md:gap-4">
|
||||||
<div className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-bold text-sm md:text-base">
|
<div className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-medium text-sm md:text-base">
|
||||||
{step}
|
{step}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="font-semibold mb-1.5 md:mb-2">{title}</h3>
|
<h3 className="font-medium mb-1.5 md:mb-2">{title}</h3>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function NoteCard({
|
|||||||
<CardContent className="flex min-h-0 flex-1 flex-col gap-4">
|
<CardContent className="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
<div className="flex shrink-0 items-start justify-between gap-3">
|
<div className="flex shrink-0 items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<h3 className="text-lg font-semibold leading-tight text-foreground wrap-break-word">
|
<h3 className="text-lg font-medium leading-tight text-foreground wrap-break-word">
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
</h3>
|
</h3>
|
||||||
{createdAtLabel && (
|
{createdAtLabel && (
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function PayerCardUsageCard({ items }: PagadorCardUsageCardProps) {
|
|||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-semibold uppercase text-muted-foreground">
|
<span className="text-sm font-medium uppercase text-muted-foreground">
|
||||||
{initials}
|
{initials}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function PayerHeaderCard({
|
|||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<CardTitle className="text-xl font-semibold text-foreground">
|
<CardTitle className="text-xl font-medium text-foreground">
|
||||||
{payer.name}
|
{payer.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
@@ -197,11 +197,11 @@ export function PayerHeaderCard({
|
|||||||
<DialogTitle>Confirmar envio do resumo</DialogTitle>
|
<DialogTitle>Confirmar envio do resumo</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Resumo de{" "}
|
Resumo de{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{summary.periodLabel}
|
{summary.periodLabel}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
para{" "}
|
para{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{payer.email}
|
{payer.email}
|
||||||
</span>
|
</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -218,7 +218,7 @@ export function PayerHeaderCard({
|
|||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
Total de Despesas
|
Total de Despesas
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-foreground">
|
<p className="text-2xl font-medium text-foreground">
|
||||||
{formatCurrency(summary.totalExpenses)}
|
{formatCurrency(summary.totalExpenses)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,11 +235,11 @@ export function PayerHeaderCard({
|
|||||||
<div className="rounded-lg border p-3">
|
<div className="rounded-lg border p-3">
|
||||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
<RiBankCard2Line className="size-4" />
|
<RiBankCard2Line className="size-4" />
|
||||||
<span className="text-xs font-semibold uppercase">
|
<span className="text-xs font-medium uppercase">
|
||||||
Cartões
|
Cartões
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold text-foreground">
|
<p className="text-lg font-medium text-foreground">
|
||||||
{formatCurrency(summary.paymentSplits.card)}
|
{formatCurrency(summary.paymentSplits.card)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,11 +247,11 @@ export function PayerHeaderCard({
|
|||||||
<div className="rounded-lg border p-3">
|
<div className="rounded-lg border p-3">
|
||||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
<RiBillLine className="size-4" />
|
<RiBillLine className="size-4" />
|
||||||
<span className="text-xs font-semibold uppercase">
|
<span className="text-xs font-medium uppercase">
|
||||||
Boletos
|
Boletos
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold text-foreground">
|
<p className="text-lg font-medium text-foreground">
|
||||||
{formatCurrency(summary.paymentSplits.boleto)}
|
{formatCurrency(summary.paymentSplits.boleto)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,11 +259,11 @@ export function PayerHeaderCard({
|
|||||||
<div className="rounded-lg border p-3">
|
<div className="rounded-lg border p-3">
|
||||||
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
<RiExchangeDollarLine className="size-4" />
|
<RiExchangeDollarLine className="size-4" />
|
||||||
<span className="text-xs font-semibold uppercase">
|
<span className="text-xs font-medium uppercase">
|
||||||
Pix/Débito
|
Pix/Débito
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold text-foreground">
|
<p className="text-lg font-medium text-foreground">
|
||||||
{formatCurrency(summary.paymentSplits.instant)}
|
{formatCurrency(summary.paymentSplits.instant)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,7 +274,7 @@ export function PayerHeaderCard({
|
|||||||
<div className="rounded-lg border p-3">
|
<div className="rounded-lg border p-3">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
<span className="text-xs font-medium uppercase text-muted-foreground">
|
||||||
Cartões Utilizados
|
Cartões Utilizados
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,14 +299,14 @@ export function PayerHeaderCard({
|
|||||||
<div className="rounded-lg border p-3">
|
<div className="rounded-lg border p-3">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<RiBillLine className="size-4 text-muted-foreground" />
|
<RiBillLine className="size-4 text-muted-foreground" />
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
<span className="text-xs font-medium uppercase text-muted-foreground">
|
||||||
Status de Boletos
|
Status de Boletos
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Pagos</p>
|
<p className="text-xs text-muted-foreground">Pagos</p>
|
||||||
<p className="text-sm font-semibold text-success">
|
<p className="text-sm font-medium text-success">
|
||||||
{formatCurrency(summary.boletoStats.paidAmount)}{" "}
|
{formatCurrency(summary.boletoStats.paidAmount)}{" "}
|
||||||
<span className="text-xs font-normal">
|
<span className="text-xs font-normal">
|
||||||
({summary.boletoStats.paidCount})
|
({summary.boletoStats.paidCount})
|
||||||
@@ -317,7 +317,7 @@ export function PayerHeaderCard({
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Pendentes
|
Pendentes
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-semibold text-warning">
|
<p className="text-sm font-medium text-warning">
|
||||||
{formatCurrency(summary.boletoStats.pendingAmount)}{" "}
|
{formatCurrency(summary.boletoStats.pendingAmount)}{" "}
|
||||||
<span className="text-xs font-normal">
|
<span className="text-xs font-normal">
|
||||||
({summary.boletoStats.pendingCount})
|
({summary.boletoStats.pendingCount})
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const ValueLabel = (props: LabelProps) => {
|
|||||||
y={labelY}
|
y={labelY}
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="text-[11px] font-semibold text-muted-foreground"
|
className="text-xs font-medium text-muted-foreground"
|
||||||
>
|
>
|
||||||
{amount}
|
{amount}
|
||||||
</text>
|
</text>
|
||||||
@@ -63,7 +63,7 @@ export function PayerHistoryCard({ data }: PagadorHistoryCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Card className="border">
|
<Card className="border">
|
||||||
<CardHeader className="gap-1.5 pb-3">
|
<CardHeader className="gap-1.5 pb-3">
|
||||||
<CardTitle className="text-lg font-semibold">
|
<CardTitle className="text-lg font-medium">
|
||||||
Evolução (últimos 6 meses)
|
Evolução (últimos 6 meses)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function PagadorInfoCard({ payer }: PayerInfoCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Card className="border gap-4">
|
<Card className="border gap-4">
|
||||||
<CardHeader className="gap-1.5">
|
<CardHeader className="gap-1.5">
|
||||||
<CardTitle className="text-lg font-semibold">
|
<CardTitle className="text-lg font-medium">
|
||||||
Detalhes do pagador
|
Detalhes do pagador
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -79,7 +79,7 @@ export function PagadorInfoCard({ payer }: PayerInfoCardProps) {
|
|||||||
<InfoItem
|
<InfoItem
|
||||||
label="Aviso"
|
label="Aviso"
|
||||||
value={
|
value={
|
||||||
<span className="text-[13px] text-warning">
|
<span className="text-sm text-warning">
|
||||||
Cadastre um e-mail para permitir o envio automático.
|
Cadastre um e-mail para permitir o envio automático.
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ type InfoItemProps = {
|
|||||||
function InfoItem({ label, value, className }: InfoItemProps) {
|
function InfoItem({ label, value, className }: InfoItemProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-1", className)}>
|
<div className={cn("space-y-1", className)}>
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground/80">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<div className="text-base text-foreground">{value}</div>
|
<div className="text-base text-foreground">{value}</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function PayerLeaveShareCard({
|
|||||||
return (
|
return (
|
||||||
<Card className="border">
|
<Card className="border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base font-semibold">
|
<CardTitle className="text-base font-medium">
|
||||||
Acesso Compartilhado
|
Acesso Compartilhado
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -63,7 +63,7 @@ export function PayerLeaveShareCard({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-dashed p-4 text-sm">
|
<div className="flex flex-col gap-2 rounded-lg border border-dashed p-4 text-sm">
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground/80">
|
<span className="text-xs font-medium uppercase text-muted-foreground/80">
|
||||||
Informações do compartilhamento
|
Informações do compartilhamento
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function PayerMonthlySummaryCard({
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-1.5">
|
<CardHeader className="flex flex-col gap-1.5">
|
||||||
<CardTitle className="text-lg font-semibold">Totais do mês</CardTitle>
|
<CardTitle className="text-lg font-medium">Totais do mês</CardTitle>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{periodLabel} - Despesas por forma de pagamento
|
{periodLabel} - Despesas por forma de pagamento
|
||||||
</p>
|
</p>
|
||||||
@@ -65,7 +65,7 @@ export function PayerMonthlySummaryCard({
|
|||||||
</span>
|
</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={breakdown.totalExpenses}
|
amount={breakdown.totalExpenses}
|
||||||
className="block text-2xl font-semibold text-foreground"
|
className="block text-2xl font-medium text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export function PayerMonthlySummaryCard({
|
|||||||
totalBase > 0 ? Math.round((entry.value / totalBase) * 100) : 0;
|
totalBase > 0 ? Math.round((entry.value / totalBase) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
<div key={entry.key} className="space-y-1 rounded-lg border p-3">
|
<div key={entry.key} className="space-y-1 rounded-lg border p-3">
|
||||||
<span className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground/70">
|
<span className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
|
||||||
<span
|
<span
|
||||||
className={cn("size-2 rounded-full", entry.color)}
|
className={cn("size-2 rounded-full", entry.color)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -109,7 +109,7 @@ export function PayerMonthlySummaryCard({
|
|||||||
</span>
|
</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={entry.value}
|
amount={entry.value}
|
||||||
className="block text-lg font-semibold text-foreground"
|
className="block text-lg font-medium text-foreground"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{percent}% das despesas
|
{percent}% das despesas
|
||||||
|
|||||||
@@ -84,9 +84,7 @@ export function PayerSharingCard({
|
|||||||
return (
|
return (
|
||||||
<Card className="border">
|
<Card className="border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg font-semibold">
|
<CardTitle className="text-lg font-medium">Compartilhamentos</CardTitle>
|
||||||
Compartilhamentos
|
|
||||||
</CardTitle>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Compartilhe o código abaixo com outra pessoa. Ela poderá adicioná-lo
|
Compartilhe o código abaixo com outra pessoa. Ela poderá adicioná-lo
|
||||||
na página de pagadores usando a opção Adicionar por código para ter
|
na página de pagadores usando a opção Adicionar por código para ter
|
||||||
@@ -95,7 +93,7 @@ export function PayerSharingCard({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-dashed p-4 text-sm">
|
<div className="flex flex-col gap-2 rounded-lg border border-dashed p-4 text-sm">
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground/80">
|
<span className="text-xs font-medium uppercase text-muted-foreground/80">
|
||||||
Código de compartilhamento
|
Código de compartilhamento
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function PayerCard({ payer, onEdit, onRemove }: PayerCardProps) {
|
|||||||
|
|
||||||
{/* Nome e badges */}
|
{/* Nome e badges */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
<h3 className="text-base font-medium text-foreground">
|
||||||
{payer.name}
|
{payer.name}
|
||||||
</h3>
|
</h3>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function CardTopExpenses({ data }: CardTopExpensesProps) {
|
|||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
{/* Rank number */}
|
{/* Rank number */}
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted">
|
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted">
|
||||||
<span className="text-sm font-semibold text-muted-foreground">
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user