mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
Compare commits
1 Commits
18893bfe02
...
v2.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6fba5f953 |
106
CHANGELOG.md
106
CHANGELOG.md
@@ -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/),
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- Anexos: corrigido upload que falhava com `NetworkError` — CSP `connect-src` bloqueava fetch para o Storage
|
||||
|
||||
## [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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
Versão pequena: novo widget de pagadores no dashboard com avatares atualizados.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- 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
|
||||
|
||||
Correção pontual: preservação de formatação nas anotações e ajuste no layout do card de anotações.
|
||||
|
||||
### Corrigido
|
||||
|
||||
- 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
|
||||
|
||||
Versão pequena: versão do app passa a aparecer na sidebar e atualização da documentação.
|
||||
|
||||
### Adicionado
|
||||
|
||||
- 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
|
||||
|
||||
Versão de manutenção: atualização de dependências e formatação aplicada em todo o código.
|
||||
|
||||
### Alterado
|
||||
|
||||
- Atualização de dependências
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
@@ -61,7 +61,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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" />
|
||||
</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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.5",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
@@ -88,6 +88,7 @@
|
||||
"resend": "^6.12.2",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "1.1.2",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -182,6 +182,9 @@ importers:
|
||||
tailwind-merge:
|
||||
specifier: 3.5.0
|
||||
version: 3.5.0
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
vaul:
|
||||
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)
|
||||
@@ -4211,6 +4214,9 @@ packages:
|
||||
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
tw-animate-css@1.4.0:
|
||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||
|
||||
typescript@6.0.3:
|
||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -8398,6 +8404,8 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 1.14.1
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
unbash@3.0.0: {}
|
||||
|
||||
@@ -76,11 +76,11 @@ const capitalize = (value: string) =>
|
||||
|
||||
const EMPTY_FILTERS: TransactionSearchFilters = {
|
||||
transactionFilter: null,
|
||||
conditionFilter: null,
|
||||
paymentFilter: null,
|
||||
payerFilter: null,
|
||||
categoryFilter: null,
|
||||
accountCardFilter: null,
|
||||
conditionFilters: [],
|
||||
paymentFilters: [],
|
||||
payerFilters: [],
|
||||
categoryFilters: [],
|
||||
accountCardFilters: [],
|
||||
searchFilter: null,
|
||||
settledFilter: null,
|
||||
attachmentFilter: null,
|
||||
|
||||
@@ -208,7 +208,7 @@ export default async function Page() {
|
||||
</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="mx-auto max-w-6xl">
|
||||
<AnimateOnScroll>
|
||||
@@ -447,7 +447,7 @@ export default async function Page() {
|
||||
</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="mx-auto max-w-6xl">
|
||||
<AnimateOnScroll>
|
||||
@@ -535,7 +535,7 @@ export default async function Page() {
|
||||
</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="mx-auto max-w-4xl">
|
||||
<AnimateOnScroll>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -269,54 +270,6 @@
|
||||
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 {
|
||||
0%, 40% { opacity: 1; }
|
||||
50%, 90% { opacity: 0; }
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import Link from "next/link";
|
||||
import type { ChangelogVersion } from "@/features/settings/lib/parse-changelog";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
"use client";
|
||||
|
||||
/** Converte "[texto](url)" em link; texto simples fica como está */
|
||||
function parseContributorLine(content: string) {
|
||||
const linkMatch = content.match(/^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/);
|
||||
if (linkMatch) {
|
||||
return { label: linkMatch[1], url: linkMatch[2] };
|
||||
}
|
||||
return { label: content, url: null };
|
||||
}
|
||||
import { RiArrowDownSLine } from "@remixicon/react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
BumpType,
|
||||
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<
|
||||
string,
|
||||
"success" | "info" | "destructive" | "secondary" | "outline"
|
||||
"success" | "info" | "destructive" | "outline" | "secondary"
|
||||
> = {
|
||||
Adicionado: "success",
|
||||
Alterado: "info",
|
||||
@@ -22,75 +28,211 @@ const sectionBadgeVariant: Record<
|
||||
Removido: "destructive",
|
||||
};
|
||||
|
||||
function getSectionVariant(type: string) {
|
||||
return sectionBadgeVariant[type] ?? "secondary";
|
||||
const dotByBump: Record<BumpType, string> = {
|
||||
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, "-")}`;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="space-y-4 p-4 bg-primary/5 dark:bg-primary/5">
|
||||
{version.sections.map((section) => (
|
||||
<div key={section.type}>
|
||||
<Badge
|
||||
variant={sectionBadgeVariant[section.type] ?? "secondary"}
|
||||
className="mb-2"
|
||||
>
|
||||
{section.type}
|
||||
</Badge>
|
||||
<ul className="space-y-2 text-muted-foreground">
|
||||
{section.items.map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="text-primary">•</span>
|
||||
<span className="text-sm">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</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-4">
|
||||
{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) => (
|
||||
<div key={section.type}>
|
||||
<Badge
|
||||
variant={getSectionVariant(section.type)}
|
||||
className="mb-2"
|
||||
>
|
||||
{section.type}
|
||||
</Badge>
|
||||
<ul className="space-y-2 text-muted-foreground leading-relaxed text-pretty">
|
||||
{section.items.map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="text-primary">•</span>
|
||||
<span className="text-sm">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<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)}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
30
src/features/settings/lib/changelog-types.ts
Normal file
30
src/features/settings/lib/changelog-types.ts
Normal 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);
|
||||
}
|
||||
@@ -1,21 +1,27 @@
|
||||
import "server-only";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
type BumpType,
|
||||
type ChangelogSection,
|
||||
type ChangelogVersion,
|
||||
isSectionType,
|
||||
} from "@/features/settings/lib/changelog-types";
|
||||
|
||||
type ChangelogSection = {
|
||||
type: string;
|
||||
items: string[];
|
||||
};
|
||||
function diffBump(current: string, previous: string | undefined): BumpType {
|
||||
if (!previous) return "minor";
|
||||
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 = {
|
||||
version: string;
|
||||
date: string;
|
||||
summary?: string;
|
||||
sections: ChangelogSection[];
|
||||
/** Linha de contribuições/autor (pode conter markdown, ex: [Nome](url)) */
|
||||
contributor?: string;
|
||||
};
|
||||
let cached: ChangelogVersion[] | null = null;
|
||||
|
||||
export function parseChangelog(): ChangelogVersion[] {
|
||||
if (cached) return cached;
|
||||
|
||||
const filePath = path.join(process.cwd(), "CHANGELOG.md");
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
@@ -31,10 +37,14 @@ export function parseChangelog(): ChangelogVersion[] {
|
||||
if (currentSection && currentVersion) {
|
||||
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 = {
|
||||
version: versionMatch[1],
|
||||
date: d && m && y ? `${d}/${m}/${y}` : versionMatch[2],
|
||||
version,
|
||||
isoDate,
|
||||
date: d && m && y ? `${d}/${m}/${y}` : isoDate,
|
||||
bump: "patch",
|
||||
sections: [],
|
||||
};
|
||||
versions.push(currentVersion);
|
||||
@@ -52,33 +62,35 @@ export function parseChangelog(): ChangelogVersion[] {
|
||||
if (currentSection) {
|
||||
currentVersion.sections.push(currentSection);
|
||||
}
|
||||
currentSection = { type: sectionMatch[1], items: [] };
|
||||
const type = sectionMatch[1] ?? "";
|
||||
currentSection = isSectionType(type) ? { type, items: [] } : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemMatch = line.match(/^- (.+)$/);
|
||||
if (itemMatch && currentSection) {
|
||||
currentSection.items.push(itemMatch[1]);
|
||||
currentSection.items.push(itemMatch[1] ?? "");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentVersion && !currentSection && 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
||||
period: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
filters: z.object({
|
||||
transactionFilter: z.string().nullable(),
|
||||
conditionFilter: z.string().nullable(),
|
||||
paymentFilter: z.string().nullable(),
|
||||
payerFilter: z.string().nullable(),
|
||||
categoryFilter: z.string().nullable(),
|
||||
accountCardFilter: z.string().nullable(),
|
||||
conditionFilters: z.array(z.string()),
|
||||
paymentFilters: z.array(z.string()),
|
||||
payerFilters: z.array(z.string()),
|
||||
categoryFilters: z.array(z.string()),
|
||||
accountCardFilters: z.array(z.string()),
|
||||
searchFilter: z.string().nullable(),
|
||||
settledFilter: z.string().nullable(),
|
||||
attachmentFilter: z.string().nullable(),
|
||||
|
||||
@@ -116,10 +116,10 @@ export function BulkActionDialog({
|
||||
htmlFor="period"
|
||||
className="text-sm cursor-pointer font-medium"
|
||||
>
|
||||
Todas as pessoas deste período
|
||||
{`Todas as pessoas desta parcela (${currentNumber}/${totalCount})`}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Aplica a todos os lançamentos deste mesmo mês na série
|
||||
Aplica a alteração para todas as pessoas que dividem esta parcela
|
||||
</p>
|
||||
{scope === "period" && actionType === "edit" && (
|
||||
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddFill } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
getPresignedUploadUrlAction,
|
||||
} from "@/features/transactions/actions/attachments";
|
||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import type {
|
||||
TransactionsExportContext,
|
||||
TransactionsPaginationState,
|
||||
@@ -115,7 +117,6 @@ export function TransactionsPage({
|
||||
const [selectedTransaction, setSelectedTransaction] =
|
||||
useState<TransactionItem | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [copyOpen, setCopyOpen] = useState(false);
|
||||
const [transactionToCopy, setTransactionToCopy] =
|
||||
useState<TransactionItem | null>(null);
|
||||
@@ -411,15 +412,6 @@ export function TransactionsPage({
|
||||
setPendingMultipleDeleteData([]);
|
||||
};
|
||||
|
||||
const [transactionTypeForCreate, setTransactionTypeForCreate] = useState<
|
||||
"Despesa" | "Receita" | null
|
||||
>(null);
|
||||
|
||||
const handleCreate = (type: "Despesa" | "Receita") => {
|
||||
setTransactionTypeForCreate(type);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleMassAdd = () => {
|
||||
setMassAddOpen(true);
|
||||
};
|
||||
@@ -558,6 +550,57 @@ export function TransactionsPage({
|
||||
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 (
|
||||
<>
|
||||
<TransactionsTable
|
||||
@@ -571,7 +614,7 @@ export function TransactionsPage({
|
||||
selectedPeriod={selectedPeriod}
|
||||
pagination={pagination}
|
||||
exportContext={exportContext}
|
||||
onCreate={allowCreate ? handleCreate : undefined}
|
||||
createSlot={createSlot}
|
||||
onMassAdd={allowCreate ? handleMassAdd : undefined}
|
||||
onEdit={handleEdit}
|
||||
onCopy={handleCopy}
|
||||
@@ -587,28 +630,6 @@ export function TransactionsPage({
|
||||
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
|
||||
mode="create"
|
||||
open={copyOpen && !!transactionToCopy}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiExpandUpDownLine,
|
||||
RiFilter3Line,
|
||||
} from "@remixicon/react";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
TRANSACTION_TYPES,
|
||||
} from "@/features/transactions/lib/constants";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -46,9 +49,7 @@ import {
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
} from "@/shared/components/ui/select";
|
||||
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 {
|
||||
payerOptions: TransactionFilterOption[];
|
||||
categoryOptions: TransactionFilterOption[];
|
||||
@@ -152,6 +305,11 @@ export function TransactionsFilters({
|
||||
const getParamValue = (key: string) =>
|
||||
searchParams.get(key) ?? FILTER_EMPTY_VALUE;
|
||||
|
||||
const getParamValues = useCallback(
|
||||
(key: string) => searchParams.getAll(key),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
@@ -174,6 +332,27 @@ export function TransactionsFilters({
|
||||
[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 currentSearchParam = searchParams.get("q") ?? "";
|
||||
|
||||
@@ -205,7 +384,6 @@ export function TransactionsFilters({
|
||||
nextParams.set("pageSize", pageSizeValue);
|
||||
}
|
||||
setSearchValue("");
|
||||
setCategoryOpen(false);
|
||||
startTransition(() => {
|
||||
const target = nextParams.toString()
|
||||
? `${pathname}?${nextParams.toString()}`
|
||||
@@ -214,56 +392,79 @@ export function TransactionsFilters({
|
||||
});
|
||||
};
|
||||
|
||||
const payerSelectOptions = payerOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
avatarUrl: option.avatarUrl,
|
||||
}));
|
||||
const conditionOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
TRANSACTION_CONDITIONS.map((value) => ({
|
||||
value: slugify(value),
|
||||
label: value,
|
||||
render: <ConditionSelectContent label={value} />,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const accountOptions = accountCardOptions
|
||||
.filter((option) => option.kind === "conta")
|
||||
.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
logo: option.logo,
|
||||
}));
|
||||
const paymentOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
PAYMENT_METHODS.map((value) => ({
|
||||
value: slugify(value),
|
||||
label: value,
|
||||
render: <PaymentMethodSelectContent label={value} />,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const cardOptions = accountCardOptions
|
||||
.filter((option) => option.kind === "cartao")
|
||||
.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
logo: option.logo,
|
||||
}));
|
||||
const payerMultiOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
payerOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
render: (
|
||||
<PayerSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
[payerOptions],
|
||||
);
|
||||
|
||||
const categoryValue = getParamValue("category");
|
||||
const selectedCategory =
|
||||
categoryValue !== FILTER_EMPTY_VALUE
|
||||
? categoryOptions.find((option) => option.slug === categoryValue)
|
||||
: null;
|
||||
const categoryMultiOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
categoryOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
render: (
|
||||
<CategorySelectContent label={option.label} icon={option.icon} />
|
||||
),
|
||||
})),
|
||||
[categoryOptions],
|
||||
);
|
||||
|
||||
const payerValue = getParamValue("payer");
|
||||
const selectedPayer =
|
||||
payerValue !== FILTER_EMPTY_VALUE
|
||||
? payerOptions.find((option) => option.slug === payerValue)
|
||||
: null;
|
||||
const accountCardMultiOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
accountCardOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
group: option.kind === "cartao" ? "Cartões" : "Contas",
|
||||
render: (
|
||||
<AccountCardSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={option.kind === "cartao"}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
[accountCardOptions],
|
||||
);
|
||||
|
||||
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 hasActiveFilters =
|
||||
searchParams.get("type") ||
|
||||
searchParams.get("condition") ||
|
||||
searchParams.get("payment") ||
|
||||
searchParams.get("payer") ||
|
||||
searchParams.get("category") ||
|
||||
searchParams.get("accountCard") ||
|
||||
searchParams.getAll("condition").length > 0 ||
|
||||
searchParams.getAll("payment").length > 0 ||
|
||||
searchParams.getAll("payer").length > 0 ||
|
||||
searchParams.getAll("category").length > 0 ||
|
||||
searchParams.getAll("accountCard").length > 0 ||
|
||||
searchParams.get("settled") ||
|
||||
searchParams.get("hasAttachment") ||
|
||||
searchParams.get("isDivided");
|
||||
@@ -280,13 +481,28 @@ export function TransactionsFilters({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder="Buscar"
|
||||
aria-label="Buscar lançamentos"
|
||||
className="w-full md:w-[250px] text-sm border-dashed"
|
||||
/>
|
||||
<div className="relative w-full md:w-[250px]">
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder="Buscar"
|
||||
aria-label="Buscar lançamentos"
|
||||
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">
|
||||
{exportButton && (
|
||||
@@ -348,20 +564,14 @@ export function TransactionsFilters({
|
||||
<label className="text-sm font-medium">
|
||||
Condição de Lançamento
|
||||
</label>
|
||||
<FilterSelect
|
||||
param="condition"
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={TRANSACTION_CONDITIONS.map((v) => ({
|
||||
value: slugify(v),
|
||||
label: v,
|
||||
}))}
|
||||
widthClass="w-full border-dashed"
|
||||
options={conditionOptions}
|
||||
selected={getParamValues("condition")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("condition", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
getParamValue={getParamValue}
|
||||
onChange={handleFilterChange}
|
||||
renderContent={(label) => (
|
||||
<ConditionSelectContent label={label} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -369,195 +579,61 @@ export function TransactionsFilters({
|
||||
<label className="text-sm font-medium">
|
||||
Forma de Pagamento
|
||||
</label>
|
||||
<FilterSelect
|
||||
param="payment"
|
||||
placeholder="Todos"
|
||||
options={PAYMENT_METHODS.map((v) => ({
|
||||
value: slugify(v),
|
||||
label: v,
|
||||
}))}
|
||||
widthClass="w-full border-dashed"
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={paymentOptions}
|
||||
selected={getParamValues("payment")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("payment", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
getParamValue={getParamValue}
|
||||
onChange={handleFilterChange}
|
||||
renderContent={(label) => (
|
||||
<PaymentMethodSelectContent label={label} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Pessoa</label>
|
||||
<Select
|
||||
value={getParamValue("payer")}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange(
|
||||
"payer",
|
||||
value === FILTER_EMPTY_VALUE ? null : value,
|
||||
)
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={payerMultiOptions}
|
||||
selected={getParamValues("payer")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("payer", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger
|
||||
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>
|
||||
searchable
|
||||
searchPlaceholder="Buscar pessoa..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Categoria</label>
|
||||
<Popover
|
||||
open={categoryOpen}
|
||||
onOpenChange={setCategoryOpen}
|
||||
modal
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={categoryOpen}
|
||||
className="w-full justify-between text-sm border-dashed"
|
||||
disabled={isPending}
|
||||
>
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{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>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={categoryMultiOptions}
|
||||
selected={getParamValues("category")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("category", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
searchable
|
||||
searchPlaceholder="Buscar categoria..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Conta/Cartão</label>
|
||||
<Select
|
||||
value={getParamValue("accountCard")}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange(
|
||||
"accountCard",
|
||||
value === FILTER_EMPTY_VALUE ? null : value,
|
||||
)
|
||||
<MultiSelectFilter
|
||||
placeholder="Todos"
|
||||
options={accountCardMultiOptions}
|
||||
selected={getParamValues("accountCard")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("accountCard", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full text-sm border-dashed"
|
||||
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>
|
||||
searchable
|
||||
searchPlaceholder="Buscar conta ou cartão..."
|
||||
groupOrder={["Contas", "Cartões"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import {
|
||||
RiAddFill,
|
||||
RiArrowLeftRightLine,
|
||||
RiFileExcel2Line,
|
||||
RiFlashlightFill,
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import type {
|
||||
TransactionsExportContext,
|
||||
TransactionsPaginationState,
|
||||
@@ -61,7 +60,7 @@ type TransactionsTableProps = {
|
||||
selectedPeriod?: string;
|
||||
pagination?: TransactionsPaginationState;
|
||||
exportContext?: TransactionsExportContext;
|
||||
onCreate?: (type: "Despesa" | "Receita") => void;
|
||||
createSlot?: ReactNode;
|
||||
onMassAdd?: () => void;
|
||||
onEdit?: (item: TransactionItem) => void;
|
||||
onCopy?: (item: TransactionItem) => void;
|
||||
@@ -90,7 +89,7 @@ export function TransactionsTable({
|
||||
selectedPeriod,
|
||||
pagination: serverPagination,
|
||||
exportContext,
|
||||
onCreate,
|
||||
createSlot,
|
||||
onMassAdd,
|
||||
onEdit,
|
||||
onCopy,
|
||||
@@ -253,32 +252,15 @@ export function TransactionsTable({
|
||||
};
|
||||
|
||||
const showTopControls =
|
||||
Boolean(onCreate) || Boolean(onMassAdd) || showFilters;
|
||||
Boolean(createSlot) || Boolean(onMassAdd) || showFilters;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{showTopControls ? (
|
||||
<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">
|
||||
{onCreate ? (
|
||||
<>
|
||||
<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}
|
||||
{createSlot}
|
||||
{onMassAdd ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
type TransactionExportFilters = {
|
||||
transactionFilter: string | null;
|
||||
conditionFilter: string | null;
|
||||
paymentFilter: string | null;
|
||||
payerFilter: string | null;
|
||||
categoryFilter: string | null;
|
||||
accountCardFilter: string | null;
|
||||
conditionFilters: string[];
|
||||
paymentFilters: string[];
|
||||
payerFilters: string[];
|
||||
categoryFilters: string[];
|
||||
accountCardFilters: string[];
|
||||
searchFilter: string | null;
|
||||
settledFilter: string | null;
|
||||
attachmentFilter: string | null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
cards,
|
||||
type categories,
|
||||
@@ -37,11 +37,11 @@ const TRANSACTIONS_PAGE_SIZE_OPTIONS = [5, 10, 20, 30, 40, 50, 100];
|
||||
|
||||
export type TransactionSearchFilters = {
|
||||
transactionFilter: string | null;
|
||||
conditionFilter: string | null;
|
||||
paymentFilter: string | null;
|
||||
payerFilter: string | null;
|
||||
categoryFilter: string | null;
|
||||
accountCardFilter: string | null;
|
||||
conditionFilters: string[];
|
||||
paymentFilters: string[];
|
||||
payerFilters: string[];
|
||||
categoryFilters: string[];
|
||||
accountCardFilters: string[];
|
||||
searchFilter: string | null;
|
||||
settledFilter: string | null;
|
||||
attachmentFilter: string | null;
|
||||
@@ -123,15 +123,27 @@ export const getSingleParam = (
|
||||
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 = (
|
||||
params: ResolvedSearchParams,
|
||||
): TransactionSearchFilters => ({
|
||||
transactionFilter: getSingleParam(params, "type"),
|
||||
conditionFilter: getSingleParam(params, "condition"),
|
||||
paymentFilter: getSingleParam(params, "payment"),
|
||||
payerFilter: getSingleParam(params, "payer"),
|
||||
categoryFilter: getSingleParam(params, "category"),
|
||||
accountCardFilter: getSingleParam(params, "accountCard"),
|
||||
conditionFilters: getMultiParam(params, "condition"),
|
||||
paymentFilters: getMultiParam(params, "payment"),
|
||||
payerFilters: getMultiParam(params, "payer"),
|
||||
categoryFilters: getMultiParam(params, "category"),
|
||||
accountCardFilters: getMultiParam(params, "accountCard"),
|
||||
searchFilter: getSingleParam(params, "q"),
|
||||
settledFilter: getSingleParam(params, "settled"),
|
||||
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
||||
@@ -354,41 +366,63 @@ export const buildTransactionWhere = ({
|
||||
where.push(eq(transactions.transactionType, typeValue));
|
||||
}
|
||||
|
||||
const conditionValue =
|
||||
conditionSlugToValue[filters.conditionFilter ?? ""] ?? null;
|
||||
if (isValidCondition(conditionValue)) {
|
||||
where.push(eq(transactions.condition, conditionValue));
|
||||
const conditionValues = filters.conditionFilters
|
||||
.map((slug) => conditionSlugToValue[slug] ?? null)
|
||||
.filter(isValidCondition);
|
||||
if (conditionValues.length > 0) {
|
||||
where.push(inArray(transactions.condition, conditionValues));
|
||||
}
|
||||
|
||||
const paymentValue = paymentSlugToValue[filters.paymentFilter ?? ""] ?? null;
|
||||
if (isValidPaymentMethod(paymentValue)) {
|
||||
where.push(eq(transactions.paymentMethod, paymentValue));
|
||||
const paymentValues = filters.paymentFilters
|
||||
.map((slug) => paymentSlugToValue[slug] ?? null)
|
||||
.filter(isValidPaymentMethod);
|
||||
if (paymentValues.length > 0) {
|
||||
where.push(inArray(transactions.paymentMethod, paymentValues));
|
||||
}
|
||||
|
||||
if (!payerId && filters.payerFilter) {
|
||||
const id = slugMaps.payer.get(filters.payerFilter);
|
||||
if (id) {
|
||||
where.push(eq(transactions.payerId, id));
|
||||
if (!payerId && filters.payerFilters.length > 0) {
|
||||
const ids = filters.payerFilters
|
||||
.map((slug) => slugMaps.payer.get(slug))
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (ids.length > 0) {
|
||||
where.push(inArray(transactions.payerId, ids));
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.categoryFilter) {
|
||||
const id = slugMaps.category.get(filters.categoryFilter);
|
||||
if (id) {
|
||||
where.push(eq(transactions.categoryId, id));
|
||||
if (filters.categoryFilters.length > 0) {
|
||||
const ids = filters.categoryFilters
|
||||
.map((slug) => slugMaps.category.get(slug))
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (ids.length > 0) {
|
||||
where.push(inArray(transactions.categoryId, ids));
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.accountCardFilter) {
|
||||
const accountId = slugMaps.financialAccount.get(filters.accountCardFilter);
|
||||
const relatedCardId = accountId
|
||||
? null
|
||||
: slugMaps.card.get(filters.accountCardFilter);
|
||||
if (accountId) {
|
||||
where.push(eq(transactions.accountId, accountId));
|
||||
if (filters.accountCardFilters.length > 0) {
|
||||
const accountIds: string[] = [];
|
||||
const cardIds: string[] = [];
|
||||
for (const slug of filters.accountCardFilters) {
|
||||
const accountId = slugMaps.financialAccount.get(slug);
|
||||
if (accountId) {
|
||||
accountIds.push(accountId);
|
||||
continue;
|
||||
}
|
||||
const cardId = slugMaps.card.get(slug);
|
||||
if (cardId) {
|
||||
cardIds.push(cardId);
|
||||
}
|
||||
}
|
||||
if (!accountId && relatedCardId) {
|
||||
where.push(eq(transactions.cardId, relatedCardId));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ export function NavbarUser({
|
||||
>
|
||||
<RiMegaphoneLine className="size-4 text-success shrink-0" />
|
||||
<span className="flex-1 tracking-wide text-xs font-bold">
|
||||
Atualização {updateCheck.latestVersion} disponível
|
||||
Versão {updateCheck.latestVersion} disponível
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -22,8 +22,8 @@ function Checkbox({
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<RiCheckLine className="size-3.5 [[data-state=indeterminate]_&]:hidden" />
|
||||
<RiSubtractLine className="size-3.5 hidden [[data-state=indeterminate]_&]:block" />
|
||||
<RiCheckLine className="size-3.5 text-current [[data-state=indeterminate]_&]:hidden" />
|
||||
<RiSubtractLine className="size-3.5 hidden text-current [[data-state=indeterminate]_&]:block" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,10 @@ function DialogOverlay({
|
||||
return (
|
||||
<DialogPrimitive.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}
|
||||
/>
|
||||
);
|
||||
@@ -56,7 +59,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user