3 Commits
v2.5.4 ... main

Author SHA1 Message Date
Felipe Coutinho
467f71493d fix: ajusta label da opção 'period' no BulkActionDialog para recorrência
Em recorrência, currentInstallment é undefined e o label usava 'parcela',
gerando 'Todas as pessoas desta parcela (undefined/3)'. Adiciona helpers
getPeriodLabel/getPeriodDescription que adaptam o texto para installment
vs recurring, seguindo o padrão das outras opções.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:12:35 +00:00
Felipe Coutinho
0cec10ede3 fix: corrige formatação no bulk-action-dialog para passar no biome
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:27:38 +00:00
Felipe Coutinho
a6fba5f953 chore: prepara versão 2.5.5
Filtros multi-seleção em lançamentos (condição, forma de pagamento, pessoa,
categoria, conta/cartão), changelog redesenhado como timeline colapsável com
detecção de bump e resumo, e diálogos migrados para as animações utilitárias
do tw-animate-css. Inclui ajustes de label no BulkActionDialog, refinamentos
visuais na landing page e atualização da navbar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:11:59 +00:00
20 changed files with 890 additions and 505 deletions

View File

@@ -5,6 +5,24 @@ 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/), 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/). e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [2.5.5] - 2026-05-06
Esta versão melhora a navegação por históricos e lançamentos. O changelog ganhou uma linha do tempo mais leve, colapsável e fácil de escanear; os filtros de lançamentos passam a aceitar múltiplas pessoas, categorias, formas de pagamento, condições e contas/cartões na mesma busca; e os diálogos adotam as animações compartilhadas do design system. Também há pequenos polimentos de texto e layout para deixar a interface mais consistente.
### Adicionado
- Lançamentos: filtros multi-seleção para condição, forma de pagamento, pessoa, categoria e conta/cartão, permitindo combinar vários valores no mesmo filtro (query string passa a aceitar múltiplos valores por chave).
- Changelog: parser passou a inferir o tipo de bump (major/minor/patch) a partir da numeração e a extrair o parágrafo de resumo abaixo do cabeçalho de versão; novo arquivo `src/features/settings/lib/changelog-types.ts` consolidando os tipos compartilhados.
- UI: dependência `tw-animate-css` para usar as mesmas animações utilitárias já presentes nos componentes shadcn/ui.
### Alterado
- Changelog: visual da página reformulado para linha do tempo com resumo sempre visível, detalhes colapsáveis por versão, agrupamento por mês e marcadores visuais por tipo de bump; componente migrado para `"use client"` com `Collapsible` e abertura via âncora (`#vX-Y-Z`).
- Lançamentos: botões "Nova Receita" e "Nova Despesa" agora usam os próprios triggers do `TransactionDialog` (via prop `createSlot`), reduzindo estado manual na página e eliminando o fluxo `setCreateOpen` + `transactionTypeForCreate`.
- Diálogos: animações customizadas em CSS (`@keyframes dialog-in/out` e `overlay-in/out`) substituídas pelas classes utilitárias compartilhadas em `Dialog`/`DialogOverlay` (`data-[state=open]:animate-in`, `zoom-in-95`, `fade-in-0`).
- BulkActionDialog: label do escopo "Todas as pessoas" passa a indicar a parcela atual (`Todas as pessoas desta parcela (N/Total)`) com descrição mais clara sobre o efeito da ação.
- Checkbox: `RiCheckLine`/`RiSubtractLine` agora herdam `text-current` para alinhar com a cor do indicator nativo.
- Landing page: remoção de fundos alternados (`bg-muted/40`) nas seções "Funcionalidades", "Stack" e "Para quem é" para uma leitura visual mais limpa.
- Navbar: aviso de atualização passa a usar o texto "Versão X disponível".
## [2.5.4] - 2026-05-06 ## [2.5.4] - 2026-05-06
Esta versão é uma faxina arquitetural de larga escala sem nenhuma mudança visível ao usuário. Removido código morto, padronizamos identificadores em inglês conforme a convenção do projeto, simplificamos o barrel de Server Actions e consolidamos os arquivos de helpers/queries soltos nas raízes das features dentro de pastas `lib/`. O resultado é uma estrutura previsível e consistente entre features (`actions.ts`, `queries.ts`, `actions/`, `components/`, `hooks/`, `lib/`) e um saldo líquido de 428 linhas de código com zero impacto em comportamento, performance ou banco de dados. Esta versão é uma faxina arquitetural de larga escala sem nenhuma mudança visível ao usuário. Removido código morto, padronizamos identificadores em inglês conforme a convenção do projeto, simplificamos o barrel de Server Actions e consolidamos os arquivos de helpers/queries soltos nas raízes das features dentro de pastas `lib/`. O resultado é uma estrutura previsível e consistente entre features (`actions.ts`, `queries.ts`, `actions/`, `components/`, `hooks/`, `lib/`) e um saldo líquido de 428 linhas de código com zero impacto em comportamento, performance ou banco de dados.
@@ -220,6 +238,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.4.1] - 2026-04-16 ## [2.4.1] - 2026-04-16
Versão pequena com refresh visual nas telas de autenticação (efeito blob com três círculos coloridos em movimento e card com glassmorphism), capitalização dos labels da navbar para melhor legibilidade e otimização do banco com 17 índices novos em foreign keys — evitando sequential scans em deletes em tabelas grandes como `lancamentos`. Corrigida regressão no `postgres:18-alpine` que recusava iniciar em instalações existentes; adicionada variável `PGDATA` no compose para preservar dados de quem já tinha o volume populado.
### Adicionado ### Adicionado
- UI/Auth: layout animado nas páginas de login e signup com efeito blob (3 círculos coloridos em movimento) e card com glassmorphism; layout compartilhado extraído para `app/(auth)/layout.tsx` eliminando duplicação (PR #42) - UI/Auth: layout animado nas páginas de login e signup com efeito blob (3 círculos coloridos em movimento) e card com glassmorphism; layout compartilhado extraído para `app/(auth)/layout.tsx` eliminando duplicação (PR #42)
@@ -240,6 +260,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.4.0] - 2026-04-13 ## [2.4.0] - 2026-04-13
Esta versão integra o serviço Logo.dev para exibir automaticamente logos de marcas na coluna de estabelecimentos dos lançamentos, com picker manual para fixar o domínio quando a sugestão automática não acerta. As consultas vão por novas rotas de API (`/api/logo/search` e `/api/logo/mapping`) que servem como proxy seguro — a secret key fica server-side. Inclui também tabela própria `establishment_logos` com PK composta `(user_id, name_key)` para persistir as preferências por usuário.
### Adicionado ### Adicionado
- Estabelecimentos: integração com Logo.dev — logos automáticos de marcas exibidos na coluna de estabelecimentos nos lançamentos - Estabelecimentos: integração com Logo.dev — logos automáticos de marcas exibidos na coluna de estabelecimentos nos lançamentos
@@ -259,6 +281,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.3.8] - 2026-04-12 ## [2.3.8] - 2026-04-12
Refatoração do `docker-compose.yml` para virar standalone — agora basta um `curl` + `docker compose up -d`, sem dependências de arquivos externos ou profiles complexos. README reescrito em dois perfis claros (Usar com Docker e Desenvolver com hot-reload) e scripts npm reduzidos de 10 para 5.
### Alterado ### Alterado
- Docker: `docker-compose.yml` refatorado — removidos profiles, build e dependência de arquivo externo; compose agora é standalone (basta `curl` + `docker compose up -d`) - Docker: `docker-compose.yml` refatorado — removidos profiles, build e dependência de arquivo externo; compose agora é standalone (basta `curl` + `docker compose up -d`)
@@ -268,6 +292,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.3.7] - 2026-04-11 ## [2.3.7] - 2026-04-11
Esta versão amplia significativamente o dashboard com três novos widgets configuráveis (Anexos, Inbox, Tendências de Categoria), adiciona filtros úteis na tabela de lançamentos (por status de pagamento e por presença de anexo) e moderniza a tipografia substituindo a fonte local por Inter (Google Fonts, self-hosted pelo Next.js) — eliminando arquivos `.woff2` do repositório. Pesos tipográficos foram padronizados para `font-semibold` em títulos, rótulos e valores monetários, e o card de grupo de parcelas foi redesenhado expandindo num dialog de detalhes com parcelas pagas/pendentes separadas. No backend, a CSP foi expandida para permitir preview de anexos PDF via S3, e o setup ganhou script `install-deps.sh` pra preparar servidores Ubuntu 24.04 limpos.
### Adicionado ### Adicionado
- Dashboard: novos widgets configuráveis — Anexos (resumo de arquivos do período), Inbox (snapshot de pré-lançamentos pendentes) e Tendências de Categoria - Dashboard: novos widgets configuráveis — Anexos (resumo de arquivos do período), Inbox (snapshot de pré-lançamentos pendentes) e Tendências de Categoria
@@ -304,24 +330,32 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.3.6] - 2026-04-09 ## [2.3.6] - 2026-04-09
Correção pontual no Docker — adicionado `NODE_PATH=/app/migrate/node_modules` no entrypoint para o `drizzle-kit` resolver corretamente o `drizzle-orm` ao executar as migrations no container.
### Corrigido ### Corrigido
- Docker: adicionado `NODE_PATH=/app/migrate/node_modules` no entrypoint para que o `drizzle-kit` consiga resolver `drizzle-orm` ao executar as migrations no container - Docker: adicionado `NODE_PATH=/app/migrate/node_modules` no entrypoint para que o `drizzle-kit` consiga resolver `drizzle-orm` ao executar as migrations no container
## [2.3.5] - 2026-04-07 ## [2.3.5] - 2026-04-07
Correção crítica na CSP: regra movida do `next.config.ts` (build time) para `proxy.ts` (runtime), desbloqueando uploads de anexos quando o `S3_ENDPOINT` ainda não estava disponível durante o build da imagem Docker.
### Corrigido ### Corrigido
- CSP: movido `Content-Security-Policy` do `next.config.ts` (build time) para `proxy.ts` (runtime), corrigindo bloqueio de upload de anexos quando `S3_ENDPOINT` não estava disponível durante o build do Docker - CSP: movido `Content-Security-Policy` do `next.config.ts` (build time) para `proxy.ts` (runtime), corrigindo bloqueio de upload de anexos quando `S3_ENDPOINT` não estava disponível durante o build do Docker
## [2.3.4] - 2026-04-05 ## [2.3.4] - 2026-04-05
Correção pontual no upload de anexos — a CSP `connect-src` bloqueava o fetch para o storage, gerando `NetworkError` na hora de subir o arquivo.
### Corrigido ### Corrigido
- Anexos: corrigido upload que falhava com `NetworkError` — CSP `connect-src` bloqueava fetch para o Storage - Anexos: corrigido upload que falhava com `NetworkError` — CSP `connect-src` bloqueava fetch para o Storage
## [2.3.3] - 2026-04-05 ## [2.3.3] - 2026-04-05
Correção do fluxo de tokens da API: `/api/auth/device/verify` voltou a aceitar tokens criados pela tela de Settings (revertido de JWT para hash lookup). O prefixo dos tokens também foi renomeado de `os_` para `opm_` (OpenMonetis) e rotas JWT não utilizadas foram removidas — usuários precisam recriar os tokens existentes.
### Corrigido ### Corrigido
- Tokens: corrigido `/api/auth/device/verify` que rejeitava tokens criados via Settings (revertido de JWT para hash lookup) - Tokens: corrigido `/api/auth/device/verify` que rejeitava tokens criados via Settings (revertido de JWT para hash lookup)
@@ -334,6 +368,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.3.2] - 2026-04-04 ## [2.3.2] - 2026-04-04
Esta versão concentra hardening de segurança. Tokens da API ganharam expiração obrigatória de 1 ano (sem mais tokens eternos) e o refresh foi corrigido para validar JWT por assinatura. A CSP foi expandida com `default-src`, `script-src`, `style-src`, `img-src`, `font-src` e `connect-src` (no lugar de uma regra única ampla), e foi adicionada mitigação para CVE-2024-44294 desabilitando parsing de fórmulas em `xlsx`. Inclui ainda novos headers (`Referrer-Policy`, `X-Permitted-Cross-Domain-Policies`), respostas `401 JSON` em vez de redirect 302 em rotas autenticadas, `security.txt` (RFC 9116) e correção de URL com protocolo duplicado no sitemap.
### Segurança ### Segurança
- Tokens: removido aceite de tokens sem expiração (`expiresAt NULL`); tokens criados via settings agora expiram em 1 ano - Tokens: removido aceite de tokens sem expiração (`expiresAt NULL`); tokens criados via settings agora expiram em 1 ano
@@ -349,12 +385,16 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.3.1] - 2026-04-03 ## [2.3.1] - 2026-04-03
Correção pontual de infraestrutura — dependências do `drizzle-kit` passaram a ser instaladas em `/app/migrate/` separadamente do `node_modules` do build standalone, corrigindo o erro `Cannot find module 'next'` no startup do container.
### Corrigido ### Corrigido
- Infraestrutura: deps do drizzle-kit agora são instaladas em `/app/migrate/` separado do `node_modules` do standalone, corrigindo erro `Cannot find module 'next'` no startup do container - Infraestrutura: deps do drizzle-kit agora são instaladas em `/app/migrate/` separado do `node_modules` do standalone, corrigindo erro `Cannot find module 'next'` no startup do container
## [2.3.0] - 2026-04-03 ## [2.3.0] - 2026-04-03
Esta versão introduz `@tanstack/react-query` no projeto, padronizando cache, deduplicação e invalidação de leituras client-side. Várias features (anexos, insights, antecipação de parcelas) passaram a usar React Query no lugar de `useEffect` manual sobre rotas GET dedicadas. O dashboard ganhou ajuda contextual em cada métrica e configuração persistida pra ocultar contas marcadas como não consideradas no saldo total; o menu do usuário na navbar passou a avisar quando há release nova publicada no GitHub; e o Docker passou a rodar migrations automaticamente no startup via `docker-entrypoint.sh`. Internamente, o `knip` foi adicionado pra auditar arquivos/exports/tipos sem uso, várias rotas e actions ganharam validações extras (filtros por `userId` em joins, rate limits explícitos no Better Auth, headers `Cache-Control: private, no-store` em rotas privadas) e o projeto foi atualizado para Next.js 16.2.2 e Biome 2.4.10.
### Adicionado ### Adicionado
- Dependências: adiciona `@tanstack/react-query` e um provider global para padronizar cache, deduplicação e invalidação de leituras client-side - Dependências: adiciona `@tanstack/react-query` e um provider global para padronizar cache, deduplicação e invalidação de leituras client-side
@@ -390,12 +430,16 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.2.1] - 2026-04-01 ## [2.2.1] - 2026-04-01
Correção pontual no build da imagem Docker — removido `chown -R /app` do stage final (que travava o build/push da GitHub Action por lentidão excessiva); permissões agora definidas via `COPY --chown` direto.
### Corrigido ### Corrigido
- Docker: imagem de produção deixa de executar `chown -R /app` no stage final; as permissões passam a ser definidas nos `COPY --chown`, reduzindo o risco de travamento e lentidão excessiva no build/push da GitHub Action - Docker: imagem de produção deixa de executar `chown -R /app` no stage final; as permissões passam a ser definidas nos `COPY --chown`, reduzindo o risco de travamento e lentidão excessiva no build/push da GitHub Action
## [2.2.0] - 2026-04-01 ## [2.2.0] - 2026-04-01
Esta versão entrega uma nova página dedicada de galeria de anexos em `/attachments` com miniaturas, visualização inline (incluindo PDF via `pdfjs-dist`), download direto e acesso a partir do lançamento. As páginas de login e cadastro foram redesenhadas com sidebar mockup de faturas, três blocos de funcionalidade e gradiente decorativo. O dashboard passou a notificar boletos e faturas com vencimento dentro de 5 dias, e o cache do dashboard migrou de `unstable_cache` para a diretiva `use cache` (com `cacheTag` e `cacheLife`), com `cacheComponents: true` no `next.config.ts` e `connection()` em todas as páginas para forçar render dinâmico. A tipografia ganhou peso 500 (Medium) padronizado em títulos, valores e rótulos.
### Adicionado ### 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: 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
@@ -416,6 +460,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.1.2] - 2026-03-30 ## [2.1.2] - 2026-03-30
Pequena versão de polimento: novo escopo `"period"` na ação em lote de lançamentos (aplica alteração a todos os lançamentos do período sem sobrescrever o pagador individual de cada um), preferência de tamanho máximo por arquivo de anexo (5/10/25/50/100 MB) persistida no banco e respeitada em todos os pontos de upload, e redesign visual da página de Configurações com separadores entre seções e títulos maiores.
### Adicionado ### 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 - 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
@@ -432,6 +478,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.1.1] - 2026-03-29 ## [2.1.1] - 2026-03-29
Esta versão extrai a navbar pra um componente `NavbarShell` compartilhado entre app e landing page e cria uma variante `navbar` no Button pra centralizar os estilos antes duplicados em `nav-styles.ts`. A integração com `@vercel/analytics`/`@vercel/speed-insights` foi substituída por Umami self-hosted via script tag no layout raiz.
### Adicionado ### Adicionado
- Navbar: novo componente `NavbarShell` que unifica a estrutura da barra de navegação entre o app e a landing page - Navbar: novo componente `NavbarShell` que unifica a estrutura da barra de navegação entre o app e a landing page
@@ -453,6 +501,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.1.0] - 2026-03-28 ## [2.1.0] - 2026-03-28
Esta versão adiciona 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. O upload exige token assinado por arquivo, valida ownership da transação na leitura/remoção e confere tamanho/tipo do objeto no storage antes de persistir o vínculo no banco. Inclui também novo workflow `release.yml` que cria tag e GitHub Release automaticamente a partir da versão do `package.json` e da entrada correspondente no `CHANGELOG.md`.
### Adicionado ### 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 - 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
@@ -468,12 +518,16 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.0.3] - 2026-03-26 ## [2.0.3] - 2026-03-26
Correção pontual em `/transactions` — removida dependência de `crypto.randomUUID()` no carregamento inicial, que falhava em ambientes self-hosted sem HTTPS (a API só está disponível em contextos seguros).
### Corrigido ### 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 - 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 ## [2.0.2] - 2026-03-25
Versão focada nas notificações da navbar: novo estado persistido permite marcar alertas de fatura, boleto e orçamento como lidos ou arquivados por usuário; o snapshot global passa a usar o período corrente do negócio (não mais o `periodo` da URL), itens lidos saem do badge e arquivados somem da lista padrão do sino. O filtro foi refinado para um seletor explícito entre `Ativas` e `Arquivadas`. Inclui ajustes pontuais no detalhamento por categoria do dashboard (oculta categorias sem movimentação no período), na arte decorativa do cabeçalho de boas-vindas e na edição em lote de lançamentos em série (que agora propaga também o status de pagamento para transações fora do cartão).
### Adicionado ### Adicionado
- Scripts: novo comando `mockup` no `package.json` para executar `scripts/mock-data.ts` - Scripts: novo comando `mockup` no `package.json` para executar `scripts/mock-data.ts`
@@ -502,6 +556,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.0.1] - 2026-03-21 ## [2.0.1] - 2026-03-21
Versão de correções na inbox de pré-lançamentos: filtro por app passa a montar a lista completa a partir de todos os itens do status atual (sem depender da página carregada), notificações de cartões/apps sem logo cadastrado passam a usar `default_icon.png` como fallback, e o select de apps exibe os logos. Inclui também correção de divergência entre a versão exibida no UI e a reportada pelo `/api/health` (que agora reporta a versão atual do `package.json`).
### Corrigido ### Corrigido
- Inbox: filtro por app em `/inbox` agora monta a lista completa de apps da aba a partir de todos os itens do status atual, sem depender apenas da página carregada, e o SSR deixa de quebrar quando `sourceApps` vier inconsistente - Inbox: filtro por app em `/inbox` agora monta a lista completa de apps da aba a partir de todos os itens do status atual, sem depender apenas da página carregada, e o SSR deixa de quebrar quando `sourceApps` vier inconsistente
@@ -512,6 +568,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [2.0.0] - 2026-03-21 ## [2.0.0] - 2026-03-21
Marco importante do projeto. Esta versão consolida ganhos de performance, segurança e organização interna. No backend, paginação server-side real foi implementada em transações, extrato e inbox; o dashboard reduziu de 19 fetchers para 7 blocos com agregações compartilhadas; exportações de PDF/Excel passaram a carregar libs sob demanda apenas no clique; e o cache de dashboard/insights ganhou invalidação segmentada por `userId` (sem fallback global). Internamente, identificadores foram migrados de PT-BR para inglês (`lancamento``transaction`, `pagador``payer`, `conta``account`, etc.) e helpers foram consolidados em módulos de domínio. Visualmente, a navbar e os cards de auth ganharam dot pattern + brilho em primary, faturas tiveram refinamento na hierarquia visual, e a tipografia foi unificada na família America. Inclui ainda script `scripts/backup.sh` para backup automático do PostgreSQL, importação de extratos OFX e XLS/XLSX com tela de revisão e dedup por FITID, e nova opção de zerar dados financeiros sem excluir o usuário.
### Adicionado ### Adicionado
- Infraestrutura: script `scripts/backup.sh` para backup automático do banco PostgreSQL; configuração de destino (rclone, cron, retenção) feita separadamente; passa a gerar também `*.data.sql.gz` com dados puros de todas as tabelas públicas (`--data-only --schema=public`) - Infraestrutura: script `scripts/backup.sh` para backup automático do banco PostgreSQL; configuração de destino (rclone, cron, retenção) feita separadamente; passa a gerar também `*.data.sql.gz` com dados puros de todas as tabelas públicas (`--data-only --schema=public`)
@@ -568,6 +626,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.7.7] - 2026-03-05 ## [1.7.7] - 2026-03-05
Versão de organização interna sem mudanças visíveis grandes. Períodos e navegação mensal passaram a usar os helpers centrais de período (`YYYY-MM`), hooks locais (calculadora, month-picker, logo picker) foram movidos pra perto das respectivas features e `components/navbar`/`sidebar` foram consolidados em `components/navigation/*`. Análise de parcelas migrou para `/relatorios/analise-parcelas`, exportações em PDF/CSV/Excel ganharam melhor branding e apresentação, e a calculadora teve ajustes de estabilidade no arrasto.
### Alterado ### Alterado
- Períodos e navegação mensal: `useMonthPeriod` passou a usar os helpers centrais de período (`YYYY-MM`), o month-picker foi simplificado e o rótulo visual agora segue o formato `Março 2026`. - Períodos e navegação mensal: `useMonthPeriod` passou a usar os helpers centrais de período (`YYYY-MM`), o month-picker foi simplificado e o rótulo visual agora segue o formato `Março 2026`.
@@ -582,6 +642,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.7.6] - 2026-03-02 ## [1.7.6] - 2026-03-02
Esta versão adiciona suporte completo a Passkeys (WebAuthn) via `@better-auth/passkey`: nova aba em `/ajustes` permite listar, adicionar, renomear e remover credenciais, e a tela de login ganhou ação dedicada para passkey. O dashboard ganhou widget de Anotações e atalhos rápidos na toolbar de widgets pra criar Receita, Despesa ou Anotação direto. Top Estabelecimentos foi unificado num único widget com abas, e o widget "Lançamentos recentes" foi substituído por "Progresso de metas" com lista de orçamentos do período (gasto, limite e percentual de uso por categoria).
### Adicionado ### Adicionado
- Suporte completo a Passkeys (WebAuthn) com plugin `@better-auth/passkey` no servidor e `passkeyClient` no cliente de autenticação - Suporte completo a Passkeys (WebAuthn) com plugin `@better-auth/passkey` no servidor e `passkeyClient` no cliente de autenticação
@@ -616,6 +678,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.7.5] - 2026-02-28 ## [1.7.5] - 2026-02-28
Versão pequena de polimento: ações para excluir item individual (processado/descartado) e limpar itens em lote por status na inbox de pré-lançamentos, redesign dos cards e diálogos dos widgets de boletos e faturas com indicação "Atrasado / Pagar" quando vencidos e não pagos, e migração da página de categorias de cards pra layout em tabela com link direto para detalhe e ações inline.
### Adicionado ### Adicionado
- Inbox de pré-lançamentos: ações para excluir item individual (processado/descartado) e limpar itens em lote por status - Inbox de pré-lançamentos: ações para excluir item individual (processado/descartado) e limpar itens em lote por status
@@ -634,6 +698,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.7.4] - 2026-02-28 ## [1.7.4] - 2026-02-28
Versão de polimento de responsividade no mobile: 26 componentes ajustados (navbar, filtros, skeletons, widgets, dialogs), card de análise de parcelas empilhado verticalmente em telas pequenas e cards do top estabelecimentos reorganizados em coluna única no mobile. Inclui também regra mais inteligente em "Remover selecionados" — quando todos os itens pertencem à mesma série, abre dialog de escopo com 3 opções; e ajuste no consumo de limite por despesa recorrente no cartão (só consome quando a data já passou).
### Alterado ### Alterado
- Card de análise de parcelas (`/dashboard/analise-parcelas`): layout empilhado no mobile — nome/cartão e valores Total/Pendente em linhas separadas ao invés de lado-a-lado, evitando truncamento - Card de análise de parcelas (`/dashboard/analise-parcelas`): layout empilhado no mobile — nome/cartão e valores Total/Pendente em linhas separadas ao invés de lado-a-lado, evitando truncamento
@@ -645,6 +711,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.7.3] - 2026-02-27 ## [1.7.3] - 2026-02-27
Versão pequena com nova prop `compact` no DatePicker (formato abreviado "28 fev", sem "de" e sem ano) e modal de múltiplos lançamentos reformulado: selects de conta e cartão separados por forma de pagamento, InlinePeriodPicker ao escolher cartão de crédito e DatePicker compacto.
### Adicionado ### Adicionado
- Prop `compact` no DatePicker para formato abreviado "28 fev" (sem "de" e sem ano) - Prop `compact` no DatePicker para formato abreviado "28 fev" (sem "de" e sem ano)
@@ -656,6 +724,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.7.2] - 2026-02-26 ## [1.7.2] - 2026-02-26
Versão de polimento dos diálogos: padding maior (p-10), largura padronizada em `max-w-xl` e botões do footer com largura igual; o lançamento dialog ganhou seção colapsável "Condições e anotações" e cálculo automático do período da fatura via `deriveCreditCardPeriod()`. Inclui também uma faxina de tipos (non-null assertions removidas, `any` substituído por tipos explícitos em 15+ arquivos) e remoção de 6 componentes e 20+ funções/tipos sem uso.
### Alterado ### Alterado
- Dialogs padronizados: padding maior (p-10), largura max-w-xl, botões do footer com largura igual (flex-1) - Dialogs padronizados: padding maior (p-10), largura max-w-xl, botões do footer com largura igual (flex-1)
@@ -679,6 +749,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.7.1] - 2026-02-24 ## [1.7.1] - 2026-02-24
Esta versão substitui o header lateral por uma topbar de navegação com backdrop blur e links agrupados em 5 seções (Dashboard, Lançamentos, Cartões, Relatórios, Ferramentas), expande o sino de notificações pra exibir orçamentos estourados e pré-lançamentos pendentes em seções separadas, e cria página dedicada de changelog em `/changelog` (acessível pelo menu do usuário com a versão atual exibida ao lado).
### Adicionado ### Adicionado
- Topbar de navegação substituindo o header fixo: backdrop blur, links agrupados em 5 seções (Dashboard, Lançamentos, Cartões, Relatórios, Ferramentas) - Topbar de navegação substituindo o header fixo: backdrop blur, links agrupados em 5 seções (Dashboard, Lançamentos, Cartões, Relatórios, Ferramentas)
@@ -702,6 +774,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.6.3] - 2026-02-19 ## [1.6.3] - 2026-02-19
Correção pontual: variável `RESEND_FROM_EMAIL` não era lida corretamente do `.env` quando o valor continha espaços (precisa estar entre aspas). Leitura centralizada em `lib/email/resend.ts` com `getResendFromEmail()` e carregamento explícito do `.env` no contexto de Server Actions.
### Corrigido ### Corrigido
- E-mail Resend: variável `RESEND_FROM_EMAIL` não era lida do `.env` (valores com espaço precisam estar entre aspas). Leitura centralizada em `lib/email/resend.ts` com `getResendFromEmail()` e carregamento explícito do `.env` no contexto de Server Actions - E-mail Resend: variável `RESEND_FROM_EMAIL` não era lida do `.env` (valores com espaço precisam estar entre aspas). Leitura centralizada em `lib/email/resend.ts` com `getResendFromEmail()` e carregamento explícito do `.env` no contexto de Server Actions
@@ -713,12 +787,16 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.6.2] - 2026-02-19 ## [1.6.2] - 2026-02-19
Correção pontual no mobile: ao selecionar um logo no diálogo de criação de conta/cartão, o diálogo principal fechava inesperadamente. Adicionado `stopPropagation` nos eventos de click/touch dos botões e delay com `requestAnimationFrame` antes de fechar o seletor.
### Corrigido ### Corrigido
- Bug no mobile onde, ao selecionar um logo no diálogo de criação de conta/cartão, o diálogo principal fechava inesperadamente: adicionado `stopPropagation` nos eventos de click/touch dos botões de logo e delay com `requestAnimationFrame` antes de fechar o seletor de logo - Bug no mobile onde, ao selecionar um logo no diálogo de criação de conta/cartão, o diálogo principal fechava inesperadamente: adicionado `stopPropagation` nos eventos de click/touch dos botões de logo e delay com `requestAnimationFrame` antes de fechar o seletor de logo
## [1.6.1] - 2026-02-18 ## [1.6.1] - 2026-02-18
Versão pequena: nome do estabelecimento padronizado para transferências entre contas ("Saída - Transf. entre contas" e "Entrada - Transf. entre contas") com anotação no formato "de {origem} -> {destino}", e correção de avisos `width(-1) and height(-1)` do `ChartContainer` no console.
### Alterado ### Alterado
- Transferências entre contas: nome do estabelecimento passa a ser "Saída - Transf. entre contas" na saída e "Entrada - Transf. entre contas" na entrada e adicionando em anotação no formato "de {conta origem} -> {conta destino}" - Transferências entre contas: nome do estabelecimento passa a ser "Saída - Transf. entre contas" na saída e "Entrada - Transf. entre contas" na entrada e adicionando em anotação no formato "de {conta origem} -> {conta destino}"
@@ -726,6 +804,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.6.0] - 2026-02-18 ## [1.6.0] - 2026-02-18
Versão de personalização da tabela de lançamentos. Duas novas preferências em Ajustes > Extrato e lançamentos: "Anotações em coluna" (controla se a anotação aparece como coluna ou tooltip no ícone) e "Ordem das colunas" (lista ordenável por arrasto pra reordenar Estabelecimento, Transação, Valor etc.). Inclui ajustes mobile no header do dashboard (fixo só no mobile) e na rolagem horizontal de tabs e botões de ação.
### Adicionado ### Adicionado
- Preferência "Anotações em coluna" em Ajustes > Extrato e lançamentos: quando ativa, a anotação dos lançamentos aparece em coluna na tabela; quando inativa, permanece no balão (tooltip) no ícone - Preferência "Anotações em coluna" em Ajustes > Extrato e lançamentos: quando ativa, a anotação dos lançamentos aparece em coluna na tabela; quando inativa, permanece no balão (tooltip) no ícone
@@ -746,6 +826,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.5.3] - 2026-02-21 ## [1.5.3] - 2026-02-21
Versão focada no painel do pagador (novo card "Status de Pagamento" com totais pagos/pendentes e listagem individual de boletos com data de vencimento, data de pagamento e status), além de SEO completo na landing page (Open Graph, Twitter Card, JSON-LD Schema.org, sitemap.xml e robots.txt) e layout específico com metadados ricos. Imagens da landing convertidas de PNG para WebP para melhor performance.
### Adicionado ### Adicionado
- Painel do pagador: card "Status de Pagamento" com totais pagos/pendentes e listagem individual de boletos com data de vencimento, data de pagamento e status - Painel do pagador: card "Status de Pagamento" com totais pagos/pendentes e listagem individual de boletos com data de vencimento, data de pagamento e status
@@ -767,6 +849,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.5.2] - 2026-02-16 ## [1.5.2] - 2026-02-16
Reforma visual da landing page: hero com gradient sutil e tipografia responsiva, dashboard preview sem bordas pra visual mais limpo, seção "Funcionalidades" reorganizada em 6 cards principais + 6 extras compactos, seção "Como usar" com tabs Docker (Recomendado) vs Manual e footer simplificado em 3 colunas. Inclui menu hamburger mobile com Sheet drawer, animações fade-in via Intersection Observer e seção dedicada ao OpenMonetis Companion com screenshots e fluxo de captura.
### Alterado ### Alterado
- Landing page reformulada: visual modernizado, melhor experiência mobile e novas seções - Landing page reformulada: visual modernizado, melhor experiência mobile e novas seções
@@ -789,6 +873,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.5.1] - 2026-02-16 ## [1.5.1] - 2026-02-16
Esta versão renomeia o projeto de **OpenSheets** para **OpenMonetis** em todo o codebase (~40 arquivos: package.json, manifests, layouts, componentes, server actions, emails, Docker, docs e landing page). URLs do repositório atualizados de `opensheets-app` para `openmonetis`, image Docker renomeada para `felipegcoutinho/openmonetis` e logo textual atualizado. Inclui também suporte a multi-domínio via `PUBLIC_DOMAIN` (domínio público serve apenas a landing page, com middleware bloqueando rotas do app).
### Alterado ### Alterado
- Projeto renomeado de **OpenSheets** para **OpenMonetis** em todo o codebase (~40 arquivos): package.json, manifests, layouts, componentes, server actions, emails, Docker, docs e landing page - Projeto renomeado de **OpenSheets** para **OpenMonetis** em todo o codebase (~40 arquivos): package.json, manifests, layouts, componentes, server actions, emails, Docker, docs e landing page
@@ -803,6 +889,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.5.0] - 2026-02-15 ## [1.5.0] - 2026-02-15
Versão de personalização tipográfica: 13 fontes disponíveis (incluindo SF Pro Display, SF Pro Rounded, Inter, Geist Sans, Roboto, Reddit Sans, JetBrains Mono e outras) configuráveis por usuário tanto pra interface quanto pros valores monetários, com FontProvider que aplica a troca instantaneamente via CSS variables sem necessidade de reload. Fontes Apple SF Pro carregadas localmente com 4 pesos (Regular, Medium, Semibold, Bold) e novas colunas `system_font` e `money_font` na tabela `preferencias_usuario`.
### Adicionado ### Adicionado
- Customização de fontes nas preferências — fonte da interface e fonte de valores monetários configuráveis por usuário - Customização de fontes nas preferências — fonte da interface e fonte de valores monetários configuráveis por usuário
@@ -822,6 +910,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.4.1] - 2026-02-15 ## [1.4.1] - 2026-02-15
Versão focada na inbox de pré-lançamentos: novas abas "Pendentes", "Processados" e "Descartados" (antes só pendentes), logo do cartão/conta exibido automaticamente nos cards via matching por nome do app, pre-fill automático do cartão de crédito ao processar e badges de status com data nos itens já processados/descartados em modo readonly. Cor `--warning` ajustada para melhor contraste (mais alaranjada).
### Adicionado ### Adicionado
- Abas "Pendentes", "Processados" e "Descartados" na página de pré-lançamentos (antes exibia apenas pendentes) - Abas "Pendentes", "Processados" e "Descartados" na página de pré-lançamentos (antes exibia apenas pendentes)
@@ -843,6 +933,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.4.0] - 2026-02-07 ## [1.4.0] - 2026-02-07
Reforma do design system: ~60+ componentes migrados de cores hardcoded do Tailwind (`green-500`, `red-600`, `amber-500`, `blue-500` etc.) pra tokens semânticos (`success`, `destructive`, `warning`, `info`); adicionados novos tokens `--success`, `--warning`, `--info` (com foregrounds) tanto em light quanto dark mode, novas variantes `success` e `info` no Badge, e cores de chart estendidas de 6 para 10. Inclui também correção do bug de invalidação de cache do dashboard que impedia widgets de boleto/fatura de atualizar após pagamento, e fix de scroll em listas Popover+Command (estabelecimento, categorias, filtros) com a prop `modal`.
### Corrigido ### Corrigido
- Widgets de boleto/fatura não atualizavam após pagamento: actions de fatura (`updateInvoicePaymentStatusAction`, `updatePaymentDateAction`) e antecipação de parcelas não invalidavam o cache do dashboard - Widgets de boleto/fatura não atualizavam após pagamento: actions de fatura (`updateInvoicePaymentStatusAction`, `updatePaymentDateAction`) e antecipação de parcelas não invalidavam o cache do dashboard
@@ -873,6 +965,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.3.1] - 2026-02-06 ## [1.3.1] - 2026-02-06
Versão pequena: calculadora arrastável via drag handle no header do dialog, callback `onSelectValue` pra inserir valor diretamente no campo de lançamento, e nova aba "Changelog" em Ajustes com histórico parseado do `CHANGELOG.md`. As páginas de itens ativos e arquivados em Cartões, Contas e Anotações foram unificadas com sistema de tabs (mesmo padrão de Categorias), eliminando rotas separadas e nomenclatura inconsistente.
### Adicionado ### Adicionado
- Calculadora arrastável via drag handle no header do dialog - Calculadora arrastável via drag handle no header do dialog
@@ -888,6 +982,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.3.0] - 2026-02-06 ## [1.3.0] - 2026-02-06
Versão de performance no dashboard: indexes compostos em `lancamentos`, cache cross-request via `unstable_cache` com tag `"dashboard"` e TTL de 120s, e invalidação automática em mutations financeiras via `revalidateTag`. Eliminados ~20 JOINs com a tabela `pagadores` (substituídos por filtro direto via `pagadorId`) e queries consolidadas (income-expense-balance: 12→1 com GROUP BY; payment-status: 2→1; expenses/income por categoria: 4→2). Auth session deduplicada por request via `React.cache()` e scan de métricas limitado a 24 meses.
### Adicionado ### Adicionado
- Indexes compostos em `lancamentos`: `(userId, period, transactionType)` e `(pagadorId, period)` - Indexes compostos em `lancamentos`: `(userId, period, transactionType)` e `(pagadorId, period)`
@@ -908,6 +1004,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.2.6] - 2025-02-04 ## [1.2.6] - 2025-02-04
Versão de adaptação ao React 19 compiler: removidos ~60 `useCallback`/`useMemo` desnecessários, wrappers `React.memo` redundantes e simplificação de padrões de hidratação com `useSyncExternalStore`. Sem mudanças visíveis ao usuário — só faxina interna alinhada às novas otimizações automáticas do compilador.
### Alterado ### Alterado
- Refatoração para otimização do React 19 compiler - Refatoração para otimização do React 19 compiler
@@ -936,6 +1034,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.2.5] - 2025-02-01 ## [1.2.5] - 2025-02-01
Versão pequena: novo widget de pagadores no dashboard com avatares atualizados.
### Adicionado ### Adicionado
- Widget de pagadores no dashboard - Widget de pagadores no dashboard
@@ -943,6 +1043,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.2.4] - 2025-01-22 ## [1.2.4] - 2025-01-22
Correção pontual: preservação de formatação nas anotações e ajuste no layout do card de anotações.
### Corrigido ### Corrigido
- Preservar formatação nas anotações - Preservar formatação nas anotações
@@ -950,6 +1052,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.2.3] - 2025-01-22 ## [1.2.3] - 2025-01-22
Versão pequena: versão do app passa a aparecer na sidebar e atualização da documentação.
### Adicionado ### Adicionado
- Versão exibida na sidebar - Versão exibida na sidebar
@@ -957,6 +1061,8 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
## [1.2.2] - 2025-01-22 ## [1.2.2] - 2025-01-22
Versão de manutenção: atualização de dependências e formatação aplicada em todo o código.
### Alterado ### Alterado
- Atualização de dependências - Atualização de dependências

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. > **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.5.4-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.5.5-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -61,7 +61,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
### Funcionalidades ### Funcionalidades
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, extratos detalhados e importação de extratos OFX e XLS/XLSX com detecção automática de categoria. 💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, filtros combináveis, extratos detalhados e importação de extratos OFX e XLS/XLSX com detecção automática de categoria.
📊 **Dashboard e relatórios** — Widgets interativos de métricas, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos. Exportação em PDF e Excel. 📊 **Dashboard e relatórios** — Widgets interativos de métricas, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos. Exportação em PDF e Excel.
@@ -85,7 +85,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
<img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" /> <img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" />
</p> </p>
⚙️ **Personalização** — Tema dark/light e modo privacidade. ⚙️ **Personalização** — Tema dark/light, modo privacidade e changelog visual para acompanhar as novidades do app.
### Stack técnica ### Stack técnica

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.5.4", "version": "2.5.5",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
"scripts": { "scripts": {
@@ -88,6 +88,7 @@
"resend": "^6.12.2", "resend": "^6.12.2",
"sonner": "2.0.7", "sonner": "2.0.7",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"tw-animate-css": "^1.4.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"zod": "4.4.3" "zod": "4.4.3"
}, },

