mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Compare commits
46 Commits
50477fb1be
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c44089169f | ||
|
|
d04e30e3c9 | ||
|
|
229b6c5bc0 | ||
|
|
c3b133d8d9 | ||
|
|
e9a2ab1782 | ||
|
|
c7d6e23398 | ||
|
|
0514efb1c4 | ||
|
|
e32fb85006 | ||
|
|
96df6a1798 | ||
|
|
1f8a97bd16 | ||
|
|
0ab3298cef | ||
|
|
cad41680eb | ||
|
|
3b00f328c5 | ||
|
|
20d0c3e0a7 | ||
|
|
71b5a004e3 | ||
|
|
65b1506d75 | ||
|
|
2a458d5a3c | ||
|
|
f418987f47 | ||
|
|
59b4dea071 | ||
|
|
6ce132fe0c | ||
|
|
49731238e4 | ||
|
|
c5df97f7aa | ||
|
|
3476fda4db | ||
|
|
519b673ae5 | ||
|
|
303b8bedd4 | ||
|
|
f2b9b16896 | ||
|
|
6eba35542b | ||
|
|
f5e95ffba6 | ||
|
|
a75bb86eec | ||
|
|
a3b858621f | ||
|
|
fee2a2c9f5 | ||
|
|
839d7d0866 | ||
|
|
7cd7d95245 | ||
|
|
9bd762f7a3 | ||
|
|
9b76db4ce9 | ||
|
|
91457b6490 | ||
|
|
a0a71623d7 | ||
|
|
00e624b8bc | ||
|
|
f82043127a | ||
|
|
32da4f906e | ||
|
|
0bd9d0ac47 | ||
|
|
9f45fd1ecd | ||
|
|
f528e75ee1 | ||
|
|
da32b41bbc | ||
|
|
1e0c93fb6c | ||
|
|
5f70421f5a |
@@ -22,6 +22,13 @@ BETTER_AUTH_URL=http://localhost:3000
|
||||
APP_PORT=3000
|
||||
DB_PORT=5432
|
||||
|
||||
# === S3 Server (Opcional) ===
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET=
|
||||
|
||||
# === Email (Opcional) ===
|
||||
# Provider: Resend (https://resend.com)
|
||||
RESEND_API_KEY=
|
||||
|
||||
59
.github/workflows/release.yml
vendored
Normal file
59
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Read version from package.json
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(jq -r '.version' package.json)
|
||||
echo "value=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check if tag already exists
|
||||
id: tag_check
|
||||
run: |
|
||||
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Extract changelog for this version
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.value }}"
|
||||
# Extrai o bloco entre ## [X.Y.Z] e o próximo ## [
|
||||
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||
# Remove linhas em branco do início e fim
|
||||
NOTES=$(echo "$NOTES" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba}')
|
||||
{
|
||||
echo "notes<<EOF"
|
||||
echo "$NOTES"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create tag and GitHub Release
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: ${{ steps.version.outputs.tag }}
|
||||
body: ${{ steps.changelog.outputs.notes }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -12,7 +12,6 @@
|
||||
"**/.next": true,
|
||||
".next": true
|
||||
},
|
||||
"explorerExclude.backup": {},
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
@@ -25,9 +24,7 @@
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"eslint.enable": false,
|
||||
"prettier.enable": false,
|
||||
"typescript.preferences.organizeImportsCollation": "ordinal",
|
||||
"editor.fontSize": 15,
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
|
||||
108
CHANGELOG.md
108
CHANGELOG.md
@@ -5,6 +5,114 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Preferências: nova configuração de tamanho máximo por arquivo de anexo (5, 10, 25, 50 ou 100 MB), persistida no banco e respeitada em todos os pontos de upload
|
||||
- Lançamentos: novo escopo `"period"` na ação em lote, que aplica a alteração a todos os lançamentos do período sem sobrescrever o pagador individual de cada um
|
||||
### Corrigido
|
||||
|
||||
- Lançamentos: ao editar um lançamento de série, uploads e remoções de anexo agora aguardam a escolha de escopo da ação em lote antes de serem executados, evitando que o anexo fosse aplicado no lançamento errado
|
||||
- Lançamentos: ação em lote com escopo `"period"` não sobrescreve mais o `payerId` individual de cada lançamento ao alterar o pagador
|
||||
|
||||
### Alterado
|
||||
|
||||
- Configurações: redesign visual da página com separadores entre seções e títulos maiores
|
||||
- Configurações: seção "Extrato e lançamentos" renomeada para "Lançamentos"
|
||||
|
||||
## [2.1.1] - 2026-03-29
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Navbar: novo componente `NavbarShell` que unifica a estrutura da barra de navegação entre o app e a landing page
|
||||
- UI: nova variante `navbar` no componente `Button`, centralizando os estilos de botões usados dentro da navbar
|
||||
- Analytics: integração com Umami self-hosted via script tag no layout raiz
|
||||
|
||||
### Alterado
|
||||
|
||||
- Navbar: `AnimatedThemeToggler` e `RefreshPageButton` passam a aceitar prop `variant` para adaptar estilos ao contexto (navbar ou sidebar)
|
||||
- Navbar: estilos inline duplicados de `nav-styles.ts` migrados para a variante `navbar` do Button
|
||||
- Logo: prop `showVersion` removida; prop `colorIcon` passa a aplicar filtro de cor também no variant `compact`
|
||||
- Scripts: `mockup` renomeado para `db:seed`; `db:enableExtensions` renomeado para `db:extensions`; script `dev-env` removido
|
||||
- Landing: `MobileNav` simplificado com a remoção da prop `triggerClassName`
|
||||
|
||||
### Removido
|
||||
|
||||
- Navbar: arquivo `nav-styles.ts` removido após migração dos estilos para a variante `navbar`
|
||||
- Dependências: `@vercel/analytics` e `@vercel/speed-insights` removidos (substituídos pelo Umami self-hosted)
|
||||
|
||||
## [2.1.0] - 2026-03-28
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Lançamentos: suporte a anexos em transações com upload direto para storage compatível com S3, persistência em tabelas dedicadas (`anexos` e `lancamento_anexos`) e ações de visualizar/remover no detalhe do lançamento
|
||||
- Infraestrutura: novo workflow `.github/workflows/release.yml` para criar tag e GitHub Release automaticamente a partir da versão do `package.json` e da entrada correspondente no `CHANGELOG.md`
|
||||
|
||||
### Alterado
|
||||
|
||||
- Anexos: upload agora exige token assinado por arquivo, valida propriedade da transação também na leitura/remoção e confere tamanho/tipo do objeto no storage antes de persistir o vínculo no banco
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Lançamentos: criação de transações no cartão de crédito agora bloqueia períodos cujas faturas já estão pagas, evitando divergência no relatório de análise de parcelas
|
||||
|
||||
## [2.0.3] - 2026-03-26
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Lançamentos: `/transactions` deixa de depender de `crypto.randomUUID()` no carregamento inicial, corrigindo a falha em ambientes self-hosted sem HTTPS ao abrir a página
|
||||
|
||||
## [2.0.2] - 2026-03-25
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Scripts: novo comando `mockup` no `package.json` para executar `scripts/mock-data.ts`
|
||||
- Navbar: novo estado persistido para notificações do sino, permitindo marcar alertas de fatura, boleto e orçamento como lidos ou arquivados por usuário
|
||||
|
||||
### Alterado
|
||||
|
||||
- Navbar: o snapshot global de notificações deixa de depender do `periodo` da URL atual e passa a usar o período corrente do negócio; itens lidos saem do badge e itens arquivados somem da lista padrão do sino
|
||||
- Navbar: dropdown de notificações agora permite mostrar itens arquivados e reverter ações de leitura e arquivamento diretamente em cada item
|
||||
- Navbar: filtro da lista de notificações no sino foi refinado para um seletor explícito entre `Ativas` e `Arquivadas`, com destaque visual mais forte para a aba ativa
|
||||
- Navbar: componente `notification-bell` foi desmembrado em hook e componentes locais menores, reduzindo acoplamento e facilitando manutenção
|
||||
- Dashboard: detalhamento por categoria agora oculta categorias sem movimentação no período, reduzindo ruído visual no card
|
||||
- UI: arte decorativa do topo da dashboard foi restrita à faixa do cabeçalho de boas-vindas, evitando que o `dot pattern` e o gradiente claro alterem a leitura visual do month picker
|
||||
- Lançamentos em série: a edição em lote agora também permite propagar o status de pagamento (`isSettled`) para transações não feitas no cartão de crédito
|
||||
- Seed de conta vazia: `scripts/mock-data.ts` agora processa `--help` antes de exigir `DATABASE_URL` e só cria categorias/pagador admin depois de validar que a conta está financeiramente vazia
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Navbar: ao desarquivar a última notificação no modo de arquivadas, o dropdown volta automaticamente para a listagem padrão e o toggle deixa de ficar travado
|
||||
- Filtros financeiros: transações de conta com observação nula, como compras parceladas no Pix, deixam de ser ocultadas indevidamente em `/transactions`, dashboard e relatórios quando a conta está configurada para desconsiderar o saldo inicial
|
||||
- Backup: geração do arquivo `*.data.sql.gz` volta a usar a saída correta do `pg_restore`
|
||||
|
||||
### Removido
|
||||
|
||||
- DB: colunas `system_font` e `money_font` da tabela `preferencias_usuario`, que não são mais utilizadas no código
|
||||
|
||||
## [2.0.1] - 2026-03-21
|
||||
|
||||
### Corrigido
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
||||
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
||||
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
||||
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog.
|
||||
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json`.
|
||||
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
||||
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
||||
|
||||
---
|
||||
|
||||
@@ -43,6 +44,10 @@ Use esta pergunta:
|
||||
|
||||
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:
|
||||
|
||||
- auth: `src/shared/lib/auth/*`
|
||||
|
||||
36
README.md
36
README.md
@@ -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.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
@@ -32,6 +32,7 @@
|
||||
- [Início Rápido (manual)](#-início-rápido)
|
||||
- [Scripts Disponíveis](#-scripts-disponíveis)
|
||||
- [Docker](#-docker)
|
||||
- [Storage S3 Compatível](#-storage-s3-compatível)
|
||||
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
|
||||
- [Arquitetura](#-arquitetura)
|
||||
- [Contribuindo](#-contribuindo)
|
||||
@@ -155,7 +156,7 @@ O script irá:
|
||||
|
||||
```bash
|
||||
docker compose up db -d
|
||||
pnpm db:enableExtensions
|
||||
pnpm db:extensions
|
||||
```
|
||||
|
||||
4. **Execute as migrations e inicie**
|
||||
@@ -238,6 +239,30 @@ DB_PORT=5433 # Padrão: 5432
|
||||
|
||||
---
|
||||
|
||||
## ☁️ Storage S3 Compatível
|
||||
|
||||
O suporte a anexos de lançamentos usa upload direto com URL pré-assinada. Essa configuração é opcional, mas passa a ser necessária se você quiser habilitar anexos no app.
|
||||
|
||||
### Variáveis
|
||||
|
||||
```env
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET=
|
||||
```
|
||||
|
||||
### Compatibilidade
|
||||
|
||||
- O código atual espera um provider com API compatível com S3 e suporte a `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` e URLs pré-assinadas.
|
||||
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](/home/ubuntu/github/openmonetis/src/shared/lib/storage/s3-client.ts).
|
||||
- Em geral isso cobre MinIO, Cloudflare R2, Backblaze B2 S3-Compatible, DigitalOcean Spaces e AWS S3. Mas foi testado apenas no Supabase Storage.
|
||||
- Se o seu provider exigir `virtual-hosted-style` em vez de `path-style`, você vai precisar ajustar essa configuração antes de usar anexos.
|
||||
- Se as variáveis de S3 não forem configuradas, mantenha os anexos desabilitados no seu fluxo de uso.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Variáveis de Ambiente
|
||||
|
||||
Copie `.env.example` para `.env` e configure:
|
||||
@@ -258,6 +283,13 @@ POSTGRES_USER=openmonetis
|
||||
POSTGRES_PASSWORD=openmonetis_dev_password
|
||||
POSTGRES_DB=openmonetis_db
|
||||
|
||||
# S3 Server (opcional, necessario para anexos)
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET=
|
||||
|
||||
# Multi-domínio (landing-only no domínio público)
|
||||
# PUBLIC_DOMAIN=openmonetis.com
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.9/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
@@ -4,11 +4,11 @@ name: openmonetis
|
||||
# MODOS DE USO:
|
||||
# 1. Banco LOCAL (PostgreSQL em container):
|
||||
# - Configure DATABASE_URL com host "db" no .env
|
||||
# - Execute: docker compose up --build
|
||||
# - Execute: docker compose up
|
||||
#
|
||||
# 2. Banco REMOTO (ex: Supabase):
|
||||
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
|
||||
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
||||
# - Execute: docker compose up app --build (apenas o serviço app)
|
||||
# - Execute: docker compose up app (apenas o serviço app)
|
||||
#
|
||||
# 3. Para parar todos os serviços:
|
||||
# - Execute: docker compose down
|
||||
@@ -29,22 +29,21 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
||||
# Garante que os dados ficam no volume montado (evita perda após down/up)
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
# Configurações de performance
|
||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||
|
||||
ports:
|
||||
# Mapeia porta 5432 do container para 5432 do host
|
||||
# Útil para conectar com ferramentas externas (ex: DBeaver, pgAdmin)
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
|
||||
volumes:
|
||||
# Volume nomeado para persistência de dados
|
||||
# Os dados sobrevivem ao restart do container
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
# Script de inicialização (cria extensão pgcrypto automaticamente)
|
||||
- ./scripts/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
|
||||
# Cria extensão pgcrypto inline (necessária para gen_random_bytes no schema)
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
echo 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' > /docker-entrypoint-initdb.d/init.sql
|
||||
exec docker-entrypoint.sh postgres
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
@@ -57,80 +56,65 @@ services:
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
- openmonetis_network
|
||||
|
||||
# Descomentar para ativar logs de queries (debug)
|
||||
# command: ["postgres", "-c", "log_statement=all"]
|
||||
# Para ativar logs de queries (debug), adicione ao command acima:
|
||||
# exec docker-entrypoint.sh postgres -c log_statement=all
|
||||
|
||||
# ============================================
|
||||
# Serviço: Aplicação Next.js
|
||||
# ============================================
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: felipegcoutinho/openmonetis:latest
|
||||
|
||||
container_name: openmonetis_app
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
# Mapeia porta 3000 do container para 3000 do host
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
|
||||
environment:
|
||||
# Variáveis de ambiente da aplicação
|
||||
NODE_ENV: production
|
||||
|
||||
# DATABASE_URL do .env
|
||||
# Banco local: use host "db" (serviço Docker)
|
||||
# Banco remoto: use a URL completa do provider
|
||||
# Banco local: use host "db" | Banco remoto: URL completa do provider
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
|
||||
# Outras variáveis de ambiente necessárias
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||
|
||||
# Configurações de email (se usar)
|
||||
# Email (opcional)
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
||||
|
||||
# Configurações de OAuth (se usar)
|
||||
# OAuth (opcional)
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
|
||||
# Configurações de AI providers (se usar)
|
||||
# AI providers (opcional)
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||
|
||||
# Só depende do 'db' se estiver usando banco local
|
||||
# Para banco remoto, comente a linha abaixo ou suba apenas: docker compose up app
|
||||
# Para banco remoto, comente as linhas abaixo
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
- openmonetis_network
|
||||
|
||||
# Script de inicialização: roda migrations antes de iniciar o app
|
||||
# ATENÇÃO: Em produção, considere rodar migrations separadamente por segurança
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
echo "🚀 Aguardando banco de dados..."
|
||||
echo "Aguardando banco de dados..."
|
||||
sleep 5
|
||||
|
||||
echo "📦 Rodando migrations..."
|
||||
pnpm db:push || echo "⚠️ Migrations falharam ou já estão atualizadas"
|
||||
echo "Rodando migrations..."
|
||||
pnpm db:push || echo "Migrations falharam ou já estão atualizadas"
|
||||
|
||||
echo "✅ Iniciando aplicação Next.js..."
|
||||
echo "Iniciando aplicação Next.js..."
|
||||
node server.js
|
||||
|
||||
# Healthcheck da aplicação
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -151,13 +135,4 @@ services:
|
||||
# ============================================
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: openmonetis_postgres_data
|
||||
driver: local
|
||||
|
||||
# ============================================
|
||||
# Networks
|
||||
# ============================================
|
||||
networks:
|
||||
openmonetis_network:
|
||||
name: openmonetis_network
|
||||
driver: bridge
|
||||
|
||||
1
drizzle/0010_lame_psynapse.sql
Normal file
1
drizzle/0010_lame_psynapse.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- placeholder: migration aplicada via db:push, arquivo original não preservado
|
||||
37
drizzle/0023_sturdy_wolfpack.sql
Normal file
37
drizzle/0023_sturdy_wolfpack.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE "anexos" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"chave_arquivo" text NOT NULL,
|
||||
"nome_arquivo" text NOT NULL,
|
||||
"tamanho_bytes" integer NOT NULL,
|
||||
"mime_type" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "anexos_chave_arquivo_unique" UNIQUE("chave_arquivo")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "dashboard_notification_states" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"notification_key" text NOT NULL,
|
||||
"fingerprint" text NOT NULL,
|
||||
"read_at" timestamp with time zone,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "lancamento_anexos" (
|
||||
"lancamento_id" uuid NOT NULL,
|
||||
"anexo_id" uuid NOT NULL,
|
||||
CONSTRAINT "lancamento_anexos_lancamento_id_anexo_id_pk" PRIMARY KEY("lancamento_id","anexo_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "anexos" ADD CONSTRAINT "anexos_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "dashboard_notification_states" ADD CONSTRAINT "dashboard_notification_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lancamento_anexos" ADD CONSTRAINT "lancamento_anexos_lancamento_id_lancamentos_id_fk" FOREIGN KEY ("lancamento_id") REFERENCES "public"."lancamentos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lancamento_anexos" ADD CONSTRAINT "lancamento_anexos_anexo_id_anexos_id_fk" FOREIGN KEY ("anexo_id") REFERENCES "public"."anexos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "dashboard_notification_states_user_id_key_unique" ON "dashboard_notification_states" USING btree ("user_id","notification_key");--> statement-breakpoint
|
||||
CREATE INDEX "dashboard_notification_states_user_id_archived_idx" ON "dashboard_notification_states" USING btree ("user_id","archived_at");--> statement-breakpoint
|
||||
CREATE INDEX "lancamento_anexos_anexo_id_idx" ON "lancamento_anexos" USING btree ("anexo_id");--> statement-breakpoint
|
||||
ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint
|
||||
ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font";
|
||||
1
drizzle/0024_petite_lucky_pierre.sql
Normal file
1
drizzle/0024_petite_lucky_pierre.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "preferencias_usuario" ADD COLUMN "attachment_max_size_mb" integer DEFAULT 50 NOT NULL;
|
||||
2
drizzle/0026_next_blue_blade.sql
Normal file
2
drizzle/0026_next_blue_blade.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint
|
||||
ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font";
|
||||
14
drizzle/0027_glorious_mindworm.sql
Normal file
14
drizzle/0027_glorious_mindworm.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "dashboard_notification_states" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"notification_key" text NOT NULL,
|
||||
"fingerprint" text NOT NULL,
|
||||
"read_at" timestamp with time zone,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "dashboard_notification_states" ADD CONSTRAINT "dashboard_notification_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "dashboard_notification_states_user_id_key_unique" ON "dashboard_notification_states" USING btree ("user_id","notification_key");--> statement-breakpoint
|
||||
CREATE INDEX "dashboard_notification_states_user_id_archived_idx" ON "dashboard_notification_states" USING btree ("user_id","archived_at");
|
||||
2704
drizzle/meta/0023_snapshot.json
Normal file
2704
drizzle/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2711
drizzle/meta/0024_snapshot.json
Normal file
2711
drizzle/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -162,6 +162,20 @@
|
||||
"when": 1748000000000,
|
||||
"tag": "0022_import-category-mappings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1774529878374,
|
||||
"tag": "0023_sturdy_wolfpack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1774891206703,
|
||||
"tag": "0024_petite_lucky_pierre",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ dotenv.config();
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
},
|
||||
cacheComponents: true,
|
||||
reactCompiler: true,
|
||||
images: {
|
||||
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
|
||||
|
||||
32
package.json
32
package.json
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.0.1",
|
||||
"version": "2.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev-env": "tsx scripts/dev.ts",
|
||||
"db:seed": "tsx scripts/mock-data.ts",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome check .",
|
||||
@@ -13,9 +13,10 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:enableExtensions": "tsx scripts/postgres/enable-extensions.ts",
|
||||
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"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:d": "docker compose up --build -d",
|
||||
"docker:down": "docker compose down",
|
||||
@@ -28,10 +29,12 @@
|
||||
"backup": "bash scripts/backup.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.63",
|
||||
"@ai-sdk/google": "^3.0.52",
|
||||
"@ai-sdk/openai": "^3.0.47",
|
||||
"@better-auth/passkey": "^1.5.5",
|
||||
"@ai-sdk/anthropic": "^3.0.64",
|
||||
"@ai-sdk/google": "^3.0.53",
|
||||
"@ai-sdk/openai": "^3.0.48",
|
||||
"@aws-sdk/client-s3": "^3.1019.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1019.0",
|
||||
"@better-auth/passkey": "^1.5.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -58,26 +61,25 @@
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"ai": "^6.0.134",
|
||||
"better-auth": "1.5.5",
|
||||
"ai": "^6.0.141",
|
||||
"better-auth": "1.5.6",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "0.4.6",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"pg": "8.20.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "3.8.0",
|
||||
"recharts": "3.8.1",
|
||||
"resend": "^6.9.4",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.5.0",
|
||||
@@ -86,7 +88,7 @@
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@biomejs/biome": "2.4.9",
|
||||
"@tailwindcss/postcss": "4.2.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "25.5.0",
|
||||
@@ -97,6 +99,6 @@
|
||||
"drizzle-kit": "0.31.10",
|
||||
"tailwindcss": "4.2.2",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
1791
pnpm-lock.yaml
generated
1791
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
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",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./america-medium.woff2",
|
||||
weight: "500",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
display: "swap",
|
||||
variable: "--font-america",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.0 KiB |
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
@@ -81,7 +81,7 @@ fi
|
||||
|
||||
# Extrai dados puros do dump custom (sem nova conexão ao banco)
|
||||
pg_restore --data-only --schema=public --no-owner --no-privileges \
|
||||
"$DUMP_FILE" | gzip > "$DATA_FILE"
|
||||
-f - "$DUMP_FILE" | gzip > "$DATA_FILE"
|
||||
|
||||
log "Dump concluído: $(du -sh "$DUMP_FILE" | cut -f1) (.dump) | $(du -sh "$SQL_FILE" | cut -f1) (.sql.gz) | $(du -sh "$DATA_FILE" | cut -f1) (.data.sql.gz)"
|
||||
|
||||
|
||||
@@ -135,11 +135,11 @@ type SeedSummary = {
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Uso:
|
||||
pnpm seed:empty-account -- --userId=<id> --startPeriod=YYYY-MM [--months=${DEFAULT_MONTHS}]
|
||||
pnpm mockup -- --userId=<id> --startPeriod=YYYY-MM [--months=${DEFAULT_MONTHS}]
|
||||
|
||||
Exemplos:
|
||||
pnpm seed:empty-account -- --userId=user_123 --startPeriod=2026-01
|
||||
pnpm seed:empty-account -- --userId=user_123 --startPeriod=2025-10 --months=8
|
||||
pnpm mockup -- --userId=user_123 --startPeriod=2026-01
|
||||
pnpm mockup -- --userId=user_123 --startPeriod=2025-10 --months=8
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -766,11 +766,12 @@ async function seedInvoicesForCards(params: {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL não está configurada no ambiente.");
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const logoOptions = await loadLogoOptions();
|
||||
const avatarOptions = await loadAvatarOptions();
|
||||
const businessToday = getBusinessTodayInfo();
|
||||
@@ -794,9 +795,8 @@ async function main() {
|
||||
throw new Error(`Usuário ${options.userId} não foi encontrado.`);
|
||||
}
|
||||
|
||||
await ensureCategories(targetUser.id);
|
||||
const adminPayer = await ensureAdminPayer(targetUser);
|
||||
await assertFinancialSpaceIsEmpty(targetUser.id);
|
||||
const adminPayer = await ensureAdminPayer(targetUser);
|
||||
|
||||
const categoriesByName = await ensureCategories(targetUser.id);
|
||||
|
||||
|
||||
39
setup.mjs
39
setup.mjs
@@ -21,6 +21,7 @@ const c = {
|
||||
red: "\x1b[31m",
|
||||
yellow: "\x1b[33m",
|
||||
cyan: "\x1b[36m",
|
||||
orange: "\x1b[38;5;214m",
|
||||
};
|
||||
|
||||
const sym = {
|
||||
@@ -81,10 +82,38 @@ function abort(msg) {
|
||||
|
||||
// ─── Header ──────────────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`
|
||||
${c.bold}${c.cyan} OpenMonetis — Setup${c.reset}
|
||||
${c.dim}Gestão financeira self-hosted${c.reset}
|
||||
`);
|
||||
const logoLines = [
|
||||
".............................+@@@@@@@@@@=.............................",
|
||||
".............................@@@@@@@@@@@:.............................",
|
||||
"...................+@@@@@@*-:@@@@@@@@@@%...=@@@@@@-...................",
|
||||
"..................@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%..................",
|
||||
"................=@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+................",
|
||||
"...................-=+%@@@@@@@@@@@@@@@@@@@@@*:........................",
|
||||
".......................#@@@@@@@@@@@@@@@@@@@@@@@+......................",
|
||||
"....................%@@@@@@@@@@@@@%#@@@@@@@@@@@@*.....................",
|
||||
"....................+@@@@@@@@@@@......*@@@@@@#........................",
|
||||
".........................:#@@=...........+#...........................",
|
||||
];
|
||||
|
||||
const nameLines = [
|
||||
" ___ __ __ _ _ ",
|
||||
" / _ \\ _ __ ___ _ __ | \\/ | ___ _ __ ___| |_(_)___ ",
|
||||
" | | | | '_ \\ / _ \\ '_ \\| |\\/| |/ _ \\| '_ \\ / _ \\ __| / __|",
|
||||
" | |_| | |_) | __/ | | | | | | (_) | | | | __/ |_| \\__ \\",
|
||||
" \\___/| .__/ \\___|_| |_|_| |_|\\___/|_| |_|\\___|\\__|_|___/",
|
||||
" |_| ",
|
||||
];
|
||||
|
||||
const nameStart = Math.floor((logoLines.length - nameLines.length) / 2);
|
||||
|
||||
console.log();
|
||||
for (let i = 0; i < logoLines.length; i++) {
|
||||
const logoCol = c.orange + logoLines[i].replaceAll(".", " ").substring(14, 56).padEnd(42) + c.reset;
|
||||
const nameIdx = i - nameStart;
|
||||
const nameCol = nameIdx >= 0 && nameIdx < nameLines.length ? nameLines[nameIdx] : "";
|
||||
console.log(logoCol + " " + nameCol);
|
||||
}
|
||||
console.log(`\n${" ".repeat(46)}${c.dim}Gestão financeira · self-hosted${c.reset}\n`);
|
||||
|
||||
// ─── ETAPA 1: Verificações do sistema ────────────────────────────────────────
|
||||
|
||||
@@ -329,7 +358,7 @@ if (useLocalDocker) {
|
||||
// Extensões
|
||||
s = spinner("Habilitando extensões do banco...");
|
||||
try {
|
||||
run("pnpm db:enableExtensions", { cwd: targetDir });
|
||||
run("pnpm db:extensions", { cwd: targetDir });
|
||||
s.stop("Extensões habilitadas");
|
||||
} catch {
|
||||
s.fail("Falha ao habilitar extensões");
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { LoginForm } from "@/features/auth/components/login-form";
|
||||
import { Logo } from "@/shared/components/logo";
|
||||
|
||||
export default function LoginPage() {
|
||||
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="w-full max-w-sm md:max-w-5xl">
|
||||
<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="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 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { SignupForm } from "@/features/auth/components/signup-form";
|
||||
import { Logo } from "@/shared/components/logo";
|
||||
|
||||
export default function Page() {
|
||||
export default function SignupPage() {
|
||||
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="w-full max-w-sm md:max-w-5xl">
|
||||
<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="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 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { AccountDialog } from "@/features/accounts/components/account-dialog";
|
||||
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
|
||||
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;
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
await connection();
|
||||
const { accountId } = await params;
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
@@ -190,6 +192,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
allowCreate={false}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { AccountsPage } from "@/features/accounts/components/accounts-page";
|
||||
import { fetchAllAccountsForUser } from "@/features/accounts/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const { activeAccounts, archivedAccounts, logoOptions } =
|
||||
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 { fetchBudgetsForUser } from "@/features/budgets/queries";
|
||||
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);
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { MonthlyCalendar } from "@/features/calendar/components/monthly-calendar";
|
||||
import { fetchCalendarData } from "@/features/calendar/queries";
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ type PageProps = {
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const resolvedParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import type { FinancialAccount } from "@/db/schema";
|
||||
import { CardDialog } from "@/features/cards/components/card-dialog";
|
||||
import type { Card } from "@/features/cards/components/types";
|
||||
@@ -39,6 +40,7 @@ type PageProps = {
|
||||
};
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
await connection();
|
||||
const { cardId } = await params;
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
@@ -202,6 +204,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
allowCreate
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
defaultCardId={card.id}
|
||||
defaultPaymentMethod="Cartão de crédito"
|
||||
lockCardSelection
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { CardsPage } from "@/features/cards/components/cards-page";
|
||||
import { fetchAllCardsForUser } from "@/features/cards/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const { activeCards, archivedCards, accounts, logoOptions } =
|
||||
await fetchAllCardsForUser(userId);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header";
|
||||
import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries";
|
||||
import { fetchUserPreferences } from "@/features/settings/queries";
|
||||
@@ -32,6 +33,7 @@ const getSingleParam = (
|
||||
};
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
await connection();
|
||||
const { categoryId } = await params;
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
@@ -99,6 +101,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
allowCreate={true}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { connection } from "next/server";
|
||||
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
|
||||
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { getCurrentPeriod } from "@/shared/utils/period";
|
||||
|
||||
export default async function HistoricoCategoriasPage() {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const currentPeriod = getCurrentPeriod();
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { CategoriesPage } from "@/features/categories/components/categories-page";
|
||||
import { fetchCategoriesForUser } from "@/features/categories/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const categories = await fetchCategoriesForUser(userId);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
|
||||
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
|
||||
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
||||
@@ -14,6 +15,7 @@ type PageProps = {
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { InboxPage } from "@/features/inbox/components/inbox-page";
|
||||
import {
|
||||
type ResolvedInboxSearchParams,
|
||||
@@ -31,6 +32,7 @@ const EMPTY_DIALOG_DATA = {
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const activeStatus = resolveInboxStatus(resolvedSearchParams);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { InsightsPage } from "@/features/insights/components/insights-page";
|
||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
import { parsePeriodParam } from "@/shared/utils/period";
|
||||
@@ -18,6 +19,7 @@ const getSingleParam = (
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
import { connection } from "next/server";
|
||||
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
|
||||
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
|
||||
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
||||
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
||||
import { getUserSession } from "@/shared/lib/auth/server";
|
||||
import { parsePeriodParam } from "@/shared/utils/period";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
searchParams,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}>) {
|
||||
await connection();
|
||||
const session = await getUserSession();
|
||||
|
||||
// Buscar notificações para o período atual
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = resolvedSearchParams?.periodo;
|
||||
const singlePeriodoParam =
|
||||
typeof periodoParam === "string"
|
||||
? periodoParam
|
||||
: Array.isArray(periodoParam)
|
||||
? periodoParam[0]
|
||||
: null;
|
||||
const { period: currentPeriod } = parsePeriodParam(
|
||||
singlePeriodoParam ?? null,
|
||||
);
|
||||
const navbarData = await fetchDashboardNavbarData(
|
||||
session.user.id,
|
||||
currentPeriod,
|
||||
);
|
||||
const navbarData = await fetchDashboardNavbarData(session.user.id);
|
||||
|
||||
return (
|
||||
<PrivacyProvider>
|
||||
@@ -40,7 +23,7 @@ export default async function DashboardLayout({
|
||||
notificationsSnapshot={navbarData.notificationsSnapshot}
|
||||
/>
|
||||
<div className="relative flex flex-1 flex-col pt-16">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-32 overflow-hidden md:h-36">
|
||||
<DotPattern
|
||||
width={20}
|
||||
height={20}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { NotesPage } from "@/features/notes/components/notes-page";
|
||||
import { fetchAllNotesForUser } from "@/features/notes/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
RiWallet3Line,
|
||||
} from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { PayerCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card";
|
||||
import { PayerHeaderCard } from "@/features/payers/components/details/payer-header-card";
|
||||
import { PayerHistoryCard } from "@/features/payers/components/details/payer-history-card";
|
||||
@@ -91,6 +92,7 @@ const createEmptySlugMaps = (): SlugMaps => ({
|
||||
type OptionSet = ReturnType<typeof buildOptionSets>;
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
await connection();
|
||||
const { payerId } = await params;
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
@@ -390,6 +392,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
allowCreate={canEdit}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
||||
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
|
||||
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { PayersPage } from "@/features/payers/components/payers-page";
|
||||
import { fetchPayersForUser } from "@/features/payers/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const { payers, avatarOptions } = await fetchPayersForUser(userId);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RiBankCard2Line } from "@remixicon/react";
|
||||
import { connection } from "next/server";
|
||||
import { fetchCartoesReportData } from "@/features/reports/cards-report-queries";
|
||||
import { CardCategoryBreakdown } from "@/features/reports/components/cards/card-category-breakdown";
|
||||
import { CardInvoiceStatus } from "@/features/reports/components/cards/card-invoice-status";
|
||||
@@ -28,6 +29,7 @@ const getSingleParam = (
|
||||
export default async function RelatorioCartoesPage({
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import type { Category } from "@/db/schema";
|
||||
import { fetchCategoryChartData } from "@/features/reports/category-chart-queries";
|
||||
import { fetchCategoryReport } from "@/features/reports/category-report-queries";
|
||||
@@ -29,6 +30,7 @@ const getSingleParam = (
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
// Get authenticated user
|
||||
const userId = await getUserId();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { EstablishmentsList } from "@/features/reports/components/establishments/establishments-list";
|
||||
import { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards";
|
||||
import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter";
|
||||
@@ -36,6 +37,7 @@ const validatePeriodFilter = (value: string | null): PeriodFilter => {
|
||||
export default async function TopEstabelecimentosPage({
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
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 { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const data = await fetchInstallmentAnalysis(user.id);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RiArrowRightSLine } from "@remixicon/react";
|
||||
import { RiAndroidLine, RiArrowRightSLine } from "@remixicon/react";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { CompanionTab } from "@/features/settings/components/companion-tab";
|
||||
import { DeleteAccountForm } from "@/features/settings/components/delete-account-form";
|
||||
@@ -11,6 +12,7 @@ import { UpdateNameForm } from "@/features/settings/components/update-name-form"
|
||||
import { UpdatePasswordForm } from "@/features/settings/components/update-password-form";
|
||||
import { fetchSettingsPageData } from "@/features/settings/queries";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
import { Separator } from "@/shared/components/ui/separator";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
import { auth } from "@/shared/lib/auth/config";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
@@ -64,12 +67,13 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Preferências</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<h2 className="text-xl font-medium mb-1">Preferências</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Personalize sua experiência no OpenMonetis ajustando as
|
||||
configurações de acordo com suas necessidades.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<PreferencesForm
|
||||
statementNoteAsColumn={
|
||||
userPreferences?.statementNoteAsColumn ?? false
|
||||
@@ -77,25 +81,46 @@ export default async function Page() {
|
||||
transactionsColumnOrder={
|
||||
userPreferences?.transactionsColumnOrder ?? null
|
||||
}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="companion" className="mt-4">
|
||||
<CompanionTab tokens={userApiTokens} />
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h2 className="text-xl font-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">
|
||||
<RiAndroidLine className="h-3 w-3" />
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Capture notificações de transações dos seus apps de banco
|
||||
(Nubank, Itaú, Bradesco, Inter, C6 e outros) e envie para sua
|
||||
caixa de entrada.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<CompanionTab tokens={userApiTokens} />
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="nome" className="mt-4">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Alterar nome</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<h2 className="text-xl font-medium mb-1">Alterar nome</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
|
||||
ser exibido em diferentes seções do app e em comunicações.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<UpdateNameForm currentName={userName} />
|
||||
</div>
|
||||
</Card>
|
||||
@@ -105,12 +130,13 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Alterar senha</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<h2 className="text-xl font-medium mb-1">Alterar senha</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Defina uma nova senha para sua conta. Guarde-a em local
|
||||
seguro.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<UpdatePasswordForm authProvider={authProvider} />
|
||||
</div>
|
||||
</Card>
|
||||
@@ -120,12 +146,13 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Passkeys</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<h2 className="text-xl font-medium mb-1">Passkeys</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Passkeys permitem login sem senha, usando biometria (Face ID,
|
||||
Touch ID, Windows Hello) ou chaves de segurança.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<PasskeysForm />
|
||||
</div>
|
||||
</Card>
|
||||
@@ -135,13 +162,14 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<h2 className="text-xl font-medium mb-1">Alterar e-mail</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Atualize o e-mail associado à sua conta. Você precisará
|
||||
confirmar os links enviados para o novo e também para o e-mail
|
||||
atual (quando aplicável) para concluir a alteração.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<UpdateEmailForm
|
||||
currentEmail={userEmail}
|
||||
authProvider={authProvider}
|
||||
@@ -154,14 +182,15 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1 text-destructive">
|
||||
<h2 className="text-xl font-medium mb-1 text-destructive">
|
||||
Ações perigosas
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Você pode zerar os dados do OpenMonetis e manter seu acesso,
|
||||
ou excluir sua conta inteira de forma irreversível.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<DeleteAccountForm />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { ImportPage } from "@/features/transactions/components/import/import-page";
|
||||
import {
|
||||
buildOptionSets,
|
||||
@@ -7,6 +8,7 @@ import { fetchTransactionFilterSources } from "@/features/transactions/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const filterSources = await fetchTransactionFilterSources(userId);
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { fetchUserPreferences } from "@/features/settings/queries";
|
||||
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ type PageProps = {
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
@@ -102,6 +104,7 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
}}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
extraFeatures,
|
||||
getMetricsItems,
|
||||
mainFeatures,
|
||||
navbarActionClassName,
|
||||
navLinks,
|
||||
pwaHighlights,
|
||||
stackItems,
|
||||
@@ -27,6 +26,7 @@ import { landingImages } from "@/features/landing/images";
|
||||
import { fetchGitHubStats } from "@/features/landing/queries";
|
||||
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
||||
import { Logo } from "@/shared/components/logo";
|
||||
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
@@ -50,65 +50,60 @@ export default async function Page() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Navigation */}
|
||||
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center bg-primary">
|
||||
<div className="relative z-10 max-w-8xl mx-auto px-4 w-full flex h-full items-center justify-between">
|
||||
<Logo variant="compact" invertTextOnDark={false} />
|
||||
<NavbarShell>
|
||||
{/* Center Navigation Links */}
|
||||
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Center Navigation Links */}
|
||||
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<nav className="flex items-center gap-2 md:gap-3">
|
||||
<AnimatedThemeToggler className={navbarActionClassName} />
|
||||
{!isPublicDomain &&
|
||||
(session?.user ? (
|
||||
<Link prefetch href="/dashboard" className="hidden md:block">
|
||||
<nav className="ml-auto flex items-center gap-2 md:gap-3">
|
||||
<AnimatedThemeToggler variant="navbar" />
|
||||
{!isPublicDomain &&
|
||||
(session?.user ? (
|
||||
<Link prefetch href="/dashboard" className="hidden md:block">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Link href="/login">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
|
||||
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none"
|
||||
>
|
||||
Dashboard
|
||||
Entrar
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Link href="/login">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none"
|
||||
>
|
||||
Entrar
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/signup">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2"
|
||||
>
|
||||
Começar
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
<MobileNav
|
||||
isPublicDomain={isPublicDomain}
|
||||
isLoggedIn={!!session?.user}
|
||||
triggerClassName="border border-black/10 text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<Link href="/signup">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2"
|
||||
>
|
||||
Começar
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
<MobileNav
|
||||
isPublicDomain={isPublicDomain}
|
||||
isLoggedIn={!!session?.user}
|
||||
/>
|
||||
</nav>
|
||||
</NavbarShell>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
||||
@@ -131,7 +126,7 @@ export default async function Page() {
|
||||
Projeto Open Source
|
||||
</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,
|
||||
<span className="text-primary"> do seu jeito</span>
|
||||
</h1>
|
||||
@@ -212,7 +207,7 @@ export default async function Page() {
|
||||
className="flex flex-col items-center text-center gap-1.5"
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
<span className="text-xs md:text-sm text-muted-foreground">
|
||||
@@ -234,7 +229,7 @@ export default async function Page() {
|
||||
<Badge variant="outline" className="mb-4">
|
||||
Conheça as telas
|
||||
</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
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||
@@ -259,7 +254,7 @@ export default async function Page() {
|
||||
<Badge variant="outline" className="mb-4">
|
||||
O que tem aqui
|
||||
</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
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||
@@ -287,7 +282,7 @@ export default async function Page() {
|
||||
/>
|
||||
</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}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
@@ -351,7 +346,7 @@ export default async function Page() {
|
||||
<RiSmartphoneLine className="size-3.5 mr-1" />
|
||||
Mobile
|
||||
</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
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||
@@ -389,7 +384,7 @@ export default async function Page() {
|
||||
<RiSmartphoneLine className="size-3.5 mr-1" />
|
||||
PWA instalável
|
||||
</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
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
@@ -435,7 +430,7 @@ export default async function Page() {
|
||||
Companion Android
|
||||
</Badge>
|
||||
</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
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
@@ -446,7 +441,7 @@ export default async function Page() {
|
||||
{companionSteps.map((step, index) => (
|
||||
<li key={step.title} className="flex items-start gap-3">
|
||||
<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={{
|
||||
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
|
||||
color: step.colorVar,
|
||||
@@ -534,7 +529,7 @@ export default async function Page() {
|
||||
<Badge variant="outline" className="mb-4">
|
||||
Stack técnica
|
||||
</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
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||
@@ -561,7 +556,7 @@ export default async function Page() {
|
||||
/>
|
||||
</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}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
|
||||
@@ -587,7 +582,7 @@ export default async function Page() {
|
||||
<Badge variant="outline" className="mb-4">
|
||||
Como usar
|
||||
</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
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
||||
@@ -622,7 +617,7 @@ export default async function Page() {
|
||||
<Badge variant="outline" className="mb-4">
|
||||
Para quem é?
|
||||
</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
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
||||
@@ -649,7 +644,7 @@ export default async function Page() {
|
||||
/>
|
||||
</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">
|
||||
{item.description}
|
||||
</p>
|
||||
@@ -669,7 +664,7 @@ export default async function Page() {
|
||||
<div className="max-w-8xl mx-auto px-4">
|
||||
<AnimateOnScroll>
|
||||
<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?
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
|
||||
@@ -720,7 +715,7 @@ export default async function Page() {
|
||||
</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">
|
||||
<li>
|
||||
<Link
|
||||
@@ -754,7 +749,7 @@ export default async function Page() {
|
||||
</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">
|
||||
<li>
|
||||
<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,6 +1,5 @@
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
||||
import { Toaster } from "@/shared/components/ui/sonner";
|
||||
import "./globals.css";
|
||||
@@ -23,19 +22,23 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="pt-BR"
|
||||
className={`${america.variable} ${america.className}`}
|
||||
className={`${america.variable} ${america.className} `}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
|
||||
<script
|
||||
defer
|
||||
src="https://umami.felipecoutinho.com/script.js"
|
||||
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
|
||||
data-domains="openmonetis.com"
|
||||
/>
|
||||
</head>
|
||||
<body className="subpixel-antialiased" suppressHydrationWarning>
|
||||
<body className="antialiased" suppressHydrationWarning>
|
||||
<ThemeProvider attribute="class" defaultTheme="light">
|
||||
{children}
|
||||
<Suspense>{children}</Suspense>
|
||||
<Toaster position="top-right" />
|
||||
</ThemeProvider>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
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 {
|
||||
return {
|
||||
rules: [
|
||||
@@ -21,15 +17,15 @@ export default function robots(): MetadataRoute.Robots {
|
||||
"/notes",
|
||||
"/insights",
|
||||
"/calendar",
|
||||
"/consultor",
|
||||
"/attachments",
|
||||
"/settings",
|
||||
"/reports",
|
||||
"/inbox",
|
||||
"/login",
|
||||
"/signup",
|
||||
"/api/",
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${BASE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
150
src/db/schema.ts
150
src/db/schema.ts
@@ -132,11 +132,10 @@ export const userPreferences = pgTable("preferencias_usuario", {
|
||||
statementNoteAsColumn: boolean("extrato_note_as_column")
|
||||
.notNull()
|
||||
.default(false),
|
||||
systemFont: text("system_font").notNull().default("ai-sans"),
|
||||
moneyFont: text("money_font").notNull().default("ai-sans"),
|
||||
transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
|
||||
string[] | null
|
||||
>(),
|
||||
attachmentMaxSizeMb: integer("attachment_max_size_mb").notNull().default(50),
|
||||
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
@@ -527,6 +526,40 @@ export const inboxItems = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const dashboardNotificationStates = pgTable(
|
||||
"dashboard_notification_states",
|
||||
{
|
||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
notificationKey: text("notification_key").notNull(),
|
||||
fingerprint: text("fingerprint").notNull(),
|
||||
readAt: timestamp("read_at", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
}),
|
||||
archivedAt: timestamp("archived_at", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
}),
|
||||
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdNotificationKeyUnique: uniqueIndex(
|
||||
"dashboard_notification_states_user_id_key_unique",
|
||||
).on(table.userId, table.notificationKey),
|
||||
userIdArchivedAtIdx: index(
|
||||
"dashboard_notification_states_user_id_archived_idx",
|
||||
).on(table.userId, table.archivedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const installmentAnticipations = pgTable(
|
||||
"antecipacoes_parcelas",
|
||||
{
|
||||
@@ -815,32 +848,36 @@ export const inboxItemsRelations = relations(inboxItems, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const transactionsRelations = relations(transactions, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [transactions.userId],
|
||||
references: [user.id],
|
||||
export const transactionsRelations = relations(
|
||||
transactions,
|
||||
({ one, many }) => ({
|
||||
user: one(user, {
|
||||
fields: [transactions.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
card: one(cards, {
|
||||
fields: [transactions.cardId],
|
||||
references: [cards.id],
|
||||
}),
|
||||
financialAccount: one(financialAccounts, {
|
||||
fields: [transactions.accountId],
|
||||
references: [financialAccounts.id],
|
||||
}),
|
||||
category: one(categories, {
|
||||
fields: [transactions.categoryId],
|
||||
references: [categories.id],
|
||||
}),
|
||||
payer: one(payers, {
|
||||
fields: [transactions.payerId],
|
||||
references: [payers.id],
|
||||
}),
|
||||
anticipation: one(installmentAnticipations, {
|
||||
fields: [transactions.anticipationId],
|
||||
references: [installmentAnticipations.id],
|
||||
}),
|
||||
transactionAttachments: many(transactionAttachments),
|
||||
}),
|
||||
card: one(cards, {
|
||||
fields: [transactions.cardId],
|
||||
references: [cards.id],
|
||||
}),
|
||||
financialAccount: one(financialAccounts, {
|
||||
fields: [transactions.accountId],
|
||||
references: [financialAccounts.id],
|
||||
}),
|
||||
category: one(categories, {
|
||||
fields: [transactions.categoryId],
|
||||
references: [categories.id],
|
||||
}),
|
||||
payer: one(payers, {
|
||||
fields: [transactions.payerId],
|
||||
references: [payers.id],
|
||||
}),
|
||||
anticipation: one(installmentAnticipations, {
|
||||
fields: [transactions.anticipationId],
|
||||
references: [installmentAnticipations.id],
|
||||
}),
|
||||
}));
|
||||
);
|
||||
|
||||
export const installmentAnticipationsRelations = relations(
|
||||
installmentAnticipations,
|
||||
@@ -864,6 +901,40 @@ export const installmentAnticipationsRelations = relations(
|
||||
}),
|
||||
);
|
||||
|
||||
// ===================== ATTACHMENTS =====================
|
||||
|
||||
export const attachments = pgTable("anexos", {
|
||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
fileKey: text("chave_arquivo").notNull().unique(),
|
||||
fileName: text("nome_arquivo").notNull(),
|
||||
fileSize: integer("tamanho_bytes").notNull(),
|
||||
mimeType: text("mime_type").notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
export const transactionAttachments = pgTable(
|
||||
"lancamento_anexos",
|
||||
{
|
||||
transactionId: uuid("lancamento_id")
|
||||
.notNull()
|
||||
.references(() => transactions.id, { onDelete: "cascade" }),
|
||||
attachmentId: uuid("anexo_id")
|
||||
.notNull()
|
||||
.references(() => attachments.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.transactionId, table.attachmentId] }),
|
||||
attachmentIdIdx: index("lancamento_anexos_anexo_id_idx").on(
|
||||
table.attachmentId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const importCategoryMappings = pgTable(
|
||||
"import_category_mappings",
|
||||
{
|
||||
@@ -907,3 +978,28 @@ export type NewApiToken = typeof apiTokens.$inferInsert;
|
||||
export type InboxItem = typeof inboxItems.$inferSelect;
|
||||
export type NewInboxItem = typeof inboxItems.$inferInsert;
|
||||
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect;
|
||||
|
||||
export const attachmentsRelations = relations(attachments, ({ one, many }) => ({
|
||||
user: one(user, {
|
||||
fields: [attachments.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
transactionAttachments: many(transactionAttachments),
|
||||
}));
|
||||
|
||||
export const transactionAttachmentsRelations = relations(
|
||||
transactionAttachments,
|
||||
({ one }) => ({
|
||||
transaction: one(transactions, {
|
||||
fields: [transactionAttachments.transactionId],
|
||||
references: [transactions.id],
|
||||
}),
|
||||
attachment: one(attachments, {
|
||||
fields: [transactionAttachments.attachmentId],
|
||||
references: [attachments.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Attachment = typeof attachments.$inferSelect;
|
||||
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
|
||||
|
||||
@@ -88,9 +88,7 @@ export function AccountCard({
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{accountName}
|
||||
</h2>
|
||||
<h2 className="text-lg font-medium text-foreground">{accountName}</h2>
|
||||
|
||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||
<Tooltip>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function AccountStatementCard({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">
|
||||
<h2 className="truncate text-sm font-medium text-foreground">
|
||||
{accountName}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -86,12 +86,12 @@ export function AccountStatementCard({
|
||||
</p>
|
||||
<MoneyValues
|
||||
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">
|
||||
<Badge
|
||||
variant={getAccountStatusBadgeVariant(status)}
|
||||
className="text-[11px]"
|
||||
className="text-xs"
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
@@ -107,7 +107,7 @@ export function AccountStatementCard({
|
||||
label="Saldo inicial"
|
||||
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)}
|
||||
</span>
|
||||
</MetaItem>
|
||||
@@ -116,7 +116,7 @@ export function AccountStatementCard({
|
||||
label="Entradas"
|
||||
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)}
|
||||
</span>
|
||||
</MetaItem>
|
||||
@@ -125,7 +125,7 @@ export function AccountStatementCard({
|
||||
label="Saídas"
|
||||
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)}
|
||||
</span>
|
||||
</MetaItem>
|
||||
@@ -136,7 +136,7 @@ export function AccountStatementCard({
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
"text-sm font-medium",
|
||||
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) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2")}>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-card-foreground">
|
||||
<div className={cn("flex flex-col gap-2.5")}>
|
||||
<h1 className="text-2xl font-medium tracking-tight text-card-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
{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 { 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() {
|
||||
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>
|
||||
|
||||
<div className="relative flex flex-1 flex-col justify-between p-10 lg:p-12">
|
||||
<Logo
|
||||
variant="compact"
|
||||
@@ -22,14 +46,25 @@ function AuthSidebar() {
|
||||
className="opacity-92 [&_img]:brightness-0 [&_img]:saturate-0"
|
||||
/>
|
||||
|
||||
<div className="max-w-sm space-y-4.5">
|
||||
<h2 className="text-[2rem] font-semibold leading-[1.04] tracking-[-0.03em] text-black/84 lg:text-[2.35rem]">
|
||||
Controle suas finanças com clareza e foco diário.
|
||||
</h2>
|
||||
<p className="max-w-2xs text-sm leading-6 text-black/68">
|
||||
Centralize despesas, organize cartões e acompanhe metas mensais em
|
||||
um painel inteligente feito para o seu dia a dia.
|
||||
</p>
|
||||
<div className="flex flex-1 items-center justify-center py-10">
|
||||
<div className="w-full rotate-[1.5deg]">
|
||||
<AuthSidebarInvoicesMock />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
|
||||
@@ -125,7 +125,9 @@ export function LoginForm({ className, ...props }: DivProps) {
|
||||
});
|
||||
|
||||
if (passkeyError) {
|
||||
setError(passkeyError.message || "Erro ao entrar com passkey.");
|
||||
setError(
|
||||
(passkeyError.message as string) || "Erro ao entrar com passkey.",
|
||||
);
|
||||
setLoadingPasskey(false);
|
||||
}
|
||||
}
|
||||
@@ -238,7 +240,7 @@ export function LoginForm({ className, ...props }: DivProps) {
|
||||
</a>
|
||||
</FieldDescription>
|
||||
|
||||
<FieldDescription className="text-center text-[13px] text-muted-foreground">
|
||||
<FieldDescription className="text-center text-sm text-muted-foreground">
|
||||
<a href="/" className={authLinkClassName}>
|
||||
Voltar para a página inicial
|
||||
</a>
|
||||
|
||||
@@ -277,7 +277,7 @@ export function SignupForm({ className, ...props }: DivProps) {
|
||||
</a>
|
||||
</FieldDescription>
|
||||
|
||||
<FieldDescription className="text-center text-[13px] text-muted-foreground">
|
||||
<FieldDescription className="text-center text-sm text-muted-foreground">
|
||||
<a href="/" className={authLinkClassName}>
|
||||
Voltar para a página inicial
|
||||
</a>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function BudgetCard({
|
||||
size="lg"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold leading-tight">
|
||||
<h3 className="text-base font-medium leading-tight">
|
||||
{formatCategoryName(budget)}
|
||||
</h3>
|
||||
<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="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Limite atual</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatCurrency(sliderValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function CalendarGrid({
|
||||
}: CalendarGridProps) {
|
||||
return (
|
||||
<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) => (
|
||||
<span key={dayName} className="px-3 py-2 text-center text-primary">
|
||||
{dayName}
|
||||
|
||||
@@ -110,9 +110,7 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
{complement ? (
|
||||
<span
|
||||
className={cn("shrink-0 font-semibold", style.accent ?? "text-xs")}
|
||||
>
|
||||
<span className={cn("shrink-0 font-medium", style.accent ?? "text-xs")}>
|
||||
{complement}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -153,7 +151,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold leading-none",
|
||||
"text-sm font-medium leading-none",
|
||||
day.isToday
|
||||
? "text-primary-foreground bg-primary size-5 rounded-full flex items-center justify-center"
|
||||
: "text-foreground/90",
|
||||
|
||||
@@ -61,7 +61,7 @@ const renderLancamento = (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`text-sm font-semibold leading-tight ${
|
||||
className={`text-sm font-medium leading-tight ${
|
||||
isPagamentoFatura && "text-success"
|
||||
}`}
|
||||
>
|
||||
@@ -74,7 +74,7 @@ const renderLancamento = (
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold whitespace-nowrap",
|
||||
"text-sm font-medium whitespace-nowrap",
|
||||
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 flex-col gap-1">
|
||||
<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}
|
||||
</span>
|
||||
|
||||
@@ -116,7 +116,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
||||
|
||||
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
|
||||
</div>
|
||||
<span className="font-semibold">
|
||||
<span className="font-medium">
|
||||
<MoneyValues amount={event.transaction.amount} />
|
||||
</span>
|
||||
</div>
|
||||
@@ -129,7 +129,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
||||
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge>
|
||||
</div>
|
||||
{event.card.totalDue !== null ? (
|
||||
<span className="font-semibold">
|
||||
<span className="font-medium">
|
||||
<MoneyValues amount={event.card.totalDue} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function CardItem({
|
||||
|
||||
<div className="min-w-0">
|
||||
<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}
|
||||
</h3>
|
||||
{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">
|
||||
<span>
|
||||
Fecha dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatDay(closingDay)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Vence dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatDay(dueDay)}
|
||||
</span>
|
||||
</span>
|
||||
@@ -206,7 +206,7 @@ export function CardItem({
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<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} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
@@ -215,7 +215,7 @@ export function CardItem({
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<MoneyValues amount={metrics[1].value} />
|
||||
</p>
|
||||
@@ -225,7 +225,7 @@ export function CardItem({
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
|
||||
@@ -80,7 +80,7 @@ export function CategoryDetailHeader({
|
||||
size="lg"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold leading-tight">
|
||||
<h1 className="text-xl font-medium leading-tight">
|
||||
{category.name}
|
||||
</h1>
|
||||
<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">
|
||||
Total em {currentPeriodLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
<p className="mt-1 text-2xl font-medium">
|
||||
{currencyFormatter.format(currentTotal)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@ export function CategoryDetailHeader({
|
||||
</p>
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -111,10 +111,11 @@ export function buildCategoryBreakdownData({
|
||||
});
|
||||
}
|
||||
|
||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||
const filtered = categories.filter((c) => c.currentAmount > 0);
|
||||
filtered.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||
|
||||
return {
|
||||
categories,
|
||||
categories: filtered,
|
||||
currentTotal,
|
||||
previousTotal,
|
||||
};
|
||||
|
||||
@@ -111,6 +111,7 @@ export async function fetchCategoryDetails(
|
||||
sanitizedNote,
|
||||
eq(transactions.period, previousPeriod),
|
||||
or(
|
||||
isNull(transactions.note),
|
||||
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
||||
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
||||
|
||||
@@ -97,7 +97,7 @@ export function BillPaymentDialog({
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Boleto
|
||||
</p>
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{bill.name}
|
||||
</p>
|
||||
</div>
|
||||
@@ -107,24 +107,24 @@ export function BillPaymentDialog({
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||
<RiMoneyDollarCircleLine className="size-3.5" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide">
|
||||
<span className="text-xs font-medium uppercase tracking-wide">
|
||||
Valor
|
||||
</span>
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={bill.amount}
|
||||
className="text-lg font-bold"
|
||||
className="text-lg font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||
<RiCalendarLine className="size-3.5" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide">
|
||||
<span className="text-xs font-medium uppercase tracking-wide">
|
||||
Vencimento
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{dueLabel?.replace("Vencimento: ", "") ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -343,10 +343,10 @@ export function CategoryBreakdownWidgetView({
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
<span className="text-xs uppercase text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="font-bold text-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatCurrency(entry.value)}
|
||||
</span>
|
||||
<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="flex flex-col items-center gap-2">
|
||||
<RiDragMove2Line className="size-8 text-primary" />
|
||||
<span className="text-xs font-bold">
|
||||
<span className="text-xs font-medium">
|
||||
Arraste para mover
|
||||
</span>
|
||||
<Button
|
||||
|
||||
@@ -134,12 +134,12 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
|
||||
<MoneyValues
|
||||
className="text-[1.55rem] leading-none font-medium"
|
||||
className="text-2xl leading-none"
|
||||
amount={metric.current}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-xs font-medium",
|
||||
"inline-flex items-center gap-1 text-xs ",
|
||||
trendBadgeClass,
|
||||
)}
|
||||
>
|
||||
@@ -150,7 +150,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<MoneyValues
|
||||
className="inline text-xs font-medium text-muted-foreground"
|
||||
className="inline text-xs text-muted-foreground"
|
||||
amount={metric.previous}
|
||||
/>
|
||||
<span className="ml-1">no mês anterior</span>
|
||||
|
||||
@@ -8,7 +8,7 @@ export function DashboardWelcome({ name }: { name?: string | null }) {
|
||||
return (
|
||||
<section className="py-4">
|
||||
<div className="tracking-tight">
|
||||
<h1 className="text-xl">
|
||||
<h1 className="text-xl font-medium">
|
||||
{greeting}, {displayName}
|
||||
</h1>
|
||||
<h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function InstallmentAnalysisPage({
|
||||
</p>
|
||||
<MoneyValues
|
||||
amount={grandTotal}
|
||||
className="text-3xl font-bold text-primary"
|
||||
className="text-3xl font-medium text-primary"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function InstallmentGroupCard({
|
||||
<span className="text-xs text-muted-foreground">Total:</span>
|
||||
<MoneyValues
|
||||
amount={totalAmount}
|
||||
className="text-base font-bold"
|
||||
className="text-base font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -100,7 +100,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
<MoneyValues amount={share.amount} />
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -46,7 +46,7 @@ export function InvoiceLogo({
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold uppercase text-muted-foreground",
|
||||
"text-sm font-medium uppercase text-muted-foreground",
|
||||
tone === "accent" && "text-primary",
|
||||
fallbackClassName,
|
||||
)}
|
||||
|
||||
@@ -110,10 +110,10 @@ export function InvoicePaymentDialog({
|
||||
fallbackClassName="text-xs"
|
||||
/>
|
||||
<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
|
||||
</p>
|
||||
<p className="truncate text-base font-semibold text-foreground">
|
||||
<p className="truncate text-base font-medium text-foreground">
|
||||
{invoice.cardName}
|
||||
</p>
|
||||
</div>
|
||||
@@ -124,26 +124,26 @@ export function InvoicePaymentDialog({
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={Math.abs(invoice.totalAmount)}
|
||||
className="text-lg font-bold"
|
||||
className="text-lg font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||
<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
|
||||
? "Pago em"
|
||||
: "Vencimento"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
||||
? (paymentInfo?.label ?? "—")
|
||||
: (dueInfo?.label ?? "—")}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function NoteListItem({
|
||||
{getNoteTasksSummary(note)}
|
||||
</Badge>
|
||||
{createdAtLabel ? (
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{createdAtLabel}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -130,7 +130,7 @@ export function PurchasesByCategoryWidget({
|
||||
<SelectContent>
|
||||
{Object.entries(categoriesByType).map(([type, categories]) => (
|
||||
<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[
|
||||
type as keyof typeof CATEGORY_TYPE_LABEL
|
||||
] ?? type}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cacheLife, cacheTag } from "next/cache";
|
||||
import { fetchDashboardAccounts } from "./accounts-queries";
|
||||
import { fetchDashboardCategoryOverview } from "./category-overview-queries";
|
||||
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
|
||||
@@ -51,18 +51,14 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function fetchDashboardData(userId: string, period: string) {
|
||||
return unstable_cache(
|
||||
() => fetchDashboardDataInternal(userId, period),
|
||||
[`dashboard-${userId}-${period}`],
|
||||
{
|
||||
tags: [`dashboard-${userId}`],
|
||||
revalidate: 60,
|
||||
},
|
||||
)();
|
||||
export async function fetchDashboardData(userId: string, period: string) {
|
||||
"use cache";
|
||||
cacheTag(`dashboard-${userId}`);
|
||||
cacheLife({ revalidate: 3 });
|
||||
return fetchDashboardDataInternal(userId, period);
|
||||
}
|
||||
|
||||
export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function fetchDashboardInvoices(
|
||||
const paymentMap = new Map<string, string>();
|
||||
for (const row of paymentRows) {
|
||||
const note = row.note;
|
||||
if (!note || !note.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
||||
if (!note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
const parts = note.split(":");
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cacheLife, cacheTag } from "next/cache";
|
||||
import { payers } from "@/db/schema";
|
||||
import { fetchPendingInboxCount } from "@/features/inbox/queries";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { getBusinessDateString } from "@/shared/utils/date";
|
||||
import {
|
||||
type DashboardNotificationsSnapshot,
|
||||
fetchDashboardNotifications,
|
||||
@@ -36,8 +37,8 @@ async function fetchAdminPayerAvatarUrl(
|
||||
|
||||
async function fetchDashboardNavbarDataInternal(
|
||||
userId: string,
|
||||
currentPeriod: string,
|
||||
): Promise<DashboardNavbarData> {
|
||||
const currentPeriod = getBusinessDateString().slice(0, 7);
|
||||
const [pagadorAvatarUrl, notificationsSnapshot, preLancamentosCount] =
|
||||
await Promise.all([
|
||||
fetchAdminPayerAvatarUrl(userId),
|
||||
@@ -52,16 +53,9 @@ async function fetchDashboardNavbarDataInternal(
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchDashboardNavbarData(
|
||||
userId: string,
|
||||
currentPeriod: string,
|
||||
) {
|
||||
return unstable_cache(
|
||||
() => fetchDashboardNavbarDataInternal(userId, currentPeriod),
|
||||
[`dashboard-navbar-${userId}-${currentPeriod}`],
|
||||
{
|
||||
tags: [`dashboard-${userId}`],
|
||||
revalidate: 60,
|
||||
},
|
||||
)();
|
||||
export async function fetchDashboardNavbarData(userId: string) {
|
||||
"use cache";
|
||||
cacheTag(`dashboard-${userId}`);
|
||||
cacheLife({ revalidate: 3 });
|
||||
return fetchDashboardNavbarDataInternal(userId);
|
||||
}
|
||||
|
||||
252
src/features/dashboard/notifications-actions.ts
Normal file
252
src/features/dashboard/notifications-actions.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { dashboardNotificationStates } from "@/db/schema";
|
||||
import {
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/shared/lib/actions/helpers";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
|
||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||
|
||||
const notificationStateSchema = z.object({
|
||||
notificationKey: z
|
||||
.string({ message: "Chave da notificação inválida." })
|
||||
.trim()
|
||||
.min(1, "Chave da notificação inválida."),
|
||||
fingerprint: z
|
||||
.string({ message: "Fingerprint da notificação inválido." })
|
||||
.trim()
|
||||
.min(1, "Fingerprint da notificação inválido."),
|
||||
});
|
||||
|
||||
type DashboardNotificationStateInput = z.infer<typeof notificationStateSchema>;
|
||||
|
||||
function revalidateNotifications(userId: string) {
|
||||
revalidateForEntity("notifications", userId);
|
||||
}
|
||||
|
||||
async function getExistingNotificationState(
|
||||
userId: string,
|
||||
notificationKey: string,
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: dashboardNotificationStates.id,
|
||||
archivedAt: dashboardNotificationStates.archivedAt,
|
||||
})
|
||||
.from(dashboardNotificationStates)
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardNotificationStates.userId, userId),
|
||||
eq(dashboardNotificationStates.notificationKey, notificationKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return existing ?? null;
|
||||
}
|
||||
|
||||
export async function markDashboardNotificationAsReadAction(
|
||||
input: DashboardNotificationStateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = notificationStateSchema.parse(input);
|
||||
const now = new Date();
|
||||
const existing = await getExistingNotificationState(
|
||||
user.id,
|
||||
data.notificationKey,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(dashboardNotificationStates)
|
||||
.set({
|
||||
fingerprint: data.fingerprint,
|
||||
readAt: now,
|
||||
archivedAt: existing.archivedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardNotificationStates.userId, user.id),
|
||||
eq(
|
||||
dashboardNotificationStates.notificationKey,
|
||||
data.notificationKey,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await db.insert(dashboardNotificationStates).values({
|
||||
userId: user.id,
|
||||
notificationKey: data.notificationKey,
|
||||
fingerprint: data.fingerprint,
|
||||
readAt: now,
|
||||
archivedAt: null,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
revalidateNotifications(user.id);
|
||||
|
||||
return { success: true, message: "Notificação marcada como lida." };
|
||||
} catch (error) {
|
||||
if (isNotificationStatesTableMissing(error)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||
};
|
||||
}
|
||||
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markDashboardNotificationAsUnreadAction(
|
||||
input: DashboardNotificationStateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = notificationStateSchema.parse(input);
|
||||
const now = new Date();
|
||||
const existing = await getExistingNotificationState(
|
||||
user.id,
|
||||
data.notificationKey,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return { success: true, message: "Notificação marcada como não lida." };
|
||||
}
|
||||
|
||||
await db
|
||||
.update(dashboardNotificationStates)
|
||||
.set({
|
||||
fingerprint: data.fingerprint,
|
||||
readAt: null,
|
||||
archivedAt: existing.archivedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardNotificationStates.userId, user.id),
|
||||
eq(dashboardNotificationStates.notificationKey, data.notificationKey),
|
||||
),
|
||||
);
|
||||
|
||||
revalidateNotifications(user.id);
|
||||
|
||||
return { success: true, message: "Notificação marcada como não lida." };
|
||||
} catch (error) {
|
||||
if (isNotificationStatesTableMissing(error)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||
};
|
||||
}
|
||||
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveDashboardNotificationAction(
|
||||
input: DashboardNotificationStateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = notificationStateSchema.parse(input);
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.insert(dashboardNotificationStates)
|
||||
.values({
|
||||
userId: user.id,
|
||||
notificationKey: data.notificationKey,
|
||||
fingerprint: data.fingerprint,
|
||||
readAt: now,
|
||||
archivedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
dashboardNotificationStates.userId,
|
||||
dashboardNotificationStates.notificationKey,
|
||||
],
|
||||
set: {
|
||||
fingerprint: data.fingerprint,
|
||||
readAt: now,
|
||||
archivedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
revalidateNotifications(user.id);
|
||||
|
||||
return { success: true, message: "Notificação arquivada." };
|
||||
} catch (error) {
|
||||
if (isNotificationStatesTableMissing(error)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||
};
|
||||
}
|
||||
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function unarchiveDashboardNotificationAction(
|
||||
input: DashboardNotificationStateInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = notificationStateSchema.parse(input);
|
||||
const now = new Date();
|
||||
const existing = await getExistingNotificationState(
|
||||
user.id,
|
||||
data.notificationKey,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Notificação não encontrada para restaurar.",
|
||||
};
|
||||
}
|
||||
|
||||
await db
|
||||
.update(dashboardNotificationStates)
|
||||
.set({
|
||||
fingerprint: data.fingerprint,
|
||||
archivedAt: null,
|
||||
readAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardNotificationStates.userId, user.id),
|
||||
eq(dashboardNotificationStates.notificationKey, data.notificationKey),
|
||||
),
|
||||
);
|
||||
|
||||
revalidateNotifications(user.id);
|
||||
|
||||
return { success: true, message: "Notificação restaurada." };
|
||||
} catch (error) {
|
||||
if (isNotificationStatesTableMissing(error)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||
};
|
||||
}
|
||||
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, lt, ne, sql } from "drizzle-orm";
|
||||
import { and, eq, gte, inArray, isNotNull, lt, ne, sql } from "drizzle-orm";
|
||||
import {
|
||||
budgets,
|
||||
cards,
|
||||
categories,
|
||||
dashboardNotificationStates,
|
||||
invoices,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import { buildInvoiceDetailsHref } from "@/features/dashboard/invoices-helpers";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||
import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import type {
|
||||
BudgetNotification,
|
||||
DashboardNotification,
|
||||
DashboardNotificationsSnapshot,
|
||||
} from "@/shared/lib/types/notifications";
|
||||
import {
|
||||
buildDateOnlyStringFromPeriodDay,
|
||||
getBusinessDateString,
|
||||
@@ -19,41 +25,69 @@ import {
|
||||
toDateOnlyString,
|
||||
} from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
import {
|
||||
addMonthsToPeriod,
|
||||
formatPeriodForUrl,
|
||||
getNextPeriod,
|
||||
} from "@/shared/utils/period";
|
||||
|
||||
export type NotificationType = "overdue" | "due_soon";
|
||||
|
||||
export type DashboardNotification = {
|
||||
id: string;
|
||||
type: "invoice" | "boleto";
|
||||
name: string;
|
||||
dueDate: string;
|
||||
status: NotificationType;
|
||||
amount: number;
|
||||
period?: string;
|
||||
showAmount: boolean;
|
||||
cardLogo?: string | null;
|
||||
};
|
||||
|
||||
export type BudgetStatus = "exceeded" | "critical";
|
||||
|
||||
export type BudgetNotification = {
|
||||
id: string;
|
||||
categoryName: string;
|
||||
budgetAmount: number;
|
||||
spentAmount: number;
|
||||
usedPercentage: number;
|
||||
status: BudgetStatus;
|
||||
};
|
||||
|
||||
export type DashboardNotificationsSnapshot = {
|
||||
notifications: DashboardNotification[];
|
||||
totalCount: number;
|
||||
budgetNotifications: BudgetNotification[];
|
||||
};
|
||||
export type {
|
||||
BudgetNotification,
|
||||
BudgetStatus,
|
||||
DashboardNotification,
|
||||
DashboardNotificationsSnapshot,
|
||||
NotificationType,
|
||||
} from "@/shared/lib/types/notifications";
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
const BUDGET_CRITICAL_THRESHOLD = 80;
|
||||
|
||||
type PersistedNotificationState = {
|
||||
notificationKey: string;
|
||||
fingerprint: string;
|
||||
readAt: Date | null;
|
||||
archivedAt: Date | null;
|
||||
};
|
||||
|
||||
const buildInvoiceNotificationKey = (cardId: string, period: string) =>
|
||||
`invoice-${cardId}-${period}`;
|
||||
|
||||
const buildBoletoNotificationKey = (transactionId: string) =>
|
||||
`boleto-${transactionId}`;
|
||||
|
||||
const buildBudgetNotificationKey = (
|
||||
categoryId: string | null,
|
||||
budgetId: string,
|
||||
period: string,
|
||||
) => (categoryId ? `budget-${categoryId}-${period}` : `budget-${budgetId}`);
|
||||
|
||||
function mergeNotificationState<
|
||||
T extends {
|
||||
notificationKey: string;
|
||||
fingerprint: string;
|
||||
isRead: boolean;
|
||||
isArchived: boolean;
|
||||
readAt: Date | null;
|
||||
archivedAt: Date | null;
|
||||
},
|
||||
>(items: T[], stateByKey: Map<string, PersistedNotificationState>): T[] {
|
||||
return items.map((item) => {
|
||||
const persisted = stateByKey.get(item.notificationKey);
|
||||
|
||||
if (!persisted || persisted.fingerprint !== item.fingerprint) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
isRead: persisted.readAt !== null,
|
||||
isArchived: persisted.archivedAt !== null,
|
||||
readAt: persisted.readAt,
|
||||
archivedAt: persisted.archivedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca todas as notificações do dashboard:
|
||||
* - Faturas de cartão atrasadas ou com vencimento próximo
|
||||
@@ -66,6 +100,7 @@ export async function fetchDashboardNotifications(
|
||||
): Promise<DashboardNotificationsSnapshot> {
|
||||
const today = getBusinessDateString();
|
||||
const DAYS_THRESHOLD = 5;
|
||||
const nextPeriod = getNextPeriod(currentPeriod);
|
||||
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
|
||||
@@ -78,6 +113,10 @@ export async function fetchDashboardNotifications(
|
||||
if (adminPayerId) {
|
||||
boletosConditions.push(eq(transactions.payerId, adminPayerId));
|
||||
}
|
||||
boletosConditions.push(isNotNull(transactions.dueDate));
|
||||
boletosConditions.push(
|
||||
gte(transactions.period, addMonthsToPeriod(currentPeriod, -12)),
|
||||
);
|
||||
|
||||
const budgetJoinConditions = [
|
||||
eq(transactions.categoryId, budgets.categoryId),
|
||||
@@ -90,116 +129,126 @@ export async function fetchDashboardNotifications(
|
||||
budgetJoinConditions.push(eq(transactions.payerId, adminPayerId));
|
||||
}
|
||||
|
||||
// --- All 4 queries are independent — run in parallel ---
|
||||
const [overdueInvoices, currentInvoices, boletosRows, budgetRows] =
|
||||
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
|
||||
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>`
|
||||
// Helper: monta a query de faturas por período (reutilizada para período atual e próximo)
|
||||
const buildPeriodInvoicesQuery = (period: string) =>
|
||||
db
|
||||
.select({
|
||||
invoiceId: invoices.id,
|
||||
cardId: cards.id,
|
||||
cardName: cards.name,
|
||||
cardLogo: cards.logo,
|
||||
dueDay: cards.dueDay,
|
||||
period: sql<string>`COALESCE(${invoices.period}, ${period})`,
|
||||
paymentStatus: invoices.paymentStatus,
|
||||
totalAmount: sql<number | null>`
|
||||
COALESCE(SUM(${transactions.amount}), 0)
|
||||
`,
|
||||
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
|
||||
})
|
||||
.from(cards)
|
||||
.leftJoin(
|
||||
invoices,
|
||||
and(
|
||||
eq(invoices.cardId, cards.id),
|
||||
eq(invoices.userId, userId),
|
||||
eq(invoices.period, currentPeriod),
|
||||
),
|
||||
)
|
||||
.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,
|
||||
transactionCount: sql<number | null>`COUNT(${transactions.id})`,
|
||||
})
|
||||
.from(cards)
|
||||
.leftJoin(
|
||||
invoices,
|
||||
and(
|
||||
eq(invoices.cardId, cards.id),
|
||||
eq(invoices.userId, userId),
|
||||
eq(invoices.period, period),
|
||||
),
|
||||
// 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,
|
||||
budgetAmount: budgets.amount,
|
||||
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),
|
||||
]);
|
||||
)
|
||||
.leftJoin(
|
||||
transactions,
|
||||
and(
|
||||
eq(transactions.cardId, cards.id),
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.period, period),
|
||||
),
|
||||
)
|
||||
.where(eq(cards.userId, userId))
|
||||
.groupBy(
|
||||
invoices.id,
|
||||
cards.id,
|
||||
cards.name,
|
||||
cards.logo,
|
||||
cards.dueDay,
|
||||
invoices.period,
|
||||
invoices.paymentStatus,
|
||||
);
|
||||
|
||||
// --- All 5 queries are independent — run in parallel ---
|
||||
const [
|
||||
overdueInvoices,
|
||||
currentInvoices,
|
||||
nextPeriodInvoices,
|
||||
boletosRows,
|
||||
budgetRows,
|
||||
] = 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
|
||||
@@ -216,12 +265,12 @@ export async function fetchDashboardNotifications(
|
||||
);
|
||||
if (!dueDate) continue;
|
||||
const amount = toNumber(invoice.totalAmount);
|
||||
const notificationId = invoice.invoiceId
|
||||
? `invoice-${invoice.invoiceId}`
|
||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
||||
const notificationKey = buildInvoiceNotificationKey(
|
||||
invoice.cardId,
|
||||
invoice.period,
|
||||
);
|
||||
|
||||
notifications.push({
|
||||
id: notificationId,
|
||||
type: "invoice",
|
||||
name: invoice.cardName,
|
||||
dueDate,
|
||||
@@ -230,6 +279,13 @@ export async function fetchDashboardNotifications(
|
||||
period: invoice.period,
|
||||
showAmount: true,
|
||||
cardLogo: invoice.cardLogo,
|
||||
notificationKey,
|
||||
fingerprint: "overdue",
|
||||
href: buildInvoiceDetailsHref(invoice.cardId, invoice.period),
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,20 +317,75 @@ export async function fetchDashboardNotifications(
|
||||
);
|
||||
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
|
||||
|
||||
const notificationId = invoice.invoiceId
|
||||
? `invoice-${invoice.invoiceId}`
|
||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
||||
const notificationStatus = invoiceIsOverdue ? "overdue" : "due_soon";
|
||||
const notificationKey = buildInvoiceNotificationKey(
|
||||
invoice.cardId,
|
||||
invoice.period,
|
||||
);
|
||||
|
||||
notifications.push({
|
||||
id: notificationId,
|
||||
type: "invoice",
|
||||
name: invoice.cardName,
|
||||
dueDate,
|
||||
status: invoiceIsOverdue ? "overdue" : "due_soon",
|
||||
status: notificationStatus,
|
||||
amount: Math.abs(amount),
|
||||
period: invoice.period,
|
||||
showAmount: invoiceIsOverdue,
|
||||
cardLogo: invoice.cardLogo,
|
||||
notificationKey,
|
||||
fingerprint: notificationStatus,
|
||||
href: buildInvoiceDetailsHref(invoice.cardId, invoice.period),
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -291,11 +402,13 @@ export async function fetchDashboardNotifications(
|
||||
);
|
||||
const isOldPeriod = boleto.period < currentPeriod;
|
||||
const isCurrentPeriod = boleto.period === currentPeriod;
|
||||
const isNextPeriod = boleto.period === nextPeriod;
|
||||
const amount = toNumber(boleto.amount);
|
||||
const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`;
|
||||
const notificationKey = buildBoletoNotificationKey(boleto.id);
|
||||
|
||||
if (isOldPeriod) {
|
||||
notifications.push({
|
||||
id: `boleto-${boleto.id}`,
|
||||
type: "boleto",
|
||||
name: boleto.name,
|
||||
dueDate,
|
||||
@@ -303,17 +416,49 @@ export async function fetchDashboardNotifications(
|
||||
amount: Math.abs(amount),
|
||||
period: boleto.period,
|
||||
showAmount: true,
|
||||
notificationKey,
|
||||
fingerprint: "overdue",
|
||||
href,
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
} else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
|
||||
const notificationStatus = boletoIsOverdue ? "overdue" : "due_soon";
|
||||
|
||||
notifications.push({
|
||||
id: `boleto-${boleto.id}`,
|
||||
type: "boleto",
|
||||
name: boleto.name,
|
||||
dueDate,
|
||||
status: boletoIsOverdue ? "overdue" : "due_soon",
|
||||
status: notificationStatus,
|
||||
amount: Math.abs(amount),
|
||||
period: boleto.period,
|
||||
showAmount: boletoIsOverdue,
|
||||
notificationKey,
|
||||
fingerprint: notificationStatus,
|
||||
href,
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -335,14 +480,26 @@ export async function fetchDashboardNotifications(
|
||||
|
||||
const usedPercentage = (spentAmount / budgetAmount) * 100;
|
||||
if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue;
|
||||
const notificationStatus = usedPercentage >= 100 ? "exceeded" : "critical";
|
||||
const notificationKey = buildBudgetNotificationKey(
|
||||
row.categoryId,
|
||||
row.orcamentoId,
|
||||
row.period,
|
||||
);
|
||||
|
||||
budgetNotifications.push({
|
||||
id: `budget-${row.orcamentoId}`,
|
||||
categoryName: row.categoriaName,
|
||||
budgetAmount,
|
||||
spentAmount,
|
||||
usedPercentage,
|
||||
status: usedPercentage >= 100 ? "exceeded" : "critical",
|
||||
status: notificationStatus,
|
||||
notificationKey,
|
||||
fingerprint: notificationStatus,
|
||||
href: `/budgets?periodo=${formatPeriodForUrl(row.period)}`,
|
||||
isRead: false,
|
||||
isArchived: false,
|
||||
readAt: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,9 +510,68 @@ export async function fetchDashboardNotifications(
|
||||
return b.usedPercentage - a.usedPercentage;
|
||||
});
|
||||
|
||||
return {
|
||||
notifications,
|
||||
totalCount: notifications.length,
|
||||
const notificationKeys = [
|
||||
...notifications.map((notification) => notification.notificationKey),
|
||||
...budgetNotifications.map((notification) => notification.notificationKey),
|
||||
];
|
||||
|
||||
let persistedStates: PersistedNotificationState[] = [];
|
||||
|
||||
if (notificationKeys.length > 0) {
|
||||
try {
|
||||
persistedStates = await db
|
||||
.select({
|
||||
notificationKey: dashboardNotificationStates.notificationKey,
|
||||
fingerprint: dashboardNotificationStates.fingerprint,
|
||||
readAt: dashboardNotificationStates.readAt,
|
||||
archivedAt: dashboardNotificationStates.archivedAt,
|
||||
})
|
||||
.from(dashboardNotificationStates)
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardNotificationStates.userId, userId),
|
||||
inArray(
|
||||
dashboardNotificationStates.notificationKey,
|
||||
notificationKeys,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotificationStatesTableMissing(error)) {
|
||||
console.warn(
|
||||
"[DashboardNotifications] Tabela dashboard_notification_states ainda não existe. Voltando ao modo sem persistência.",
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stateByKey = new Map(
|
||||
persistedStates.map((state) => [state.notificationKey, state]),
|
||||
);
|
||||
|
||||
const mergedNotifications = mergeNotificationState(notifications, stateByKey);
|
||||
const mergedBudgetNotifications = mergeNotificationState(
|
||||
budgetNotifications,
|
||||
stateByKey,
|
||||
);
|
||||
const visibleNotifications = mergedNotifications.filter(
|
||||
(notification) => !notification.isArchived,
|
||||
);
|
||||
const visibleBudgetNotifications = mergedBudgetNotifications.filter(
|
||||
(notification) => !notification.isArchived,
|
||||
);
|
||||
const unreadCount = [
|
||||
...visibleNotifications,
|
||||
...visibleBudgetNotifications,
|
||||
].filter((notification) => !notification.isRead).length;
|
||||
|
||||
return {
|
||||
notifications: mergedNotifications,
|
||||
budgetNotifications: mergedBudgetNotifications,
|
||||
unreadCount,
|
||||
visibleCount:
|
||||
visibleNotifications.length + visibleBudgetNotifications.length,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user