8
pnpm-lock.yaml generated
View File

@@ -182,6 +182,9 @@ importers:
tailwind-merge: tailwind-merge:
specifier: 3.5.0 specifier: 3.5.0
version: 3.5.0 version: 3.5.0
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
vaul: vaul:
specifier: 1.1.2 specifier: 1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -4211,6 +4214,9 @@ packages:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'} engines: {node: '>= 6.0.0'}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
typescript@6.0.3: typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@@ -8398,6 +8404,8 @@ snapshots:
dependencies: dependencies:
tslib: 1.14.1 tslib: 1.14.1
tw-animate-css@1.4.0: {}
typescript@6.0.3: {} typescript@6.0.3: {}
unbash@3.0.0: {} unbash@3.0.0: {}

View File

@@ -76,11 +76,11 @@ const capitalize = (value: string) =>
const EMPTY_FILTERS: TransactionSearchFilters = { const EMPTY_FILTERS: TransactionSearchFilters = {
transactionFilter: null, transactionFilter: null,
conditionFilter: null, conditionFilters: [],
paymentFilter: null, paymentFilters: [],
payerFilter: null, payerFilters: [],
categoryFilter: null, categoryFilters: [],
accountCardFilter: null, accountCardFilters: [],
searchFilter: null, searchFilter: null,
settledFilter: null, settledFilter: null,
attachmentFilter: null, attachmentFilter: null,

View File

@@ -208,7 +208,7 @@ export default async function Page() {
</section> </section>
{/* Features Section */} {/* Features Section */}
<section id="funcionalidades" className="py-12 md:py-24 bg-muted/40"> <section id="funcionalidades" className="py-12 md:py-24">
<div className="max-w-8xl mx-auto px-4"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<AnimateOnScroll> <AnimateOnScroll>
@@ -447,7 +447,7 @@ export default async function Page() {
</section> </section>
{/* Tech Stack Section */} {/* Tech Stack Section */}
<section id="stack" className="py-12 md:py-24 bg-muted/40"> <section id="stack" className="py-12 md:py-24">
<div className="max-w-8xl mx-auto px-4"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<AnimateOnScroll> <AnimateOnScroll>
@@ -535,7 +535,7 @@ export default async function Page() {
</section> </section>
{/* Who is this for Section */} {/* Who is this for Section */}
<section id="para-quem-e" className="py-12 md:py-24 bg-muted/40"> <section id="para-quem-e" className="py-12 md:py-24">
<div className="max-w-8xl mx-auto px-4"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<AnimateOnScroll> <AnimateOnScroll>

View File

@@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -269,54 +270,6 @@
mix-blend-mode: normal; mix-blend-mode: normal;
} }
@keyframes dialog-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dialog-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlay-out {
from { opacity: 1; }
to { opacity: 0; }
}
[data-slot="dialog-overlay"][data-state="open"] {
animation: overlay-in 0.2s ease-out;
}
[data-slot="dialog-overlay"][data-state="closed"] {
animation: overlay-out 0.15s ease-in;
}
[data-slot="dialog-content"][data-state="open"] {
animation: dialog-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
[data-slot="dialog-content"][data-state="closed"] {
animation: dialog-out 0.15s ease-in;
}
@keyframes blink-in { @keyframes blink-in {
0%, 40% { opacity: 1; } 0%, 40% { opacity: 1; }
50%, 90% { opacity: 0; } 50%, 90% { opacity: 0; }

View File

@@ -1,20 +1,26 @@
import Link from "next/link"; "use client";
import type { ChangelogVersion } from "@/features/settings/lib/parse-changelog";
import { Badge } from "@/shared/components/ui/badge";
import { Card } from "@/shared/components/ui/card";
/** Converte "[texto](url)" em link; texto simples fica como está */ import { RiArrowDownSLine } from "@remixicon/react";
function parseContributorLine(content: string) { import { format, parseISO } from "date-fns";
const linkMatch = content.match(/^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/); import { ptBR } from "date-fns/locale";
if (linkMatch) { import { useEffect, useMemo, useState } from "react";
return { label: linkMatch[1], url: linkMatch[2] }; import type {
} BumpType,
return { label: content, url: null }; ChangelogVersion,
} } from "@/features/settings/lib/changelog-types";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card } from "@/shared/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible";
import { cn } from "@/shared/utils/ui";
const sectionBadgeVariant: Record< const sectionBadgeVariant: Record<
string, string,
"success" | "info" | "destructive" | "secondary" | "outline" "success" | "info" | "destructive" | "outline" | "secondary"
> = { > = {
Adicionado: "success", Adicionado: "success",
Alterado: "info", Alterado: "info",
@@ -22,36 +28,57 @@ const sectionBadgeVariant: Record<
Removido: "destructive", Removido: "destructive",
}; };
function getSectionVariant(type: string) { const dotByBump: Record<BumpType, string> = {
return sectionBadgeVariant[type] ?? "secondary"; major: "size-4 bg-primary",
minor: "size-3 bg-primary/80",
patch: "size-2.5 bg-muted-foreground/40",
};
const bumpLabel: Record<BumpType, string> = {
major: "Major",
minor: "Minor",
patch: "Patch",
};
function versionAnchorId(version: string) {
return `v${version.replace(/\./g, "-")}`;
} }
export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) { function anchorIdToVersion(id: string): string | null {
if (!id.startsWith("v")) return null;
return id.slice(1).replace(/-/g, ".");
}
function groupByMonth(versions: ChangelogVersion[]) {
const groups: { key: string; label: string; items: ChangelogVersion[] }[] =
[];
for (const v of versions) {
const date = parseISO(v.isoDate);
const key = Number.isNaN(date.getTime())
? v.isoDate.slice(0, 7)
: format(date, "yyyy-MM");
const label = Number.isNaN(date.getTime())
? key
: format(date, "MMMM 'de' yyyy", { locale: ptBR });
const last = groups.at(-1);
if (last?.key === key) last.items.push(v);
else groups.push({ key, label, items: [v] });
}
return groups;
}
function VersionDetails({ version }: { version: ChangelogVersion }) {
return ( return (
<div className="space-y-4"> <Card className="space-y-4 p-4 bg-primary/5 dark:bg-primary/5">
{versions.map((version) => (
<Card key={version.version} className="p-6">
<div className="flex items-baseline gap-3">
<h3 className="text-lg font-semibold">v{version.version}</h3>
<span className="text-sm text-muted-foreground">
{version.date}
</span>
</div>
<div className="space-y-4 w-full mx-auto sm:w-3/4">
{version.summary && (
<p className="border-l-2 border-muted-foreground/25 pl-3 text-sm text-muted-foreground/80 leading-relaxed italic">
{version.summary}
</p>
)}
{version.sections.map((section) => ( {version.sections.map((section) => (
<div key={section.type}> <div key={section.type}>
<Badge <Badge
variant={getSectionVariant(section.type)} variant={sectionBadgeVariant[section.type] ?? "secondary"}
className="mb-2" className="mb-2"
> >
{section.type} {section.type}
</Badge> </Badge>
<ul className="space-y-2 text-muted-foreground leading-relaxed text-pretty"> <ul className="space-y-2 text-muted-foreground">
{section.items.map((item) => ( {section.items.map((item) => (
<li key={item} className="flex gap-2"> <li key={item} className="flex gap-2">
<span className="text-primary">&bull;</span> <span className="text-primary">&bull;</span>
@@ -61,36 +88,151 @@ export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
</ul> </ul>
</div> </div>
))} ))}
{version.contributor && (
<div className="border-t pt-4 mt-4">
<span className="text-sm text-muted-foreground">
Contribuições: {(() => {
const { label, url } = parseContributorLine(
version.contributor,
);
if (url) {
return (
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-foreground underline underline-offset-2 hover:text-primary"
>
{label}
</Link>
);
}
return (
<span className="font-medium text-foreground">
{label}
</span>
);
})()}
</span>
</div>
)}
</div>
</Card> </Card>
);
}
type TimelineItemProps = {
version: ChangelogVersion;
open: boolean;
onOpenChange: (open: boolean) => void;
isLatest: boolean;
};
function TimelineItem({
version,
open,
onOpenChange,
isLatest,
}: TimelineItemProps) {
const hasDetails = version.sections.length > 0;
const date = parseISO(version.isoDate);
const validDate = !Number.isNaN(date.getTime());
return (
<div className="flex gap-4" id={versionAnchorId(version.version)}>
<div className="flex flex-col items-center pt-1.5">
<span
className={cn(
"rounded-full ring-4 ring-background shrink-0",
dotByBump[version.bump],
)}
aria-label={`Versão ${bumpLabel[version.bump].toLowerCase()}`}
/>
<span className="w-px flex-1 bg-border mt-2" aria-hidden="true" />
</div>
<div className="flex-1 pb-6 space-y-3 min-w-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-semibold font-mono">v{version.version}</h3>
{isLatest ? (
<Badge variant="default" className="text-xs">
Atual
</Badge>
) : null}
<time
className="font-mono text-xs uppercase tracking-wider text-muted-foreground"
dateTime={version.isoDate}
>
{validDate
? format(date, "dd MMM, yyyy", { locale: ptBR }).toUpperCase()
: version.date}
</time>
</div>
{version.summary ? (
<Card className="p-4">
<blockquote className="pl-2 text-sm text-muted-foreground leading-relaxed italic">
{version.summary}
</blockquote>
</Card>
) : null}
{hasDetails ? (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>
<Button
variant="link"
size="sm"
className="text-muted-foreground hover:text-foreground text-xs px-0"
>
<RiArrowDownSLine
className={cn(
"size-4 transition-transform",
open && "rotate-180",
)}
/>
{open ? "Ocultar detalhes" : "Ver detalhes"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2 overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2">
<VersionDetails version={version} />
</CollapsibleContent>
</Collapsible>
) : null}
</div>
</div>
);
}
export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
const [openVersions, setOpenVersions] = useState<Set<string>>(() => {
const initial = new Set<string>();
const first = versions[0]?.version;
if (first) initial.add(first);
return initial;
});
useEffect(() => {
if (typeof window === "undefined") return;
const hash = window.location.hash.slice(1);
if (!hash) return;
const target = anchorIdToVersion(hash);
if (target) {
setOpenVersions((prev) => {
if (prev.has(target)) return prev;
const next = new Set(prev);
next.add(target);
return next;
});
}
requestAnimationFrame(() => {
const el = document.getElementById(hash);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
});
}, []);
const groups = useMemo(() => groupByMonth(versions), [versions]);
const latestVersion = versions[0]?.version;
const setVersionOpen = (version: string, isOpen: boolean) => {
setOpenVersions((prev) => {
const next = new Set(prev);
if (isOpen) next.add(version);
else next.delete(version);
return next;
});
};
return (
<div className="space-y-8 max-w-4xl mx-auto">
{groups.map((group) => (
<div key={group.key} className="space-y-4">
<h2 className="sticky top-0 z-10 py-2 font-semibold uppercase text-primary">
{group.label}
</h2>
<div>
{group.items.map((version) => (
<TimelineItem
key={version.version}
version={version}
isLatest={version.version === latestVersion}
open={openVersions.has(version.version)}
onOpenChange={(o) => setVersionOpen(version.version, o)}
/>
))}
</div>
</div>
))} ))}
</div> </div>
); );

View File

@@ -0,0 +1,30 @@
export type SectionType = "Adicionado" | "Alterado" | "Corrigido" | "Removido";
export const SECTION_TYPES: readonly SectionType[] = [
"Adicionado",
"Alterado",
"Corrigido",
"Removido",
];
export type ChangelogSection = {
type: SectionType;
items: string[];
};
export type BumpType = "major" | "minor" | "patch";
export type ChangelogVersion = {
version: string;
/** Formato exibido "DD/MM/YYYY". */
date: string;
/** Data ISO crua "YYYY-MM-DD" para ordenação e formatação client-side. */
isoDate: string;
bump: BumpType;
summary?: string;
sections: ChangelogSection[];
};
export function isSectionType(value: string): value is SectionType {
return (SECTION_TYPES as readonly string[]).includes(value);
}

View File

@@ -1,21 +1,27 @@
import "server-only";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import {
type BumpType,
type ChangelogSection,
type ChangelogVersion,
isSectionType,
} from "@/features/settings/lib/changelog-types";
type ChangelogSection = { function diffBump(current: string, previous: string | undefined): BumpType {
type: string; if (!previous) return "minor";
items: string[]; const [aMajor = 0, aMinor = 0] = current.split(".").map(Number);
}; const [bMajor = 0, bMinor = 0] = previous.split(".").map(Number);
if (aMajor !== bMajor) return "major";
if (aMinor !== bMinor) return "minor";
return "patch";
}
export type ChangelogVersion = { let cached: ChangelogVersion[] | null = null;
version: string;
date: string;
summary?: string;
sections: ChangelogSection[];
/** Linha de contribuições/autor (pode conter markdown, ex: [Nome](url)) */
contributor?: string;
};
export function parseChangelog(): ChangelogVersion[] { export function parseChangelog(): ChangelogVersion[] {
if (cached) return cached;
const filePath = path.join(process.cwd(), "CHANGELOG.md"); const filePath = path.join(process.cwd(), "CHANGELOG.md");
const content = fs.readFileSync(filePath, "utf-8"); const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n"); const lines = content.split("\n");
@@ -31,10 +37,14 @@ export function parseChangelog(): ChangelogVersion[] {
if (currentSection && currentVersion) { if (currentSection && currentVersion) {
currentVersion.sections.push(currentSection); currentVersion.sections.push(currentSection);
} }
const [y, m, d] = versionMatch[2].split("-"); const version = versionMatch[1] ?? "";
const isoDate = versionMatch[2] ?? "";
const [y, m, d] = isoDate.split("-");
currentVersion = { currentVersion = {
version: versionMatch[1], version,
date: d && m && y ? `${d}/${m}/${y}` : versionMatch[2], isoDate,
date: d && m && y ? `${d}/${m}/${y}` : isoDate,
bump: "patch",
sections: [], sections: [],
}; };
versions.push(currentVersion); versions.push(currentVersion);
@@ -52,33 +62,35 @@ export function parseChangelog(): ChangelogVersion[] {
if (currentSection) { if (currentSection) {
currentVersion.sections.push(currentSection); currentVersion.sections.push(currentSection);
} }
currentSection = { type: sectionMatch[1], items: [] }; const type = sectionMatch[1] ?? "";
currentSection = isSectionType(type) ? { type, items: [] } : null;
continue; continue;
} }
const itemMatch = line.match(/^- (.+)$/); const itemMatch = line.match(/^- (.+)$/);
if (itemMatch && currentSection) { if (itemMatch && currentSection) {
currentSection.items.push(itemMatch[1]); currentSection.items.push(itemMatch[1] ?? "");
continue; continue;
} }
if (currentVersion && !currentSection && line.trim()) { if (currentVersion && !currentSection && line.trim()) {
summaryLines.push(line.trim()); summaryLines.push(line.trim());
continue;
}
// **Contribuições:** ou **Autor:** com texto/link opcional
const contributorMatch = line.match(
/^\*\*(?:Contribuições|Autor):\*\*\s*(.+)$/,
);
if (contributorMatch && currentVersion) {
currentVersion.contributor = contributorMatch[1].trim() || undefined;
} }
} }
if (currentSection && currentVersion) { if (currentSection && currentVersion) {
currentVersion.sections.push(currentSection); currentVersion.sections.push(currentSection);
} }
if (currentVersion && !currentVersion.summary && summaryLines.length > 0) {
currentVersion.summary = summaryLines.join(" ").trim();
}
for (let i = 0; i < versions.length; i++) {
const current = versions[i];
if (!current) continue;
current.bump = diffBump(current.version, versions[i + 1]?.version);
}
cached = versions;
return versions; return versions;
} }

View File

@@ -25,11 +25,11 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
period: z.string().regex(/^\d{4}-\d{2}$/), period: z.string().regex(/^\d{4}-\d{2}$/),
filters: z.object({ filters: z.object({
transactionFilter: z.string().nullable(), transactionFilter: z.string().nullable(),
conditionFilter: z.string().nullable(), conditionFilters: z.array(z.string()),
paymentFilter: z.string().nullable(), paymentFilters: z.array(z.string()),
payerFilter: z.string().nullable(), payerFilters: z.array(z.string()),
categoryFilter: z.string().nullable(), categoryFilters: z.array(z.string()),
accountCardFilter: z.string().nullable(), accountCardFilters: z.array(z.string()),
searchFilter: z.string().nullable(), searchFilter: z.string().nullable(),
settledFilter: z.string().nullable(), settledFilter: z.string().nullable(),
attachmentFilter: z.string().nullable(), attachmentFilter: z.string().nullable(),

View File

@@ -70,6 +70,23 @@ export function BulkActionDialog({
return "Este e os próximos lançamentos"; return "Este e os próximos lançamentos";
}; };
const getPeriodLabel = () => {
if (seriesType === "installment" && currentNumber && totalCount) {
return `Todas as pessoas desta parcela (${currentNumber}/${totalCount})`;
}
if (seriesType === "installment") {
return "Todas as pessoas desta parcela";
}
return "Todas as pessoas deste lançamento";
};
const getPeriodDescription = () => {
if (seriesType === "installment") {
return "Aplica a alteração para todas as pessoas que dividem esta parcela";
}
return "Aplica a alteração para todas as pessoas que dividem este lançamento";
};
const getAllLabel = () => { const getAllLabel = () => {
if (seriesType === "installment" && totalCount) { if (seriesType === "installment" && totalCount) {
return `Todas as parcelas (${totalCount} ${ return `Todas as parcelas (${totalCount} ${
@@ -116,10 +133,10 @@ export function BulkActionDialog({
htmlFor="period" htmlFor="period"
className="text-sm cursor-pointer font-medium" className="text-sm cursor-pointer font-medium"
> >
Todas as pessoas deste período {getPeriodLabel()}
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Aplica a todos os lançamentos deste mesmo mês na série {getPeriodDescription()}
</p> </p>
{scope === "period" && actionType === "edit" && ( {scope === "period" && actionType === "edit" && (
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300"> <div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { RiAddFill } from "@remixicon/react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -18,6 +19,7 @@ import {
getPresignedUploadUrlAction, getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments"; } from "@/features/transactions/actions/attachments";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { Button } from "@/shared/components/ui/button";
import type { import type {
TransactionsExportContext, TransactionsExportContext,
TransactionsPaginationState, TransactionsPaginationState,
@@ -115,7 +117,6 @@ export function TransactionsPage({
const [selectedTransaction, setSelectedTransaction] = const [selectedTransaction, setSelectedTransaction] =
useState<TransactionItem | null>(null); useState<TransactionItem | null>(null);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [copyOpen, setCopyOpen] = useState(false); const [copyOpen, setCopyOpen] = useState(false);
const [transactionToCopy, setTransactionToCopy] = const [transactionToCopy, setTransactionToCopy] =
useState<TransactionItem | null>(null); useState<TransactionItem | null>(null);
@@ -411,15 +412,6 @@ export function TransactionsPage({
setPendingMultipleDeleteData([]); setPendingMultipleDeleteData([]);
}; };
const [transactionTypeForCreate, setTransactionTypeForCreate] = useState<
"Despesa" | "Receita" | null
>(null);
const handleCreate = (type: "Despesa" | "Receita") => {
setTransactionTypeForCreate(type);
setCreateOpen(true);
};
const handleMassAdd = () => { const handleMassAdd = () => {
setMassAddOpen(true); setMassAddOpen(true);
}; };
@@ -558,6 +550,57 @@ export function TransactionsPage({
setAnticipationHistoryOpen(true); setAnticipationHistoryOpen(true);
}; };
const createSlot = allowCreate ? (
<>
<TransactionDialog
mode="create"
payerOptions={payerOptions}
splitPayerOptions={splitPayerOptions}
defaultPayerId={defaultPayerId}
accountOptions={accountOptions}
cardOptions={cardOptions}
categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod}
defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection}
lockPaymentMethod={lockPaymentMethod}
defaultTransactionType="Receita"
maxSizeMb={attachmentMaxSizeMb}
trigger={
<Button className="w-full sm:w-auto">
<RiAddFill className="size-4" />
Nova Receita
</Button>
}
/>
<TransactionDialog
mode="create"
payerOptions={payerOptions}
splitPayerOptions={splitPayerOptions}
defaultPayerId={defaultPayerId}
accountOptions={accountOptions}
cardOptions={cardOptions}
categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod}
defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection}
lockPaymentMethod={lockPaymentMethod}
defaultTransactionType="Despesa"
maxSizeMb={attachmentMaxSizeMb}
trigger={
<Button className="w-full sm:w-auto">
<RiAddFill className="size-4" />
Nova Despesa
</Button>
}
/>
</>
) : null;
return ( return (
<> <>
<TransactionsTable <TransactionsTable
@@ -571,7 +614,7 @@ export function TransactionsPage({
selectedPeriod={selectedPeriod} selectedPeriod={selectedPeriod}
pagination={pagination} pagination={pagination}
exportContext={exportContext} exportContext={exportContext}
onCreate={allowCreate ? handleCreate : undefined} createSlot={createSlot}
onMassAdd={allowCreate ? handleMassAdd : undefined} onMassAdd={allowCreate ? handleMassAdd : undefined}
onEdit={handleEdit} onEdit={handleEdit}
onCopy={handleCopy} onCopy={handleCopy}
@@ -587,28 +630,6 @@ export function TransactionsPage({
isSettlementLoading={(id) => settlementLoadingId === id} isSettlementLoading={(id) => settlementLoadingId === id}
/> />
{allowCreate ? (
<TransactionDialog
mode="create"
open={createOpen}
onOpenChange={setCreateOpen}
payerOptions={payerOptions}
splitPayerOptions={splitPayerOptions}
defaultPayerId={defaultPayerId}
accountOptions={accountOptions}
cardOptions={cardOptions}
categoryOptions={categoryOptions}
estabelecimentos={estabelecimentos}
defaultPeriod={selectedPeriod}
defaultCardId={defaultCardId}
defaultPaymentMethod={defaultPaymentMethod}
lockCardSelection={lockCardSelection}
lockPaymentMethod={lockPaymentMethod}
defaultTransactionType={transactionTypeForCreate ?? undefined}
maxSizeMb={attachmentMaxSizeMb}
/>
) : null}
<TransactionDialog <TransactionDialog
mode="create" mode="create"
open={copyOpen && !!transactionToCopy} open={copyOpen && !!transactionToCopy}

View File

@@ -2,6 +2,7 @@
import { import {
RiCheckLine, RiCheckLine,
RiCloseLine,
RiExpandUpDownLine, RiExpandUpDownLine,
RiFilter3Line, RiFilter3Line,
} from "@remixicon/react"; } from "@remixicon/react";
@@ -10,6 +11,7 @@ import {
type ReactNode, type ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useState, useState,
useTransition, useTransition,
} from "react"; } from "react";
@@ -20,6 +22,7 @@ import {
TRANSACTION_TYPES, TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants"; } from "@/features/transactions/lib/constants";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -46,9 +49,7 @@ import {
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
} from "@/shared/components/ui/select"; } from "@/shared/components/ui/select";
import { Switch } from "@/shared/components/ui/switch"; import { Switch } from "@/shared/components/ui/switch";
@@ -127,6 +128,158 @@ function FilterSelect({
); );
} }
type MultiOption = {
value: string;
label: string;
group?: string;
render?: ReactNode;
};
interface MultiSelectFilterProps {
placeholder: string;
options: MultiOption[];
selected: string[];
onChange: (values: string[]) => void;
widthClass?: string;
disabled?: boolean;
searchable?: boolean;
searchPlaceholder?: string;
groupOrder?: string[];
}
function MultiSelectFilter({
placeholder,
options,
selected,
onChange,
widthClass = "w-full",
disabled,
searchable = false,
searchPlaceholder = "Buscar...",
groupOrder,
}: MultiSelectFilterProps) {
const [open, setOpen] = useState(false);
const groupedOptions = useMemo(() => {
const map = new Map<string, MultiOption[]>();
for (const option of options) {
const key = option.group ?? "";
const list = map.get(key) ?? [];
list.push(option);
map.set(key, list);
}
const orderedKeys = groupOrder
? [
...groupOrder,
...Array.from(map.keys()).filter((k) => !groupOrder.includes(k)),
]
: Array.from(map.keys());
return orderedKeys
.filter((key) => map.has(key))
.map((key) => ({ name: key, items: map.get(key) ?? [] }));
}, [options, groupOrder]);
const selectedSet = new Set(selected);
const selectedOptions = options.filter((option) =>
selectedSet.has(option.value),
);
const toggle = (value: string) => {
if (selectedSet.has(value)) {
onChange(selected.filter((v) => v !== value));
} else {
onChange([...selected, value]);
}
};
const clear = () => {
onChange([]);
};
const triggerLabel: ReactNode =
selectedOptions.length === 0 ? (
placeholder
) : selectedOptions.length === 1 ? (
(selectedOptions[0]?.render ?? selectedOptions[0]?.label)
) : (
<span className="flex items-center gap-1.5">
<span className="text-foreground">
{selectedOptions.length} selecionados
</span>
</span>
);
return (
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"justify-between text-sm border-dashed font-normal",
widthClass,
)}
disabled={disabled}
>
<span className="truncate flex items-center gap-2">
{triggerLabel}
</span>
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[260px] p-0">
<Command>
{searchable ? <CommandInput placeholder={searchPlaceholder} /> : null}
<CommandList>
<CommandEmpty>Nada encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__clear"
onSelect={() => clear()}
disabled={selectedOptions.length === 0}
className="text-muted-foreground data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none"
>
Limpar seleção
</CommandItem>
</CommandGroup>
{groupedOptions.map((group) => (
<CommandGroup
key={group.name || "default"}
heading={group.name || undefined}
>
{group.items.map((option) => {
const isSelected = selectedSet.has(option.value);
return (
<CommandItem
key={option.value}
value={`${option.value} ${option.label}`}
onSelect={() => toggle(option.value)}
className="gap-2"
>
<Checkbox
checked={isSelected}
className="pointer-events-none"
aria-hidden
/>
<span className="flex items-center gap-2 flex-1 min-w-0 truncate">
{option.render ?? option.label}
</span>
{isSelected ? (
<RiCheckLine className="ml-auto size-4 shrink-0" />
) : null}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
interface TransactionsFiltersProps { interface TransactionsFiltersProps {
payerOptions: TransactionFilterOption[]; payerOptions: TransactionFilterOption[];
categoryOptions: TransactionFilterOption[]; categoryOptions: TransactionFilterOption[];
@@ -152,6 +305,11 @@ export function TransactionsFilters({
const getParamValue = (key: string) => const getParamValue = (key: string) =>
searchParams.get(key) ?? FILTER_EMPTY_VALUE; searchParams.get(key) ?? FILTER_EMPTY_VALUE;
const getParamValues = useCallback(
(key: string) => searchParams.getAll(key),
[searchParams],
);
const handleFilterChange = useCallback( const handleFilterChange = useCallback(
(key: string, value: string | null) => { (key: string, value: string | null) => {
const nextParams = new URLSearchParams(searchParams.toString()); const nextParams = new URLSearchParams(searchParams.toString());
@@ -174,6 +332,27 @@ export function TransactionsFilters({
[searchParams, pathname, router], [searchParams, pathname, router],
); );
const handleMultiFilterChange = useCallback(
(key: string, values: string[]) => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete(key);
for (const value of values) {
if (value) {
nextParams.append(key, value);
}
}
nextParams.delete("page");
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
},
[searchParams, pathname, router],
);
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? ""); const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
const currentSearchParam = searchParams.get("q") ?? ""; const currentSearchParam = searchParams.get("q") ?? "";
@@ -205,7 +384,6 @@ export function TransactionsFilters({
nextParams.set("pageSize", pageSizeValue); nextParams.set("pageSize", pageSizeValue);
} }
setSearchValue(""); setSearchValue("");
setCategoryOpen(false);
startTransition(() => { startTransition(() => {
const target = nextParams.toString() const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}` ? `${pathname}?${nextParams.toString()}`
@@ -214,56 +392,79 @@ export function TransactionsFilters({
}); });
}; };
const payerSelectOptions = payerOptions.map((option) => ({ const conditionOptions = useMemo<MultiOption[]>(
() =>
TRANSACTION_CONDITIONS.map((value) => ({
value: slugify(value),
label: value,
render: <ConditionSelectContent label={value} />,
})),
[],
);
const paymentOptions = useMemo<MultiOption[]>(
() =>
PAYMENT_METHODS.map((value) => ({
value: slugify(value),
label: value,
render: <PaymentMethodSelectContent label={value} />,
})),
[],
);
const payerMultiOptions = useMemo<MultiOption[]>(
() =>
payerOptions.map((option) => ({
value: option.slug, value: option.slug,
label: option.label, label: option.label,
avatarUrl: option.avatarUrl, render: (
})); <PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
),
})),
[payerOptions],
);
const accountOptions = accountCardOptions const categoryMultiOptions = useMemo<MultiOption[]>(
.filter((option) => option.kind === "conta") () =>
.map((option) => ({ categoryOptions.map((option) => ({
value: option.slug, value: option.slug,
label: option.label, label: option.label,
logo: option.logo, render: (
})); <CategorySelectContent label={option.label} icon={option.icon} />
),
})),
[categoryOptions],
);
const cardOptions = accountCardOptions const accountCardMultiOptions = useMemo<MultiOption[]>(
.filter((option) => option.kind === "cartao") () =>
.map((option) => ({ accountCardOptions.map((option) => ({
value: option.slug, value: option.slug,
label: option.label, label: option.label,
logo: option.logo, group: option.kind === "cartao" ? "Cartões" : "Contas",
})); render: (
<AccountCardSelectContent
label={option.label}
logo={option.logo}
isCartao={option.kind === "cartao"}
/>
),
})),
[accountCardOptions],
);
const categoryValue = getParamValue("category");
const selectedCategory =
categoryValue !== FILTER_EMPTY_VALUE
? categoryOptions.find((option) => option.slug === categoryValue)
: null;
const payerValue = getParamValue("payer");
const selectedPayer =
payerValue !== FILTER_EMPTY_VALUE
? payerOptions.find((option) => option.slug === payerValue)
: null;
const accountCardValue = getParamValue("accountCard");
const selectedAccountCard =
accountCardValue !== FILTER_EMPTY_VALUE
? accountCardOptions.find((option) => option.slug === accountCardValue)
: null;
const [categoryOpen, setCategoryOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const hasActiveFilters = const hasActiveFilters =
searchParams.get("type") || searchParams.get("type") ||
searchParams.get("condition") || searchParams.getAll("condition").length > 0 ||
searchParams.get("payment") || searchParams.getAll("payment").length > 0 ||
searchParams.get("payer") || searchParams.getAll("payer").length > 0 ||
searchParams.get("category") || searchParams.getAll("category").length > 0 ||
searchParams.get("accountCard") || searchParams.getAll("accountCard").length > 0 ||
searchParams.get("settled") || searchParams.get("settled") ||
searchParams.get("hasAttachment") || searchParams.get("hasAttachment") ||
searchParams.get("isDivided"); searchParams.get("isDivided");
@@ -280,13 +481,28 @@ export function TransactionsFilters({
className, className,
)} )}
> >
<div className="relative w-full md:w-[250px]">
<Input <Input
value={searchValue} value={searchValue}
onChange={(event) => setSearchValue(event.target.value)} onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar" placeholder="Buscar"
aria-label="Buscar lançamentos" aria-label="Buscar lançamentos"
className="w-full md:w-[250px] text-sm border-dashed" className={cn(
"w-full text-sm border-dashed",
searchValue.length > 0 && "pr-8",
)}
/> />
{searchValue.length > 0 ? (
<button
type="button"
onClick={() => setSearchValue("")}
aria-label="Limpar busca"
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<RiCloseLine className="size-4" />
</button>
) : null}
</div>
<div className="flex w-full gap-2 md:w-auto"> <div className="flex w-full gap-2 md:w-auto">
{exportButton && ( {exportButton && (
@@ -348,20 +564,14 @@ export function TransactionsFilters({
<label className="text-sm font-medium"> <label className="text-sm font-medium">
Condição de Lançamento Condição de Lançamento
</label> </label>
<FilterSelect <MultiSelectFilter
param="condition"
placeholder="Todas" placeholder="Todas"
options={TRANSACTION_CONDITIONS.map((v) => ({ options={conditionOptions}
value: slugify(v), selected={getParamValues("condition")}
label: v, onChange={(values) =>
}))} handleMultiFilterChange("condition", values)
widthClass="w-full border-dashed" }
disabled={isPending} disabled={isPending}
getParamValue={getParamValue}
onChange={handleFilterChange}
renderContent={(label) => (
<ConditionSelectContent label={label} />
)}
/> />
</div> </div>
@@ -369,195 +579,61 @@ export function TransactionsFilters({
<label className="text-sm font-medium"> <label className="text-sm font-medium">
Forma de Pagamento Forma de Pagamento
</label> </label>
<FilterSelect <MultiSelectFilter
param="payment" placeholder="Todas"
placeholder="Todos" options={paymentOptions}
options={PAYMENT_METHODS.map((v) => ({ selected={getParamValues("payment")}
value: slugify(v), onChange={(values) =>
label: v, handleMultiFilterChange("payment", values)
}))} }
widthClass="w-full border-dashed"
disabled={isPending} disabled={isPending}
getParamValue={getParamValue}
onChange={handleFilterChange}
renderContent={(label) => (
<PaymentMethodSelectContent label={label} />
)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Pessoa</label> <label className="text-sm font-medium">Pessoa</label>
<Select <MultiSelectFilter
value={getParamValue("payer")} placeholder="Todas"
onValueChange={(value) => options={payerMultiOptions}
handleFilterChange( selected={getParamValues("payer")}
"payer", onChange={(values) =>
value === FILTER_EMPTY_VALUE ? null : value, handleMultiFilterChange("payer", values)
)
} }
disabled={isPending} disabled={isPending}
> searchable
<SelectTrigger searchPlaceholder="Buscar pessoa..."
className="w-full text-sm border-dashed"
disabled={isPending}
>
<span className="truncate">
{selectedPayer ? (
<PayerSelectContent
label={selectedPayer.label}
avatarUrl={selectedPayer.avatarUrl}
/> />
) : (
"Todos"
)}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
{payerSelectOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Categoria</label> <label className="text-sm font-medium">Categoria</label>
<Popover <MultiSelectFilter
open={categoryOpen} placeholder="Todas"
onOpenChange={setCategoryOpen} options={categoryMultiOptions}
modal selected={getParamValues("category")}
> onChange={(values) =>
<PopoverTrigger asChild> handleMultiFilterChange("category", values)
<Button }
variant="outline"
role="combobox"
aria-expanded={categoryOpen}
className="w-full justify-between text-sm border-dashed"
disabled={isPending} disabled={isPending}
> searchable
<span className="truncate flex items-center gap-2"> searchPlaceholder="Buscar categoria..."
{selectedCategory ? (
<CategorySelectContent
label={selectedCategory.label}
icon={selectedCategory.icon}
/> />
) : (
"Todas"
)}
</span>
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[220px] p-0">
<Command>
<CommandInput placeholder="Buscar categoria..." />
<CommandList>
<CommandEmpty>Nada encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
value={FILTER_EMPTY_VALUE}
onSelect={() => {
handleFilterChange("category", null);
setCategoryOpen(false);
}}
>
Todas
{categoryValue === FILTER_EMPTY_VALUE ? (
<RiCheckLine className="ml-auto size-4" />
) : null}
</CommandItem>
{categoryOptions.map((option) => (
<CommandItem
key={option.slug}
value={option.slug}
onSelect={() => {
handleFilterChange("category", option.slug);
setCategoryOpen(false);
}}
>
<CategorySelectContent
label={option.label}
icon={option.icon}
/>
{categoryValue === option.slug ? (
<RiCheckLine className="ml-auto size-4" />
) : null}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Conta/Cartão</label> <label className="text-sm font-medium">Conta/Cartão</label>
<Select <MultiSelectFilter
value={getParamValue("accountCard")} placeholder="Todos"
onValueChange={(value) => options={accountCardMultiOptions}
handleFilterChange( selected={getParamValues("accountCard")}
"accountCard", onChange={(values) =>
value === FILTER_EMPTY_VALUE ? null : value, handleMultiFilterChange("accountCard", values)
)
} }
disabled={isPending} disabled={isPending}
> searchable
<SelectTrigger searchPlaceholder="Buscar conta ou cartão..."
className="w-full text-sm border-dashed" groupOrder={["Contas", "Cartões"]}
disabled={isPending}
>
<span className="truncate">
{selectedAccountCard ? (
<AccountCardSelectContent
label={selectedAccountCard.label}
logo={selectedAccountCard.logo}
isCartao={selectedAccountCard.kind === "cartao"}
/> />
) : (
"Todos"
)}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
{accountOptions.length > 0 ? (
<SelectGroup>
<SelectLabel>Contas</SelectLabel>
{accountOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectGroup>
) : null}
{cardOptions.length > 0 ? (
<SelectGroup>
<SelectLabel>Cartões</SelectLabel>
{cardOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))}
</SelectGroup>
) : null}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { import {
RiAddFill,
RiArrowLeftRightLine, RiArrowLeftRightLine,
RiFileExcel2Line, RiFileExcel2Line,
RiFlashlightFill, RiFlashlightFill,
@@ -16,7 +15,7 @@ import {
type VisibilityState, type VisibilityState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useMemo, useState } from "react"; import { type ReactNode, useMemo, useState } from "react";
import type { import type {
TransactionsExportContext, TransactionsExportContext,
TransactionsPaginationState, TransactionsPaginationState,
@@ -61,7 +60,7 @@ type TransactionsTableProps = {
selectedPeriod?: string; selectedPeriod?: string;
pagination?: TransactionsPaginationState; pagination?: TransactionsPaginationState;
exportContext?: TransactionsExportContext; exportContext?: TransactionsExportContext;
onCreate?: (type: "Despesa" | "Receita") => void; createSlot?: ReactNode;
onMassAdd?: () => void; onMassAdd?: () => void;
onEdit?: (item: TransactionItem) => void; onEdit?: (item: TransactionItem) => void;
onCopy?: (item: TransactionItem) => void; onCopy?: (item: TransactionItem) => void;
@@ -90,7 +89,7 @@ export function TransactionsTable({
selectedPeriod, selectedPeriod,
pagination: serverPagination, pagination: serverPagination,
exportContext, exportContext,
onCreate, createSlot,
onMassAdd, onMassAdd,
onEdit, onEdit,
onCopy, onCopy,
@@ -253,32 +252,15 @@ export function TransactionsTable({
}; };
const showTopControls = const showTopControls =
Boolean(onCreate) || Boolean(onMassAdd) || showFilters; Boolean(createSlot) || Boolean(onMassAdd) || showFilters;
return ( return (
<TooltipProvider> <TooltipProvider>
{showTopControls ? ( {showTopControls ? (
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
{onCreate || onMassAdd ? ( {createSlot || onMassAdd ? (
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row"> <div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
{onCreate ? ( {createSlot}
<>
<Button
onClick={() => onCreate("Receita")}
className="w-full sm:w-auto"
>
<RiAddFill className="size-4" />
Nova Receita
</Button>
<Button
onClick={() => onCreate("Despesa")}
className="w-full sm:w-auto"
>
<RiAddFill className="size-4" />
Nova Despesa
</Button>
</>
) : null}
{onMassAdd ? ( {onMassAdd ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -1,10 +1,10 @@
type TransactionExportFilters = { type TransactionExportFilters = {
transactionFilter: string | null; transactionFilter: string | null;
conditionFilter: string | null; conditionFilters: string[];
paymentFilter: string | null; paymentFilters: string[];
payerFilter: string | null; payerFilters: string[];
categoryFilter: string | null; categoryFilters: string[];
accountCardFilter: string | null; accountCardFilters: string[];
searchFilter: string | null; searchFilter: string | null;
settledFilter: string | null; settledFilter: string | null;
attachmentFilter: string | null; attachmentFilter: string | null;

View File

@@ -1,5 +1,5 @@
import type { SQL } from "drizzle-orm"; import type { SQL } from "drizzle-orm";
import { and, eq, ilike, isNotNull, or, sql } from "drizzle-orm"; import { and, eq, ilike, inArray, isNotNull, or, sql } from "drizzle-orm";
import { import {
cards, cards,
type categories, type categories,
@@ -37,11 +37,11 @@ const TRANSACTIONS_PAGE_SIZE_OPTIONS = [5, 10, 20, 30, 40, 50, 100];
export type TransactionSearchFilters = { export type TransactionSearchFilters = {
transactionFilter: string | null; transactionFilter: string | null;
conditionFilter: string | null; conditionFilters: string[];
paymentFilter: string | null; paymentFilters: string[];
payerFilter: string | null; payerFilters: string[];
categoryFilter: string | null; categoryFilters: string[];
accountCardFilter: string | null; accountCardFilters: string[];
searchFilter: string | null; searchFilter: string | null;
settledFilter: string | null; settledFilter: string | null;
attachmentFilter: string | null; attachmentFilter: string | null;
@@ -123,15 +123,27 @@ export const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value; return Array.isArray(value) ? (value[0] ?? null) : value;
}; };
export const getMultiParam = (
params: ResolvedSearchParams,
key: string,
): string[] => {
const value = params?.[key];
if (!value) {
return [];
}
const list = Array.isArray(value) ? value : [value];
return list.filter((item): item is string => Boolean(item));
};
export const extractTransactionSearchFilters = ( export const extractTransactionSearchFilters = (
params: ResolvedSearchParams, params: ResolvedSearchParams,
): TransactionSearchFilters => ({ ): TransactionSearchFilters => ({
transactionFilter: getSingleParam(params, "type"), transactionFilter: getSingleParam(params, "type"),
conditionFilter: getSingleParam(params, "condition"), conditionFilters: getMultiParam(params, "condition"),
paymentFilter: getSingleParam(params, "payment"), paymentFilters: getMultiParam(params, "payment"),
payerFilter: getSingleParam(params, "payer"), payerFilters: getMultiParam(params, "payer"),
categoryFilter: getSingleParam(params, "category"), categoryFilters: getMultiParam(params, "category"),
accountCardFilter: getSingleParam(params, "accountCard"), accountCardFilters: getMultiParam(params, "accountCard"),
searchFilter: getSingleParam(params, "q"), searchFilter: getSingleParam(params, "q"),
settledFilter: getSingleParam(params, "settled"), settledFilter: getSingleParam(params, "settled"),
attachmentFilter: getSingleParam(params, "hasAttachment"), attachmentFilter: getSingleParam(params, "hasAttachment"),
@@ -354,41 +366,63 @@ export const buildTransactionWhere = ({
where.push(eq(transactions.transactionType, typeValue)); where.push(eq(transactions.transactionType, typeValue));
} }
const conditionValue = const conditionValues = filters.conditionFilters
conditionSlugToValue[filters.conditionFilter ?? ""] ?? null; .map((slug) => conditionSlugToValue[slug] ?? null)
if (isValidCondition(conditionValue)) { .filter(isValidCondition);
where.push(eq(transactions.condition, conditionValue)); if (conditionValues.length > 0) {
where.push(inArray(transactions.condition, conditionValues));
} }
const paymentValue = paymentSlugToValue[filters.paymentFilter ?? ""] ?? null; const paymentValues = filters.paymentFilters
if (isValidPaymentMethod(paymentValue)) { .map((slug) => paymentSlugToValue[slug] ?? null)
where.push(eq(transactions.paymentMethod, paymentValue)); .filter(isValidPaymentMethod);
if (paymentValues.length > 0) {
where.push(inArray(transactions.paymentMethod, paymentValues));
} }
if (!payerId && filters.payerFilter) { if (!payerId && filters.payerFilters.length > 0) {
const id = slugMaps.payer.get(filters.payerFilter); const ids = filters.payerFilters
if (id) { .map((slug) => slugMaps.payer.get(slug))
where.push(eq(transactions.payerId, id)); .filter((id): id is string => Boolean(id));
if (ids.length > 0) {
where.push(inArray(transactions.payerId, ids));
} }
} }
if (filters.categoryFilter) { if (filters.categoryFilters.length > 0) {
const id = slugMaps.category.get(filters.categoryFilter); const ids = filters.categoryFilters
if (id) { .map((slug) => slugMaps.category.get(slug))
where.push(eq(transactions.categoryId, id)); .filter((id): id is string => Boolean(id));
if (ids.length > 0) {
where.push(inArray(transactions.categoryId, ids));
} }
} }
if (filters.accountCardFilter) { if (filters.accountCardFilters.length > 0) {
const accountId = slugMaps.financialAccount.get(filters.accountCardFilter); const accountIds: string[] = [];
const relatedCardId = accountId const cardIds: string[] = [];
? null for (const slug of filters.accountCardFilters) {
: slugMaps.card.get(filters.accountCardFilter); const accountId = slugMaps.financialAccount.get(slug);
if (accountId) { if (accountId) {
where.push(eq(transactions.accountId, accountId)); accountIds.push(accountId);
continue;
} }
if (!accountId && relatedCardId) { const cardId = slugMaps.card.get(slug);
where.push(eq(transactions.cardId, relatedCardId)); if (cardId) {
cardIds.push(cardId);
}
}
if (accountIds.length > 0 && cardIds.length > 0) {
where.push(
or(
inArray(transactions.accountId, accountIds),
inArray(transactions.cardId, cardIds),
) as SQL,
);
} else if (accountIds.length > 0) {
where.push(inArray(transactions.accountId, accountIds));
} else if (cardIds.length > 0) {
where.push(inArray(transactions.cardId, cardIds));
} }
} }

View File

@@ -190,7 +190,7 @@ export function NavbarUser({
> >
<RiMegaphoneLine className="size-4 text-success shrink-0" /> <RiMegaphoneLine className="size-4 text-success shrink-0" />
<span className="flex-1 tracking-wide text-xs font-bold"> <span className="flex-1 tracking-wide text-xs font-bold">
Atualização {updateCheck.latestVersion} disponível Versão {updateCheck.latestVersion} disponível
</span> </span>
</Link> </Link>
)} )}

View File

@@ -22,8 +22,8 @@ function Checkbox({
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none" className="grid place-content-center text-current transition-none"
> >
<RiCheckLine className="size-3.5 [[data-state=indeterminate]_&]:hidden" /> <RiCheckLine className="size-3.5 text-current [[data-state=indeterminate]_&]:hidden" />
<RiSubtractLine className="size-3.5 hidden [[data-state=indeterminate]_&]:block" /> <RiSubtractLine className="size-3.5 hidden text-current [[data-state=indeterminate]_&]:block" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
); );

View File

@@ -36,7 +36,10 @@ function DialogOverlay({
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn("fixed inset-0 z-50 bg-black/50", className)} className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props} {...props}
/> />
); );
@@ -56,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background fixed top-[50%] left-[50%] z-50 grid w-full min-w-0 max-w-[calc(100%-2rem)] max-h-[90vh] overflow-x-hidden overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg sm:p-10 sm:max-w-xl", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full min-w-0 max-w-[calc(100%-2rem)] max-h-[90vh] overflow-x-hidden overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg duration-200 sm:p-10 sm:max-w-xl",
className, className,
)} )}
{...props} {...props}