Compare commits

...

37 Commits

Author SHA1 Message Date
Felipe Coutinho
78c3ed5995 chore(release): prepara versao 2.7.1 2026-05-31 15:19:15 -03:00
Felipe Coutinho
c34adba587 style(inbox): padroniza acao de lancar notificacoes 2026-05-31 15:19:07 -03:00
Felipe Coutinho
99bc049cf4 fix(boletos): diferencia pagamentos e recebimentos 2026-05-31 15:18:55 -03:00
Felipe Coutinho
35abe1b0bf feat(dashboard): refina experiencia dos widgets 2026-05-31 15:18:43 -03:00
Felipe Coutinho
402f0072af feat(lancamentos): melhora painel de filtros ativos 2026-05-31 15:18:23 -03:00
Felipe Coutinho
02ee5bb758 feat(navegacao): adiciona atalhos financeiros e seletor mensal 2026-05-31 15:18:19 -03:00
Felipe Coutinho
41eecc2538 feat(preferencias): permite ocultar resumo do lancamento 2026-05-31 15:18:07 -03:00
Felipe Coutinho
cdcc677787 style(ui): atualiza identidade visual 2026-05-31 15:17:57 -03:00
Felipe Coutinho
e50eeba36e feat(insights): adiciona suporte ao Ollama 2026-05-28 20:01:44 -03:00
Felipe Coutinho
26cb18a9ad chore(release): prepara versao 2.7.0 2026-05-28 11:00:27 -03:00
Felipe Coutinho
382727a96d chore(deps): atualiza dependencias do workspace 2026-05-28 11:00:20 -03:00
Felipe Coutinho
0df648c7f3 chore(config): atualiza ambiente e setup 2026-05-28 11:00:14 -03:00
Felipe Coutinho
27f361923c style(ui): ajusta tipografia e descricoes 2026-05-28 10:59:46 -03:00
Felipe Coutinho
60b2612e8a feat(relatorios): refina indicadores e filtros 2026-05-28 10:59:36 -03:00
Felipe Coutinho
0171b0ce2f feat(lancamentos): refina filtros e tabela responsiva 2026-05-28 10:59:24 -03:00
Felipe Coutinho
311369f81b feat(lancamentos): amplia divisao e resumo do modal 2026-05-28 10:59:13 -03:00
Felipe Coutinho
ef2c8c50e8 feat(contas): adiciona rendimento pelo extrato 2026-05-28 10:58:59 -03:00
Felipe Coutinho
5319d8a5a6 feat(insights): adiciona suporte ao MiniMax 2026-05-28 10:58:52 -03:00
Felipe Coutinho
37247e319c feat(auth): adiciona sessao persistente no login 2026-05-28 10:58:43 -03:00
Felipe Coutinho
766af2b347 fix(ci): atualiza actions para checkout em Node 24 2026-05-23 13:27:22 -03:00
Felipe Coutinho
5dcd30010e fix(lancamentos): corrige tipo do filtro selecionado 2026-05-23 13:22:32 -03:00
Felipe Coutinho
d589df6993 chore(release): prepara versao 2.6.4 2026-05-23 13:18:15 -03:00
Felipe Coutinho
8a19f0f311 feat(lancamentos): aprimora antecipacao de parcelas 2026-05-23 13:17:55 -03:00
Felipe Coutinho
887885cd98 feat(relatorios): refina analise de parcelas 2026-05-23 13:17:49 -03:00
Felipe Coutinho
7a0e33efd8 feat(lancamentos): adiciona filtro por intervalo de datas 2026-05-23 13:17:42 -03:00
Felipe Coutinho
b9557961e5 style(logos): formata dicionario de nomes 2026-05-23 13:03:27 -03:00
Felipe Coutinho
53c8e47981 Merge branch 'pr-69' 2026-05-23 12:57:48 -03:00
Felipe Coutinho
adc9292cd8 Merge branch 'pr-72' 2026-05-23 12:57:47 -03:00
Felipe Coutinho
b95d6f6752 Merge branch 'pr-70' 2026-05-23 12:57:43 -03:00
lucas
c9f667a065 fix(transactions): restaura scroll no container de anexos do TransactionDialog
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:08:30 -03:00
lucas
01d9c6ea05 (HEAD) fix(transactions): remove overflow styles do container de rolagem do TransactionDialog 2026-05-23 11:58:53 -03:00
lucas
d383d2db91 fix(transactions): usa data da última transação ao adicionar nova linha no MassAddDialog
Quando o usuário adiciona uma nova linha de transação no dialog de múltiplos lançamentos,
a data agora é pré-preenchida com o valor da transação anterior em vez da data atual.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 11:27:43 -03:00
lucas
7a8d01debe feat(logos): adiciona nomes de exibicao via dicionario e busca sem acentos
- Adiciona arquivo display-names.ts com 433 nomes legiveis (ex: bb.png → "Banco do Brasil")
- Adiciona getLogoDisplayName() que consulta dicionario primeiro, com fallback para deriveNameFromLogo
- Adiciona normalizeForSearch() para busca accent-insensitive
- Atualiza account-dialog, card-dialog, logo-picker e use-logo-selection para usar a nova API

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 23:35:11 -03:00
lucas
3be15d3b15 feat(transactions): adiciona dialogo de confirmacao ao descartar lancamentos massivos
Quando o usuario tenta fechar ou cancelar o dialogo de multiplos lancamentos
com dados ainda nao salvos, agora exibe um ConfirmActionDialog pedindo
confirmacao. Evita perda acidental de dados preenchidos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:30:48 -03:00
Felipe Coutinho
fea9cf81d8 fix(docker): aplica configuracao do pnpm no build 2026-05-21 14:15:24 +00:00
Felipe Coutinho
7a10d431ab chore(release): prepara versao 2.6.1 2026-05-21 14:06:08 +00:00
Felipe Coutinho
b7343eb235 fix(ci): usa pnpm do packageManager no workflow 2026-05-21 13:59:14 +00:00
162 changed files with 10320 additions and 3017 deletions

View File

@@ -19,6 +19,9 @@ BETTER_AUTH_SECRET=your-secret-key-here-change-this
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3000
# Defina como true para bloquear novos cadastros # Defina como true para bloquear novos cadastros
DISABLE_SIGNUP=false DISABLE_SIGNUP=false
# Duração de sessões persistentes quando "Manter conectado" estiver marcado
AUTH_SESSION_EXPIRES_IN_DAYS=30
AUTH_SESSION_UPDATE_AGE_HOURS=24
# === Portas === # === Portas ===
APP_PORT=3000 APP_PORT=3000
@@ -56,7 +59,10 @@ UMAMI_DOMAINS=
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY= GOOGLE_GENERATIVE_AI_API_KEY=
MINIMAX_API_KEY=
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OLLAMA_BASE_URL=http://127.0.0.1:11434/v1
OLLAMA_API_KEY=
# === Logo.dev (Opcional) === # === Logo.dev (Opcional) ===
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev # Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev

View File

@@ -13,22 +13,19 @@ on:
env: env:
DOCKER_IMAGE_NAME: openmonetis DOCKER_IMAGE_NAME: openmonetis
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
quality: quality:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10.33.0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' cache: 'pnpm'
@@ -48,7 +45,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

View File

@@ -5,9 +5,6 @@ on:
branches: branches:
- main - main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -16,7 +13,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0

View File

@@ -5,6 +5,115 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [2.7.1] - 2026-05-30
Esta versão melhora a clareza dos fluxos de lançamento e a experiência do dashboard. Boletos de receita agora diferenciam pagamentos de recebimentos, a navegação mensal ficou mais direta e o painel ganhou atalhos mais úteis com personalização simplificada.
### Adicionado
- Preferências: nova opção para exibir ou ocultar o card `Resumo da operação` no modal de lançamento.
- Navegação mensal: ao passar o mouse, focar ou clicar no período selecionado, agora é possível abrir um seletor e ir diretamente para outro mês.
### Alterado
- Documentação: o guia visual foi reescrito com os tokens, temas, componentes e práticas de acessibilidade atuais; o README agora apresenta a identidade visual e as preferências disponíveis.
- Dashboard: os cards de receitas e despesas agora oferecem um atalho discreto para abrir os lançamentos da pessoa principal filtrados pelo tipo e período.
- Dashboard: a configuração e a reordenação de widgets agora partem de uma única ação `Personalizar`, com controle de visibilidade durante a edição.
- Dashboard: em telas pequenas, os atalhos para receita, despesa e anotação foram agrupados no menu `Adicionar`.
- Dashboard: os títulos dos widgets agora usam sentence case para reduzir ruído visual.
- Dashboard: os widgets receberam uma revisão ampla de UX, com hierarquia visual mais clara, listas compactas, textos mais diretos, estados acessíveis e navegação interna consistente.
- Dashboard: o widget `Comportamento de pagamento` foi renomeado para `Distribuição de despesas`.
- Dashboard: limites de orçamento agora aparecem apenas no widget de despesas por categoria.
- Dashboard: o widget `Panorama de gastos` agora exibe todos os lançamentos sem filtro adicional por cartão.
- Navegação: o menu de finanças agora oferece submenus para abrir diretamente as faturas dos cartões e os extratos das contas ativas.
- Lançamentos: ao passar o mouse sobre `Filtros`, os filtros ativos agora aparecem em um painel compacto com remoção individual e ação para limpar todos de uma vez.
### Corrigido
- Dashboard: o saldo consolidado do widget `Minhas contas` não inclui mais contas inativas.
- Boletos: lançamentos de receita agora exibem ações e status como `Receber`, `Recebido` e `Recebido em`, enquanto despesas continuam usando `Pagar`, `Pago` e `Pago em`.
- Dashboard: o modal de baixa de boleto agora usa textos de recebimento e conta de destino para receitas.
- Calendário e pessoas: os detalhes de boletos de receita agora preservam a nomenclatura de recebimento.
## [2.7.0] - 2026-05-28
Esta versão amplia o OpenMonetis para quem usa o app todos os dias e para quem prefere mais controle sobre os próprios dados. Os Insights ganham novas opções de IA, incluindo modelos locais via Ollama, enquanto a autenticação fica mais confortável em dispositivos pessoais. Também entram melhorias práticas em contas, lançamentos compartilhados, filtros, relatórios e dashboard, deixando os fluxos financeiros mais completos e fáceis de revisar.
### Adicionado
- Autenticação: a tela de login agora tem a opção "Manter conectado neste dispositivo", usando a persistência nativa do Better Auth para evitar novo login ao reabrir o navegador ou PWA.
- Autenticação: novas variáveis `AUTH_SESSION_EXPIRES_IN_DAYS` e `AUTH_SESSION_UPDATE_AGE_HOURS` para configurar, em ambientes self-hosted, a duração e a renovação de sessões persistentes.
- Contas: o extrato de uma conta agora tem um atalho "Adicionar rendimento" ao lado de "Ajustar saldo", abrindo um modal simples com valor e data para criar uma receita paga na conta atual, com categoria `Rendimentos`, forma de pagamento `Transferência bancária` e pessoa admin.
- Insights: adicionado suporte ao provider MiniMax via `vercel-minimax-ai-provider`, incluindo os modelos M2.7, M2.7 Highspeed, M2.5, M2.5 Highspeed, M2.1, M2.1 Highspeed e M2.
- Insights: adicionado suporte ao provider Ollama via endpoint OpenAI-compatible, com modelos sugeridos `llama3.2`, `llama3.1`, `qwen2.5` e `mistral`, além de input para qualquer modelo instalado localmente.
- Configuração: adicionadas as variáveis `MINIMAX_API_KEY`, `OLLAMA_BASE_URL` e `OLLAMA_API_KEY` aos exemplos de ambiente, ao assistente de setup e à documentação.
- Dependências: adicionada `@ai-sdk/openai-compatible` para integrar provedores compatíveis com a API da OpenAI, incluindo Ollama.
- Lançamentos: o campo "Dividir com" agora permite selecionar múltiplas pessoas e exibe um campo de valor para cada participante escolhido.
- Lançamentos: o modal de criação e edição agora exibe um card compacto de resumo da operação abaixo dos anexos, incluindo forma de pagamento, destino, categoria, pessoas, valores divididos e quantidade de lançamentos que serão criados.
### Alterado
- Contas: o modal "Adicionar rendimento" usa o mesmo seletor de data do modal de lançamentos e os botões de rendimento e ajuste de saldo agora exibem tooltip.
- Categorias: o header de `/categories/[categoryId]` agora usa três blocos de métrica alinhados para total do mês selecionado, total do mês anterior e variação.
- Dashboard: o botão expansível dos widgets passou de "Ver tudo" para "Expandir", com visual secundário e gradiente inferior mais compacto para diferenciar melhor a ação de abrir o modal dos links que navegam para páginas completas.
- Insights: a resolução de modelos foi centralizada em `model-provider.ts`, reduzindo ramificações na action de geração e preservando OpenAI, Anthropic, Google, MiniMax e OpenRouter.
- Insights: o aviso de privacidade agora diferencia providers externos de providers locais como Ollama.
- Lançamentos: o filtro de categorias agora separa as opções em grupos de `Despesas` e `Receitas`, preservando ícones e busca dentro do seletor.
- Lançamentos: a configuração de divisão foi movida para um modal dedicado e minimalista, com seleção direta de participantes, divisão igual e conferência do total distribuído.
- Lançamentos: a validação de divisão agora aceita uma lista de participações, exige pessoas distintas e confere se a soma dos valores bate com o total do lançamento.
- Lançamentos: os textos de edição de lançamentos divididos foram ajustados para tratar divisões com mais de duas pessoas.
- Lançamentos: o card "Dividir lançamento" agora mostra avatares discretos antes dos nomes das pessoas selecionadas e remove as vírgulas entre os nomes no resumo.
- Relatórios: em `/reports/category-trends`, o seletor de categorias não exibe mais a ação `Todas`; quando há seleção ativa, mostra apenas `Limpar seleção` e resume múltiplas escolhas pela contagem.
- Relatórios: em `/reports/category-trends`, as tabelas agora usam os cabeçalhos `Categoria Despesa` e `Categoria Receita` e não exibem mais o ponto colorido antes do nome da categoria.
### Corrigido
- Categorias: em `/categories/[categoryId]`, o percentual de variação do header agora aparece sem `+` quando já há uma etiqueta indicando aumento, queda ou estabilidade.
- Dashboard: os modais "Ver tudo" dos widgets agora reservam espaço para a barra de rolagem, evitando que ela fique sobreposta aos valores alinhados à direita.
- Insights: o seletor de modelo do OpenRouter mantém o provider selecionado enquanto o usuário digita um modelo customizado sem `/`, evitando voltar automaticamente para o provider padrão.
- Relatórios: em `/reports/category-trends`, a busca do seletor de categorias agora pesquisa pelo nome da categoria, e não apenas pelo ID interno, incluindo correspondência sem acentos.
## [2.6.4] - 2026-05-23
Esta versão reúne o polimento final antes da próxima publicação: melhora o fluxo de antecipação de parcelas, deixa os dialogs de lançamentos mais seguros e consistentes, e incorpora as contribuições vindas dos PRs abertos para nomes de logos e ajustes no cadastro de transações.
### Adicionado
- Logos: adicionado um dicionário de nomes de exibição para logos, com busca normalizada sem acentos e fallback para o comportamento anterior quando não houver mapeamento específico (PR #69).
- Lançamentos: o dialog de adicionar múltiplos lançamentos agora pede confirmação antes de descartar alterações não salvas ao fechar ou cancelar (PR #70).
### Alterado
- Lançamentos: o modal "Histórico de Antecipações" agora segue o padrão do modal de detalhes, com `Fechar` e `Desfazer Antecipação` no rodapé, contagem dentro do conteúdo e cards de antecipação reorganizados em blocos mais escaneáveis.
- Lançamentos: a antecipação de parcelas agora só permite selecionar parcelas futuras ao período escolhido, evitando antecipar a parcela do próprio mês sem bloquear parcelas seguintes da mesma compra.
- Lançamentos: ao criar uma antecipação, o cache do histórico da série agora é invalidado e o modal refaz a busca ao abrir.
- Lançamentos: ao adicionar uma nova linha no dialog de múltiplos lançamentos, a data passa a seguir a última transação informada em vez de voltar para a data atual (PR #72).
### Corrigido
- Lançamentos: ajustado o espaçamento horizontal da área rolável do dialog de adicionar transação para preservar o alinhamento dos campos e botões (PR #71).
## [2.6.3] - 2026-05-22
Esta versão concentra os ajustes feitos depois da `2.6.2` em um único ciclo público. O foco está em dar mais precisão aos filtros de lançamentos por período real de compra e em polir a análise de parcelas para priorizar parcelamentos mais próximos da quitação sem causar saltos visuais nos cards.
### Adicionado
- Lançamentos: o drawer de filtros agora permite informar data inicial e data final para filtrar a tabela por `data_compra`.
### Alterado
- Lançamentos: quando um intervalo de datas está ativo, a consulta server-side deixa de limitar os dados a um único mês e usa o intervalo real de compra, mantendo paginação e exportação alinhadas ao que aparece na tabela.
- Relatórios: os cards de `/reports/installment-analysis` agora são ordenados pelo percentual pago em ordem decrescente, mantendo a data da compra como critério de desempate.
- Relatórios: em `/reports/installment-analysis`, o contador de parcelas selecionadas agora aparece discretamente no botão "detalhes", sem criar uma área extra no corpo do card.
### Corrigido
- Relatórios: selecionar parcelas em um card de `/reports/installment-analysis` não força mais os outros cards da mesma linha a reservarem espaço vazio para o resumo de seleção.
## [2.6.2] - 2026-05-21
Esta versão corrige o build da imagem Docker depois da atualização para `pnpm@11.1.3`. A etapa de dependências dentro do Docker não recebia a configuração do workspace, então o install congelado falhava ao comparar os `overrides` e as políticas de build com o lockfile.
### Corrigido
- Docker: o `Dockerfile` agora usa `pnpm@11.1.3` em todos os estágios e copia `pnpm-workspace.yaml` antes do `pnpm install --frozen-lockfile`, garantindo que `overrides` e `allowBuilds` sejam aplicados também no build da imagem.
## [2.6.1] - 2026-05-21
Esta versão corrige o pipeline de publicação após o salto para a `2.6.0`. O build do GitHub Actions falhava antes mesmo de instalar as dependências porque o workflow ainda fixava uma versão antiga do `pnpm`, enquanto o projeto já declarava `pnpm@11.1.3` no `packageManager`.
### Corrigido
- CI: o workflow de build deixou de fixar uma versão diferente do `pnpm`, usando a versão declarada em `packageManager` para evitar conflito no `pnpm/action-setup`.
- CI: a política de builds do `pnpm` foi migrada para `allowBuilds`, permitindo explicitamente os scripts necessários de `core-js`, `esbuild`, `sharp` e `unrs-resolver` durante o install no GitHub Actions.
## [2.6.0] - 2026-05-21 ## [2.6.0] - 2026-05-21
Esta versão implementa melhorias pensadas para o uso real do dia a dia. O OpenMonetis passa a lidar melhor com parcelamentos que já começaram, recupera atalhos importantes no extrato, deixa a revisão de importações mais precisa e dá mais controle para quem mantém uma instância self-hosted. Também entram correções de consistência no dashboard, em cartões, no tratamento da categoria `Pagamentos` e nas datas vindas de planilhas. Esta versão implementa melhorias pensadas para o uso real do dia a dia. O OpenMonetis passa a lidar melhor com parcelamentos que já começaram, recupera atalhos importantes no extrato, deixa a revisão de importações mais precisa e dá mais controle para quem mantém uma instância self-hosted. Também entram correções de consistência no dashboard, em cartões, no tratamento da categoria `Pagamentos` e nas datas vindas de planilhas.

479
DESIGN.md
View File

@@ -1,389 +1,178 @@
# Design System Inspired by OpenMonetis # Design System do OpenMonetis
## 1. Visual Theme & Atmosphere Este documento descreve a identidade visual implementada no OpenMonetis. Ele deve
ser usado como referência ao criar telas, revisar componentes e manter a
experiência consistente entre dashboard, relatórios, formulários e landing page.
OpenMonetis embodies a warm, approachable financial management aesthetic grounded in trust and transparency. The design system combines a rich warm-orange accent palette with a sophisticated warm-neutral foundation, creating an interface that feels both professional and inviting. The typography and spacing work together to emphasize clarity and hierarchy, supporting the open-source ethos of personal financial control. The visual atmosphere prioritizes legibility and calm navigation, with generous whitespace and deliberate color restraint—the bold orange is reserved for critical calls-to-action and highlights, while the warm grays and blacks anchor the interface with stability and focus. ## 1. Direção visual
**Key Characteristics** O OpenMonetis busca tornar a gestão financeira clara e acolhedora. A interface
- Warm, approachable color story with a dominant orange accent (`#FF7733`) usa superfícies quentes, poucos elementos decorativos e uma cor laranja de
- Generous whitespace and breathing room between sections destaque para orientar o olhar sem transformar toda ação em urgência.
- High contrast between backgrounds and text for accessibility
- Clear typographic hierarchy using Inter for all text and UI
- Minimal elevation and shadow treatment—mostly flat design
- Subtle border accents in warm grays to define surfaces
- Open-source transparency reflected in straightforward, honest design language
## 2. Color Palette & Roles Princípios:
### Primary - priorizar legibilidade e hierarquia em telas com muitos dados;
- **Primary Accent** (`#FF7733`): Used for primary call-to-action buttons, highlights, and key interactive elements throughout the interface; draws user attention to the most important actions - usar laranja para ações principais, seleção e foco;
- **Primary Dark** (`#443732`): Warm-brown anchor color used extensively for text, headings, and interactive elements; provides the primary text color across the system - manter superfícies leves no tema claro e contraste confortável no tema escuro;
- aplicar cores semânticas para comunicar estado, não como decoração;
- preservar espaço suficiente entre blocos para evitar ruído visual;
- favorecer componentes responsivos e navegação acessível por teclado.
### Interactive ## 2. Fonte de verdade
- **Interactive Neutral** (`#0F0D0C`): Near-black used for primary text and strong emphasis elements; highest contrast state
- **Interactive Overlay** (`#0006`): Transparent black overlay at 24% opacity; used for hover states, modals, and depth layering
### Neutral Scale Os tokens globais estão definidos em
- **Neutral 900** (`#2A2827`): Very dark warm gray; used for secondary text and disabled states [`src/app/globals.css`](./src/app/globals.css). Componentes reutilizáveis ficam
- **Neutral 800** (`#322C2A`): Dark warm gray; alternative text color for lower-emphasis content em [`src/shared/components/ui/`](./src/shared/components/ui/) e seguem o padrão
- **Neutral 700** (`#676260`): Medium warm gray; used for tertiary text, captions, and metadata do shadcn/ui com Radix UI e Tailwind CSS 4.
- **Neutral 50** (`#FCF7F6`): Almost-white warm cream; primary background color for light surfaces
- **Neutral 100** (`#F8F6F4`): Very light warm gray; secondary background and subtle surface distinction
- **Neutral 200** (`#F5F2EF`): Light warm gray; tertiary background and card interiors
- **Neutral 300** (`#F0EEEC`): Pale warm gray; border and divider lines
### Surface & Borders Ao implementar uma tela:
- **Surface** (`#FFFFFF`): Pure white; primary card and container background; high-contrast surface
- **Border Light** (`#F0EEEC`): Pale warm gray used for subtle borders between elements
- **Border Dark** (`#2A2827`): Dark warm gray; stronger borders for defined card boundaries
### Semantic / Status 1. use classes semânticas como `bg-background`, `bg-card`, `text-foreground`,
- **Success** (`#0E9D6E`): Green used for positive status indicators, confirmation states, and success messages `text-muted-foreground`, `border-border` e `ring-ring`;
- **Warning** (`#F7A439`): Amber used for cautionary states, warnings, and attention-drawing alerts 2. reutilize os componentes em `src/shared/components/ui/`;
- **Error** (`#F53F2D`): Red-orange used for error states, destructive actions, and validation failures 3. evite cores hexadecimais e valores arbitrários quando já existir um token;
- **Error Alt** (`#D40C1A`): Deep red alternative for critical errors and danger states 4. valide os dois temas antes de concluir a alteração.
## 3. Typography Rules ## 3. Cores
### Font Family A paleta é definida em OKLCH para manter uma percepção de contraste mais
**Primary:** Inter (sans-serif) consistente. Não copie os valores para componentes: use os tokens semânticos.
Fallback: `Inter, system-ui, -apple-system, sans-serif`
**Monospace:** ui-monospace | Token | Papel |
Fallback: `ui-monospace, 'Courier New', monospace` |---|---|
| `background` | Fundo geral da aplicação |
| `foreground` | Texto principal |
| `card` / `card-foreground` | Cards e conteúdo em destaque |
| `popover` / `popover-foreground` | Menus, popovers e overlays |
| `primary` / `primary-foreground` | Ações principais, foco e seleção |
| `secondary` / `secondary-foreground` | Ações secundárias e superfícies discretas |
| `muted` / `muted-foreground` | Apoio visual, descrições e metadados |
| `accent` / `accent-foreground` | Hover e seleção leve |
| `success` | Confirmações, recebimentos e estados positivos |
| `warning` | Atenção, vencimentos e estados intermediários |
| `info` | Informações auxiliares |
| `destructive` | Erros e ações destrutivas |
| `border`, `input`, `ring` | Bordas, campos e foco |
### Hierarchy ### Gráficos
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | Gráficos usam `chart-1` a `chart-10`. Visualizações que precisam de uma escala
|------|------|------|--------|-------------|----------------|-------| sequencial quente podem usar `data-1` a `data-6`. A cor nunca deve ser o único
| Display / H1 | Inter | 60px | 600 | 60px | 0px | Page titles and hero headlines; maximum visual impact | meio de distinguir uma série: inclua legenda, rótulo ou tooltip.
| Heading H2 | Inter | 36px | 600 | 40px | 0px | Section headers and major subsections |
| Heading H3 | Inter | 16px | 600 | 20px | 0px | Card titles and smaller section headers |
| Body | Inter | 20px | 400 | 28px | 0px | Descriptive text, paragraphs, and long-form content |
| Body Secondary | Inter | 16px | 400 | 24px | 0px | Standard body text, list items, and descriptions |
| Emphasis / Span | Inter | 14px | 500 | 20px | 0px | Emphasized text, labels, and badge content |
| Button | Inter | 14px | 500 | 20px | 0px | All button text; medium weight for clarity |
| Caption | Inter | 14px | 400 | 20px | 0px | Metadata, timestamps, and small supporting text |
| Code | ui-monospace | 14px | 400 | 20px | 0px | Code blocks and technical content |
### Principles ### Tema escuro
- **Contrast & Clarity:** Text color `#0F0D0C` on light backgrounds; `#FFFFFF` on dark backgrounds
- **Weight Hierarchy:** Use 600 weight for all headings; 500 for interactive/emphasized text; 400 for body
- **Scale Progression:** Sizes increase in meaningful increments (14 → 16 → 20 → 36 → 60); maintain consistent rhythm
- **Line Height:** Body text uses 1.4× line height multiplier for comfortable reading; headings use tighter ratio (1:1) for impact
- **Readability First:** Avoid line lengths over 80 characters for long-form content; increase line height on smaller screens
## 4. Component Stylings O tema escuro redefine a mesma camada semântica dentro de `.dark`. Não crie uma
segunda árvore de componentes para suportá-lo. Prefira tokens e, somente quando
necessário, variantes Tailwind `dark:`.
### Buttons ## 4. Tipografia
#### Primary Button A família principal é **Bricolage Grotesque**, carregada com `next/font` em
- **Background:** `#FF7733` [`public/fonts/font_index.ts`](./public/fonts/font_index.ts). Os pesos
- **Text Color:** `#FFFFFF` disponíveis são `500`, `600` e `700`, com fallback para Arial e fontes sans-serif
- **Font Size:** `14px` do sistema.
- **Font Weight:** `500`
- **Font Family:** `Inter`
- **Padding:** `8px 16px`
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Height:** `40px`
- **Box Shadow:** `none`
- **Hover State:** Darken background to `#E55F1F`; add subtle shadow `0px 2px 8px rgba(0, 0, 0, 0.12)`
- **Active State:** Darken further to `#CC5118`; increase shadow
- **Disabled State:** Background `#E8E3E0`; text color `#999890`; cursor not-allowed
#### Secondary Button Diretrizes:
- **Background:** `#FFFFFF`
- **Text Color:** `#2A2827`
- **Font Size:** `14px`
- **Font Weight:** `500`
- **Font Family:** `Inter`
- **Padding:** `8px 24px`
- **Border Radius:** `9.2px`
- **Border:** `1px solid #F0EEEC`
- **Height:** `40px`
- **Box Shadow:** `0px 1px 3px rgba(0, 0, 0, 0.06)`
- **Hover State:** Background `#F8F6F4`; border color `#E8E3E0`
- **Active State:** Background `#F0EEEC`; border color `#D5CCCA`
- **Disabled State:** Background `#FAFAF8`; text color `#BFBAB7`; border color `#F0EEEC`
#### Ghost Button - corpo e controles: `text-sm` ou `text-base`;
- **Background:** `transparent` - descrições e metadados: `text-sm text-muted-foreground`;
- **Text Color:** `#443732` - títulos de card: `text-base font-medium`;
- **Font Size:** `14px` - títulos de modal: `text-lg font-semibold`;
- **Font Weight:** `500` - títulos de página: hierarquia responsiva conforme a densidade da tela;
- **Font Family:** `Inter` - números financeiros: destaque por peso e alinhamento, sem depender apenas da
- **Padding:** `6px 8px` cor.
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Height:** `32px`
- **Box Shadow:** `none`
- **Hover State:** Background `rgba(68, 55, 50, 0.05)`
- **Active State:** Background `rgba(68, 55, 50, 0.1)`
- **Disabled State:** Text color `#BFBAB7`; cursor not-allowed
#### Icon Button ## 5. Espaçamento, raio e elevação
- **Background:** `transparent`
- **Icon Color:** `#443732`
- **Size:** `32px` × `32px`
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Padding:** `0px`
- **Box Shadow:** `none`
- **Hover State:** Background `rgba(68, 55, 50, 0.08)`
- **Active State:** Background `rgba(68, 55, 50, 0.12)`
### Cards & Containers A escala base é de `0.25rem` (`4px`). Prefira a escala padrão do Tailwind para
padding, gap e margens. O raio base é `0.7rem`, exposto pelas classes
`rounded-sm`, `rounded-md`, `rounded-lg` e `rounded-xl`.
#### Standard Card Sombras também são tokens. Cards comuns usam `shadow-xs`; menus, tooltips e
- **Background:** `#FFFFFF` modais podem subir de nível conforme a necessidade. Evite adicionar sombra forte
- **Border:** `1px solid #F0EEEC` a cada bloco: bordas e diferença de superfície devem resolver a maior parte da
- **Border Radius:** `11.2px` hierarquia.
- **Padding:** `24px`
- **Box Shadow:** `none`
- **Text Color:** `#2A2827`
- **Hover State:** Border color `#E8E3E0`; box-shadow `0px 4px 12px rgba(0, 0, 0, 0.08)`
#### Card with Top Border ## 6. Componentes
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `15.2px 15.2px 0px 0px` (top corners only)
- **Padding:** `24px`
- **Box Shadow:** `none`
- **Top Border Color:** `#FF7733` (3px height implied)
#### Surface Container (Header/Nav) ### Botões
- **Background:** `#FF7733`
- **Height:** `64px`
- **Padding:** `0px 24px`
- **Box Shadow:** `none`
- **Text Color:** `#FFFFFF`
- **Border:** `0px solid transparent`
#### Light Surface Use [`Button`](./src/shared/components/ui/button.tsx) e suas variantes:
- **Background:** `#F8F6F4`
- **Border:** `0px solid transparent`
- **Border Radius:** `11.2px`
- **Padding:** `16px`
- **Box Shadow:** `none`
### Inputs & Forms | Variante | Uso |
|---|---|
| `default` | Ação principal da tela ou do fluxo |
| `secondary` | Ação complementar |
| `outline` | Ação neutra com contorno |
| `ghost` | Ação discreta em barras e grupos |
| `link` | Ação textual |
| `destructive` | Exclusão ou operação irreversível |
| `navbar` | Ferramentas da navegação superior |
#### Text Input Não coloque duas ações `default` competindo na mesma região. Para ícones sem
- **Background:** `#FFFFFF` rótulo visível, inclua `aria-label` ou texto apenas para leitores de tela.
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `9.2px`
- **Padding:** `12px 16px`
- **Font Size:** `16px`
- **Text Color:** `#2A2827`
- **Line Height:** `24px`
- **Placeholder Color:** `#999890`
- **Focus State:** Border color `#FF7733`; box-shadow `0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
- **Error State:** Border color `#F53F2D`; background `#FEF5F3`
- **Disabled State:** Background `#F8F6F4`; text color `#BFBAB7`; border color `#F0EEEC`
#### Select / Dropdown ### Cards
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `9.2px`
- **Padding:** `12px 16px`
- **Font Size:** `16px`
- **Text Color:** `#2A2827`
- **Focus State:** Border color `#FF7733`; outline `0px`
- **Hover State:** Background `#FAFAF8`
#### Checkbox & Radio Use [`Card`](./src/shared/components/ui/card.tsx) para agrupar informações
- **Size:** `20px` × `20px` relacionadas. O componente já define fundo, borda, sombra leve, raio e destaque
- **Border Radius:** `4px` (checkbox), `50%` (radio) de hover. Não transforme todo conteúdo em card: listas densas e tabelas podem
- **Border:** `2px solid #F0EEEC` usar uma única superfície.
- **Background:** `#FFFFFF`
- **Checked Background:** `#FF7733`
- **Checked Border:** `2px solid #FF7733`
- **Checked Icon Color:** `#FFFFFF`
- **Focus:** Border `2px solid #FF7733`; box-shadow `0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
### Navigation ### Formulários
#### Primary Navigation Campos devem usar os componentes compartilhados, como `Input`, `Select`,
- **Background:** `#FF7733` `Checkbox`, `Switch` e `DatePicker`. Eles já aplicam foco com `ring`, estados
- **Height:** `64px` desabilitados e integração visual com os temas. Sempre associe controles a
- **Padding:** `0px 48px` `Label` e apresente erros próximos ao campo correspondente.
- **Display:** flex; align-items: center; gap `32px`
- **Link Color:** `#FFFFFF`
- **Link Font Size:** `16px`
- **Link Font Weight:** `400`
- **Link Hover:** Opacity `0.8`
- **Link Active:** Text decoration underline; opacity `1.0`
#### Secondary Navigation / Tabs ### Diálogos
- **Background:** `transparent`
- **Border Bottom:** `2px solid #F0EEEC`
- **Tab Padding:** `16px 24px`
- **Tab Color:** `#676260`
- **Tab Font Size:** `16px`
- **Tab Hover:** Color `#443732`
- **Tab Active:** Color `#FF7733`; border-bottom color `#FF7733`
#### Breadcrumb Navigation Use [`Dialog`](./src/shared/components/ui/dialog.tsx) para tarefas focadas. Em
- **Font Size:** `14px` mobile, o conteúdo respeita a largura disponível; em telas maiores, o modal pode
- **Color:** `#676260` ganhar mais espaço. Botões do rodapé devem preservar a ordem e a hierarquia da
- **Separator:** `/` with `0px 8px` margin ação principal.
- **Link Color:** `#443732`
- **Link Hover:** Color `#FF7733`
- **Current (Active):** Color `#2A2827`; font-weight `500`
### Badges & Status Indicators ### Feedback
#### Badge Default Use toast para retorno breve, `Alert` para contexto persistente e componentes em
- **Background:** `#F8F6F4` [`src/shared/components/feedback/`](./src/shared/components/feedback/) para
- **Text Color:** `#443732` estados vazios, status e confirmações. Textos visíveis ao usuário devem estar em
- **Padding:** `4px 12px` português claro.
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
- **Border:** `0px solid transparent`
#### Badge Success ## 7. Layout e navegação
- **Background:** `#E8F5F0`
- **Text Color:** `#0E9D6E`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
#### Badge Warning As páginas protegidas usam uma navbar fixa e um contêiner central com largura
- **Background:** `#FEF5E8` máxima `max-w-8xl`, padding lateral responsivo e espaçamento vertical enxuto. A
- **Text Color:** `#F7A439` navegação principal fica em
- **Padding:** `4px 12px` [`src/shared/components/navigation/navbar/`](./src/shared/components/navigation/navbar/).
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
#### Badge Error Padrões:
- **Background:** `#FEF5F3`
- **Text Color:** `#F53F2D`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
## 5. Layout Principles - telas do App Router devem continuar finas;
- conteúdo principal começa abaixo da navbar fixa (`pt-16`);
- use uma coluna em telas pequenas e expanda grids progressivamente;
- tabelas e gráficos devem preservar leitura em viewport estreita;
- ações essenciais precisam continuar alcançáveis por toque e teclado.
### Spacing System ## 8. Acessibilidade
- **Base Unit:** `4px`
- **Scale:** `4px`, `8px`, `12px`, `16px`, `24px`, `32px`, `48px`, `56px`, `64px`, `80px`, `96px`, `128px`
**Usage Contexts:** - mantenha foco visível com os tokens `ring`;
- **48px:** Tight spacing within compact components (icon-text pairs, inline elements) - use HTML semântico antes de adicionar ARIA;
- **1216px:** Standard padding inside cards, inputs, and buttons - não comunique estado apenas por cor;
- **2432px:** Section gaps, spacing between components on a page - associe labels a inputs;
- **4864px:** Large section separations, hero spacing - forneça nome acessível para botões de ícone;
- **80128px:** Hero margins, page-level vertical rhythm - confira contraste e navegação por teclado nos temas claro e escuro;
- mantenha áreas de toque confortáveis em mobile.
### Grid & Container ## 9. Checklist de revisão visual
- **Max Width:** `1440px` for full-width containers
- **Content Width:** `1152px` for typical page layouts
- **Column Strategy:** 12-column grid system; gutter `24px`
- **Container Padding:** `48px` on desktop (left + right)
- **Section Pattern:** Full-width containers with internal max-width constraint
### Whitespace Philosophy - O componente compartilhado existente foi reutilizado?
OpenMonetis prioritizes breathing room and visual clarity. Whitespace is intentional and strategic—surrounding headings, separating card groups, and framing key messages. The warm neutral backgrounds (`#F8F6F4`, `#F5F2EF`) create natural visual separation without hard borders. Minimum margin between major sections is `64px` vertically; minimum padding inside containers is `16px`. - As cores usam tokens semânticos?
- A tela funciona em tema claro e escuro?
### Border Radius Scale - O layout continua legível em mobile?
- **Sharp Corners:** `0px` (utility container tops, category selectors) - Foco, labels e nomes acessíveis estão presentes?
- **Subtle Radius:** `9.2px` (buttons, small inputs, icon buttons) - Estados vazio, carregando, erro e sucesso foram considerados?
- **Standard Radius:** `11.2px` (cards, standard containers, modals) - Valores financeiros continuam fáceis de comparar?
- **Rounded Top:** `15.2px 15.2px 0px 0px` (card headers, sheet-style containers)
- **Pill Shape:** `24px` (badges, full-rounded tags, avatar images)
- **Circle:** `50%` (avatar images, radial elements)
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (None) | No shadow, `border: 1px solid #F0EEEC` | Default cards, inputs, containers; baseline surfaces |
| Subtle (sm) | `0px 1px 3px rgba(0, 0, 0, 0.06)` | Secondary buttons, hover states on light surfaces |
| Medium (md) | `0px 4px 12px rgba(0, 0, 0, 0.08)` | Elevated cards on hover, floating actions |
| Deep (lg) | `0px 10px 24px rgba(0, 0, 0, 0.12)` | Modals, dropdowns, popover menus |
**Shadow Philosophy:**
The design system uses restrained shadow treatment aligned with a flat-modern aesthetic. Shadows emerge subtly on interaction (hover, focus) rather than as default styling. The primary depth cue is border color (`#F0EEEC`), which maintains visual hierarchy without excessive z-depth. When shadows are used, they employ warm-tinted blacks (`rgba(0, 0, 0, 0.060.12)`) to harmonize with the warm neutral palette.
## 7. Do's and Don'ts
### Do
- Use the primary orange (`#FF7733`) exclusively for the most important call-to-action buttons
- Apply generous padding (`24px48px`) around sections and inside cards for breathing room
- Stack elements vertically with `2432px` gaps for clear visual rhythm
- Use warm grays (`#443732`, `#2A2827`) for all body text to maintain the warm aesthetic
- Apply `9.2px` border radius consistently to all interactive elements (buttons, inputs)
- Keep line heights at `1.4×` or greater for comfortable reading on body text
- Use semantic colors (`#0E9D6E` success, `#F7A439` warning, `#F53F2D` error) intentionally
- Test contrast ratios; maintain WCAG AA minimum (4.5:1 for body text)
- Use the `Inter` typeface exclusively for consistency
- Implement focus states with a `3px` colored outline or border
### Don't
- Don't use orange anywhere except primary CTAs and critical highlights
- Don't reduce padding below `12px` inside cards or `8px` inside compact buttons
- Don't use dark backgrounds (`#0F0D0C`) for body text on light surfaces; stick to `#2A2827` or `#443732`
- Don't apply shadows as default styling; reserve them for elevated states (hover, focus, modal)
- Don't mix border radius values on the same component type; stick to defined scale
- Don't increase line height above `1.6×` for headings; tighten for impact
- Don't use the error color (`#F53F2D`) for general emphasis; reserve for genuine errors
- Don't create new colors outside the palette; use opacity if gradation is needed
- Don't apply multiple shadows to a single element; layer a maximum of two shadow values
- Don't forget to include focus/keyboard navigation states on all interactive elements
## 8. Responsive Behavior
### Breakpoints
| Breakpoint | Width | Key Changes |
|-----------|-------|-------------|
| Mobile | `375px599px` | Single column; container padding `16px`; font sizes reduce 12 sizes; gap scale halved |
| Tablet | `600px1023px` | Two-column grid; container padding `32px`; button height `36px`; heading sizes reduce slightly |
| Desktop | `1024px+` | Full 12-column grid; container padding `48px`; full-scale typography; max-width `1440px` |
### Touch Targets
- **Minimum Interactive Size:** `44px` × `44px` for mobile; `40px` × `40px` for desktop
- **Button Padding:** `12px` vertical, `16px` horizontal (minimum)
- **Link Padding:** `6px` vertical, `8px` horizontal minimum
- **Icon Button:** `32px` × `32px` minimum (32px on mobile preferred)
- **Spacing Between Targets:** `8px` minimum to avoid accidental activation
### Collapsing Strategy
- **Navigation:** Horizontal nav on desktop collapses to hamburger menu on tablet; menu items stack vertically with `12px` gap
- **Grid:** 12-column layout on desktop → 6-column on tablet → 2-column (stacked) on mobile
- **Cards:** Three-column card layouts collapse to single column on mobile; padding reduces from `24px` to `16px`
- **Typography:** Display (60px) → 36px on tablet → 28px on mobile; body (20px) → 18px on tablet → 16px on mobile
- **Spacing:** All spacing scale values reduce by 2533% on mobile (e.g., `24px` gap → `16px` on tablet, `12px` on mobile)
- **Buttons:** Full-width on mobile (padding `0px`); inline (auto-width) on desktop
- **Inputs:** Full-width on mobile; constrained width on desktop
## 9. Agent Prompt Guide
### Quick Color Reference
- **Primary CTA:** Warm Orange (`#FF7733`) — Buttons, highlights, key interactions
- **Primary Text:** Warm Brown (`#443732`) — Headings, strong emphasis
- **Secondary Text:** Dark Neutral (`#2A2827`) — Body text, card content
- **Background:** White (`#FFFFFF`) — Cards, primary surfaces
- **Background Alt:** Cream (`#FCF7F6`) — Alternative surfaces, light containers
- **Border:** Pale Gray (`#F0EEEC`) — Card borders, divider lines
- **Success:** Green (`#0E9D6E`) — Confirmation, positive states
- **Warning:** Amber (`#F7A439`) — Cautions, attention states
- **Error:** Red-Orange (`#F53F2D`) — Errors, destructive actions
- **Disabled:** Light Gray (`#E8E3E0`) — Inactive elements, inaccessible states
### Iteration Guide
1. **Always use `#FF7733` for primary buttons** and all main call-to-action elements; secondary buttons use `#FFFFFF` with `#F0EEEC` border
2. **Typography is always `Inter`** with weights 400 (body), 500 (emphasis), and 600 (headings); size hierarchy: 14 → 16 → 20 → 36 → 60px
3. **Spacing base is `4px`**; use multiples from the scale (8, 12, 16, 24, 32, 48, 64, 80, 96, 128px); never arbitrary values
4. **Border radius:** Apply `9.2px` to all buttons and inputs, `11.2px` to cards, `15.2px 15.2px 0px 0px` to top-bordered containers
5. **Cards default to `#FFFFFF` background with `1px solid #F0EEEC` border**; add shadow only on hover (0px 4px 12px rgba(0, 0, 0, 0.08))
6. **Form inputs:** Padding `12px 16px`, border `1px solid #F0EEEC`, focus state `border: 1px solid #FF7733` + `box-shadow: 0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
7. **Navigation background is always `#FF7733`** with white text; links in content use `#443732` with underline on active
8. **Maintain 1.4× line height minimum for body text**; tighten headings to 1:1 or 1.2:1 ratio
9. **Contrast validation:** Text `#0F0D0C` or `#2A2827` on light backgrounds (WCAG AA); text `#FFFFFF` on `#FF7733` (WCAG AAA)
10. **Responsive collapse:** Reduce padding and font by 25% on mobile; stack multi-column layouts to single column; full-width buttons on mobile only

View File

@@ -5,12 +5,13 @@
# ============================================ # ============================================
FROM node:22-alpine AS deps FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate ARG PNPM_VERSION=11.1.3
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app WORKDIR /app
# Copiar apenas arquivos de dependências para aproveitar cache # Copiar apenas arquivos de dependências para aproveitar cache
COPY package.json pnpm-lock.yaml* ./ COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
# Criar pasta public para o postinstall do pdfjs-dist # Criar pasta public para o postinstall do pdfjs-dist
RUN mkdir -p public RUN mkdir -p public
@@ -23,7 +24,8 @@ RUN pnpm install --frozen-lockfile
# ============================================ # ============================================
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate ARG PNPM_VERSION=11.1.3
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app WORKDIR /app
@@ -52,7 +54,8 @@ RUN pnpm build
# ============================================ # ============================================
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate ARG PNPM_VERSION=11.1.3
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app WORKDIR /app

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. > **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.6.0-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.7.1-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -36,9 +36,11 @@
- [Backup](#-backup) - [Backup](#-backup)
- [Storage S3 Compatível](#-storage-s3-compatível) - [Storage S3 Compatível](#-storage-s3-compatível)
- [Variáveis de Ambiente](#-variáveis-de-ambiente) - [Variáveis de Ambiente](#-variáveis-de-ambiente)
- [Design System](#-design-system)
- [Arquitetura](#-arquitetura) - [Arquitetura](#-arquitetura)
- [Contribuindo](#-contribuindo) - [Contribuindo](#-contribuindo)
- [Apoie o Projeto](#-apoie-o-projeto) - [Apoie o Projeto](#-apoie-o-projeto)
- [Star History](#-star-history)
- [Licença](#-licença) - [Licença](#-licença)
--- ---
@@ -61,9 +63,9 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
### Funcionalidades ### Funcionalidades
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, filtros combináveis, 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, rendimentos e transferências. Categorização, divisão de lançamentos entre várias pessoas, filtros combináveis com intervalo de datas, extratos detalhados e importação de extratos OFX e XLS/XLSX com detecção automática de categoria.
📊 **Dashboard e relatórios** — Widgets interativos de métricas, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos. Exportação em PDF e Excel. 📊 **Dashboard e relatórios** — Widgets personalizáveis, métricas com atalhos para lançamentos, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos e navegação direta entre meses pelo seletor de período. Exportação em PDF e Excel.
💳 **Faturas de cartão** — Acompanhe faturas por período, controle limites e vencimentos. 💳 **Faturas de cartão** — Acompanhe faturas por período, controle limites e vencimentos.
@@ -71,7 +73,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
💸 **Parcelamentos avançados** — Séries de parcelas, antecipação com cálculo de desconto, análise consolidada. 💸 **Parcelamentos avançados** — Séries de parcelas, antecipação com cálculo de desconto, análise consolidada.
🤖 **Insights com IA** — Análises geradas por Claude, GPT, Gemini ou OpenRouter. Insights personalizados e histórico salvo. 🤖 **Insights com IA** — Análises geradas por Claude, GPT, Gemini, MiniMax, OpenRouter ou modelos locais via Ollama. Insights personalizados e histórico salvo.
👥 **Gestão colaborativa** — Pagadores com permissões (admin/viewer), notificações automáticas por e-mail, códigos de compartilhamento. 👥 **Gestão colaborativa** — Pagadores com permissões (admin/viewer), notificações automáticas por e-mail, códigos de compartilhamento.
@@ -85,7 +87,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
<img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" /> <img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" />
</p> </p>
⚙️ **Personalização** — Tema dark/light, modo privacidade e changelog visual para acompanhar as novidades do app. ⚙️ **Personalização** — Tema dark/light, modo privacidade, ordem das colunas, exibição de anotações, tamanho máximo de anexos, resumo opcional no modal de lançamento e changelog visual para acompanhar as novidades do app.
### Stack técnica ### Stack técnica
@@ -93,9 +95,10 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
- **PostgreSQL** + **Drizzle ORM** - **PostgreSQL** + **Drizzle ORM**
- **Better Auth** (email/senha, OAuth, Passkeys/WebAuthn) - **Better Auth** (email/senha, OAuth, Passkeys/WebAuthn)
- **shadcn/ui** (Radix UI) + **Tailwind CSS** - **shadcn/ui** (Radix UI) + **Tailwind CSS**
- **Bricolage Grotesque** via `next/font`
- **Docker** (multi-stage build) - **Docker** (multi-stage build)
- **Biome** (linting + formatting) - **Biome** (linting + formatting)
- **Vercel AI SDK** (Claude, GPT, Gemini, OpenRouter) - **Vercel AI SDK** (Claude, GPT, Gemini, MiniMax, OpenRouter, Ollama)
--- ---
@@ -446,6 +449,8 @@ POSTGRES_DB=openmonetis_db
# Autenticação # Autenticação
DISABLE_SIGNUP=false # true bloqueia novos cadastros DISABLE_SIGNUP=false # true bloqueia novos cadastros
AUTH_SESSION_EXPIRES_IN_DAYS=30 # duração de sessões persistentes
AUTH_SESSION_UPDATE_AGE_HOURS=24 # frequência de renovação da sessão
# S3 Server (opcional, necessario para anexos) # S3 Server (opcional, necessario para anexos)
S3_ENDPOINT= S3_ENDPOINT=
@@ -469,7 +474,10 @@ RESEND_FROM_EMAIL=
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY= GOOGLE_GENERATIVE_AI_API_KEY=
MINIMAX_API_KEY=
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OLLAMA_BASE_URL=http://localhost:11434/v1
OLLAMA_API_KEY=
# Logo.dev (opcional, necessário para logos automáticos de estabelecimentos) # Logo.dev (opcional, necessário para logos automáticos de estabelecimentos)
# Ambas as variáveis são runtime — basta definir no host; nenhum build arg necessário. # Ambas as variáveis são runtime — basta definir no host; nenhum build arg necessário.
@@ -477,6 +485,38 @@ LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY= LOGO_DEV_SECRET_KEY=
``` ```
### IA local com Ollama
O provider Ollama permite gerar insights usando modelos locais. Instale e suba o Ollama no host onde o modelo ficará disponível:
```bash
ollama pull llama3.2
ollama serve
```
Configure a URL OpenAI-compatible no `.env`:
```env
OLLAMA_BASE_URL=http://localhost:11434/v1
# Opcional; normalmente o Ollama local não exige chave.
OLLAMA_API_KEY=
```
Se o OpenMonetis estiver rodando dentro de um container Docker e o Ollama estiver no host, `localhost` aponta para o próprio container. Nesse caso, use uma URL acessível a partir do container, como `http://host.docker.internal:11434/v1` quando disponível, ou o endereço da rede Docker/host configurado no seu ambiente.
---
## 🎨 Design System
O OpenMonetis usa uma identidade visual própria com superfícies quentes, laranja
como cor de destaque, temas claro e escuro e tipografia Bricolage Grotesque. A
interface é construída com tokens semânticos em OKLCH, Tailwind CSS 4 e
componentes compartilhados baseados em shadcn/ui e Radix UI.
As regras de cores, tipografia, componentes, responsividade e acessibilidade
estão documentadas no [`DESIGN.md`](DESIGN.md). Use esse guia como referência ao
criar telas ou alterar componentes visuais.
--- ---
## 🏗️ Arquitetura ## 🏗️ Arquitetura
@@ -579,6 +619,18 @@ Outras formas de contribuir: ⭐ estrela no repo, reportar bugs, melhorar docs,
--- ---
## ⭐ Star History
<a href="https://www.star-history.com/?repos=felipegcoutinho%2Fopenmonetis&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&legend=top-left" />
</picture>
</a>
---
## 📄 Licença ## 📄 Licença
**Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0). **Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0).

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN "mostrar_resumo_lancamento" boolean DEFAULT true NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -204,6 +204,13 @@
"when": 1777648189399, "when": 1777648189399,
"tag": "0029_friendly_spitfire", "tag": "0029_friendly_spitfire",
"breakpoints": true "breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1780150535055,
"tag": "0030_complete_umar",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.6.0", "version": "2.7.1",
"private": true, "private": true,
"packageManager": "pnpm@11.1.3", "packageManager": "pnpm@11.1.3",
"scripts": { "scripts": {
@@ -31,9 +31,10 @@
"mockup": "tsx scripts/mock-data.ts" "mockup": "tsx scripts/mock-data.ts"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.78", "@ai-sdk/anthropic": "^3.0.79",
"@ai-sdk/google": "^3.0.75", "@ai-sdk/google": "^3.0.79",
"@ai-sdk/openai": "^3.0.64", "@ai-sdk/openai": "^3.0.65",
"@ai-sdk/openai-compatible": "^2.0.48",
"@aws-sdk/client-s3": "^3.1050.0", "@aws-sdk/client-s3": "^3.1050.0",
"@aws-sdk/s3-request-presigner": "^3.1050.0", "@aws-sdk/s3-request-presigner": "^3.1050.0",
"@better-auth/passkey": "^1.6.11", "@better-auth/passkey": "^1.6.11",
@@ -63,16 +64,16 @@
"@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.100.11", "@tanstack/react-query": "^5.100.14",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.26",
"ai": "^6.0.185", "ai": "^6.0.191",
"better-auth": "1.6.11", "better-auth": "1.6.11",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.2.1", "date-fns": "^4.3.0",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
@@ -85,11 +86,12 @@
"react-day-picker": "^10.0.1", "react-day-picker": "^10.0.1",
"react-dom": "19.2.6", "react-dom": "19.2.6",
"recharts": "3.8.1", "recharts": "3.8.1",
"resend": "^6.12.3", "resend": "^6.12.4",
"sonner": "2.0.7", "sonner": "2.0.7",
"tailwind-merge": "3.6.0", "tailwind-merge": "3.6.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"vercel-minimax-ai-provider": "^0.0.2",
"zod": "4.4.3" "zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -103,7 +105,7 @@
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"knip": "^6.14.1", "knip": "^6.14.2",
"tailwindcss": "4.3.0", "tailwindcss": "4.3.0",
"tsx": "4.22.3", "tsx": "4.22.3",
"typescript": "6.0.3" "typescript": "6.0.3"

686
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,35 @@
packages: packages:
- '.' - '.'
onlyBuiltDependencies: allowBuilds:
- core-js core-js: true
- esbuild esbuild: true
- sharp sharp: true
- unrs-resolver unrs-resolver: true
neverBuiltDependencies: []
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- '@aws-sdk/client-s3@3.1050.0' - '@aws-sdk/client-s3@3.1050.0'
- '@aws-sdk/s3-request-presigner@3.1050.0' - '@aws-sdk/s3-request-presigner@3.1050.0'
- '@types/node@25.9.1' - '@types/node@25.9.1'
- '@types/react@19.2.15' - '@types/react@19.2.15'
- '@aws-sdk/client-s3@3.1054.0'
- '@aws-sdk/core@3.974.14'
- '@aws-sdk/credential-provider-env@3.972.40'
- '@aws-sdk/credential-provider-http@3.972.42'
- '@aws-sdk/credential-provider-ini@3.972.44'
- '@aws-sdk/credential-provider-login@3.972.44'
- '@aws-sdk/credential-provider-node@3.972.45'
- '@aws-sdk/credential-provider-process@3.972.40'
- '@aws-sdk/credential-provider-sso@3.972.44'
- '@aws-sdk/credential-provider-web-identity@3.972.44'
- '@aws-sdk/middleware-bucket-endpoint@3.972.16'
- '@aws-sdk/middleware-flexible-checksums@3.974.22'
- '@aws-sdk/middleware-sdk-s3@3.972.43'
- '@aws-sdk/nested-clients@3.997.12'
- '@aws-sdk/s3-request-presigner@3.1054.0'
- '@aws-sdk/signature-v4-multi-region@3.996.29'
- '@aws-sdk/token-providers@3.1054.0'
- '@aws-sdk/xml-builder@3.972.26'
overrides: overrides:
defu: 6.1.7 defu: 6.1.7

View File

@@ -1,10 +1,10 @@
import { Inter } from "next/font/google"; import { Bricolage_Grotesque } from "next/font/google";
export const inter = Inter({ export const bricolage = Bricolage_Grotesque({
subsets: ["latin"], subsets: ["latin"],
display: "swap", display: "swap",
variable: "--font-inter", variable: "--font-bricolage",
fallback: ["ui-sans-serif", "system-ui"], fallback: ["arial", "ui-sans-serif", "system-ui"],
weight: ["500", "600", "700"], weight: ["500", "600", "700"],
preload: true, preload: true,
}); });

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MiniMax</title><defs><linearGradient id="lobe-icons-minimax-gradient" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"/><stop offset="100%" stop-color="#FE603C"/></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-gradient)" fill-rule="nonzero"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -229,14 +229,22 @@ if (await askYesNo(" E-mail via Resend (notificações e convites)?")) {
let anthropicKey = ""; let anthropicKey = "";
let openaiKey = ""; let openaiKey = "";
let googleAiKey = ""; let googleAiKey = "";
let minimaxKey = "";
let openrouterKey = ""; let openrouterKey = "";
if (await askYesNo(" Insights com IA (Claude, GPT, Gemini, OpenRouter)?")) { let ollamaBaseUrl = "";
let ollamaApiKey = "";
if (await askYesNo(" Insights com IA (Claude, GPT, Gemini, MiniMax, OpenRouter)?")) {
console.log(` ${c.dim}Deixe em branco o que não for usar${c.reset}`); console.log(` ${c.dim}Deixe em branco o que não for usar${c.reset}`);
anthropicKey = await ask(" ANTHROPIC_API_KEY: "); anthropicKey = await ask(" ANTHROPIC_API_KEY: ");
openaiKey = await ask(" OPENAI_API_KEY: "); openaiKey = await ask(" OPENAI_API_KEY: ");
googleAiKey = await ask(" GOOGLE_GENERATIVE_AI_API_KEY: "); googleAiKey = await ask(" GOOGLE_GENERATIVE_AI_API_KEY: ");
minimaxKey = await ask(" MINIMAX_API_KEY: ");
openrouterKey = await ask(" OPENROUTER_API_KEY: "); openrouterKey = await ask(" OPENROUTER_API_KEY: ");
} }
if (await askYesNo(" Insights locais com Ollama?")) {
ollamaBaseUrl = await askDefault(" OLLAMA_BASE_URL", "http://localhost:11434/v1");
ollamaApiKey = await ask(" OLLAMA_API_KEY (opcional): ");
}
// Domínio público // Domínio público
let publicDomain = ""; let publicDomain = "";
@@ -285,6 +293,9 @@ const envContent = [
"# === Better Auth ===", "# === Better Auth ===",
`BETTER_AUTH_SECRET=${authSecret}`, `BETTER_AUTH_SECRET=${authSecret}`,
`BETTER_AUTH_URL=${betterAuthUrl}`, `BETTER_AUTH_URL=${betterAuthUrl}`,
"DISABLE_SIGNUP=false",
"AUTH_SESSION_EXPIRES_IN_DAYS=30",
"AUTH_SESSION_UPDATE_AGE_HOURS=24",
"", "",
"# === Portas ===", "# === Portas ===",
"APP_PORT=3000", "APP_PORT=3000",
@@ -310,7 +321,10 @@ const envContent = [
opt("ANTHROPIC_API_KEY", anthropicKey), opt("ANTHROPIC_API_KEY", anthropicKey),
opt("OPENAI_API_KEY", openaiKey), opt("OPENAI_API_KEY", openaiKey),
opt("GOOGLE_GENERATIVE_AI_API_KEY", googleAiKey), opt("GOOGLE_GENERATIVE_AI_API_KEY", googleAiKey),
opt("MINIMAX_API_KEY", minimaxKey),
opt("OPENROUTER_API_KEY", openrouterKey), opt("OPENROUTER_API_KEY", openrouterKey),
opt("OLLAMA_BASE_URL", ollamaBaseUrl),
opt("OLLAMA_API_KEY", ollamaApiKey),
].join("\n"); ].join("\n");
writeFileSync(join(targetDir, ".env"), envContent); writeFileSync(join(targetDir, ".env"), envContent);

View File

@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import { connection } from "next/server"; import { connection } from "next/server";
import { AccountDialog } from "@/features/accounts/components/account-dialog"; import { AccountDialog } from "@/features/accounts/components/account-dialog";
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card"; import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
import { AddYieldDialog } from "@/features/accounts/components/add-yield-dialog";
import { AdjustBalanceDialog } from "@/features/accounts/components/adjust-balance-dialog"; import { AdjustBalanceDialog } from "@/features/accounts/components/adjust-balance-dialog";
import type { Account } from "@/features/accounts/components/types"; import type { Account } from "@/features/accounts/components/types";
import { import {
@@ -31,6 +32,7 @@ import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
import { loadLogoOptions } from "@/shared/lib/logo/options"; import { loadLogoOptions } from "@/shared/lib/logo/options";
import { getBusinessDateString } from "@/shared/utils/date";
import { parsePeriodParam } from "@/shared/utils/period"; import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<ResolvedSearchParams>; type PageSearchParams = Promise<ResolvedSearchParams>;
@@ -52,6 +54,17 @@ const resolveDefaultPaymentMethod = (
return "Pix"; return "Pix";
}; };
const resolveDefaultYieldDate = (period: string) => {
const today = getBusinessDateString();
if (today.startsWith(period)) return today;
const [year, month] = period.split("-").map((part) => Number(part));
if (!year || !month) return today;
const lastDay = new Date(year, month, 0).getDate();
return `${period}-${String(lastDay).padStart(2, "0")}`;
};
export default async function Page({ params, searchParams }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
await connection(); await connection();
const { accountId } = await params; const { accountId } = await params;
@@ -109,6 +122,7 @@ export default async function Page({ params, searchParams }: PageProps) {
accountSummary; accountSummary;
const periodLabel = `${capitalize(monthName)} de ${year}`; const periodLabel = `${capitalize(monthName)} de ${year}`;
const defaultYieldDate = resolveDefaultYieldDate(selectedPeriod);
const accountDialogData: Account = { const accountDialogData: Account = {
id: account.id, id: account.id,
@@ -152,11 +166,17 @@ export default async function Page({ params, searchParams }: PageProps) {
totalExpenses={totalExpenses} totalExpenses={totalExpenses}
logo={account.logo} logo={account.logo}
balanceAdjustment={ balanceAdjustment={
<>
<AddYieldDialog
accountId={account.id}
defaultDate={defaultYieldDate}
/>
<AdjustBalanceDialog <AdjustBalanceDialog
accountId={account.id} accountId={account.id}
period={selectedPeriod} period={selectedPeriod}
currentBalance={currentBalance} currentBalance={currentBalance}
/> />
</>
} }
actions={ actions={
<AccountDialog <AccountDialog

View File

@@ -16,8 +16,7 @@ export default function RootLayout({
icon={<RiBankLine />} icon={<RiBankLine />}
title="Contas" title="Contas"
subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas, subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas,
despesas e transações previstas. Use o seletor abaixo para navegar pelos despesas e transações previstas."
meses e visualizar as movimentações correspondentes."
/> />
{children} {children}
</section> </section>

View File

@@ -16,8 +16,7 @@ export default function RootLayout({
icon={<RiBankCard2Line />} icon={<RiBankCard2Line />}
title="Cartões" title="Cartões"
subtitle="Acompanhe todas os cartões do mês selecionado incluindo faturas, limites subtitle="Acompanhe todas os cartões do mês selecionado incluindo faturas, limites
e transações previstas. Use o seletor abaixo para navegar pelos meses e e transações previstas."
visualizar as movimentações correspondentes."
/> />
{children} {children}
</section> </section>

View File

@@ -27,6 +27,10 @@ export default async function Page({ searchParams }: PageProps) {
const { dashboardData, preferences, quickActionOptions } = const { dashboardData, preferences, quickActionOptions } =
await fetchDashboardPageData(user.id, selectedPeriod); await fetchDashboardPageData(user.id, selectedPeriod);
const { dashboardWidgets } = preferences; const { dashboardWidgets } = preferences;
const adminPayerSlug =
quickActionOptions.payerOptions.find(
(option) => option.value === quickActionOptions.defaultPayerId,
)?.slug ?? null;
const logoMappings = await prefetchLogoMappings( const logoMappings = await prefetchLogoMappings(
user.id, user.id,
@@ -37,7 +41,11 @@ export default async function Page({ searchParams }: PageProps) {
<main className="flex flex-col gap-4"> <main className="flex flex-col gap-4">
<DashboardWelcome name={user.name} /> <DashboardWelcome name={user.name} />
<MonthNavigation /> <MonthNavigation />
<DashboardMetricsCards metrics={dashboardData.metrics} /> <DashboardMetricsCards
metrics={dashboardData.metrics}
period={selectedPeriod}
adminPayerSlug={adminPayerSlug}
/>
<LogoPrefetchProvider mappings={logoMappings}> <LogoPrefetchProvider mappings={logoMappings}>
<DashboardGridEditable <DashboardGridEditable
data={dashboardData} data={dashboardData}

View File

@@ -1,39 +1,125 @@
import { Card, CardContent } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton"; import { Skeleton } from "@/shared/components/ui/skeleton";
/** const providers = [
* Loading state para a página de insights com IA "openai",
*/ "anthropic",
"google",
"minimax",
"openrouter",
"ollama",
];
const summaryRows = ["period", "data-source"];
export default function InsightsLoading() { export default function InsightsLoading() {
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<div className="space-y-6 pt-4"> <Card className="flex w-full flex-row items-center justify-between gap-2 px-3 py-3 sm:px-4">
{/* Header */} <div className="flex items-center gap-2">
<Skeleton className="size-8 bg-foreground/10" />
<Skeleton className="h-8 w-40 bg-foreground/10" />
<Skeleton className="size-8 bg-foreground/10" />
</div>
</Card>
<section className="space-y-4">
<div className="grid items-stretch gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="space-y-4">
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardContent className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-10 w-64 rounded-md bg-foreground/10" /> <Skeleton className="h-8 w-64 bg-foreground/10" />
<Skeleton className="h-6 w-96 rounded-md bg-foreground/10" /> <Skeleton className="h-4 w-full max-w-2xl bg-foreground/10" />
<Skeleton className="h-4 w-3/4 max-w-xl bg-foreground/10" />
</div> </div>
{/* Grid de insights */} <div className="space-y-3">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-md border p-6 space-y-4">
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<Skeleton className="h-6 w-48 rounded-md bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-md bg-foreground/10" />
<Skeleton className="h-4 w-3/4 rounded-md bg-foreground/10" />
</div>
<Skeleton className="size-8 rounded-full bg-foreground/10" />
</div>
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-3 w-full rounded-md bg-foreground/10" /> <Skeleton className="h-4 w-28 bg-foreground/10" />
<Skeleton className="h-3 w-full rounded-md bg-foreground/10" /> <Skeleton className="h-3 w-80 max-w-full bg-foreground/10" />
<Skeleton className="h-3 w-2/3 rounded-md bg-foreground/10" /> </div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{providers.map((provider) => (
<div
className="flex min-h-24 items-start gap-3 rounded-2xl border p-4"
key={provider}
>
<Skeleton className="mt-1 size-4 shrink-0 rounded-full bg-foreground/10" />
<Skeleton className="size-8 shrink-0 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-20 bg-foreground/10" />
<Skeleton className="h-3 w-full bg-foreground/10" />
<Skeleton className="h-3 w-3/4 bg-foreground/10" />
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</CardContent>
</Card>
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardContent className="space-y-6">
<div className="space-y-3">
<div className="space-y-2">
<Skeleton className="h-4 w-32 bg-foreground/10" />
<Skeleton className="h-3 w-72 max-w-full bg-foreground/10" />
</div>
<Skeleton className="h-9 w-full max-w-72 bg-foreground/10" />
</div>
<div className="flex items-center justify-between gap-3">
<Skeleton className="h-9 w-24 bg-foreground/10" />
<Skeleton className="h-9 w-32 bg-foreground/10" />
</div>
</CardContent>
</Card>
</div>
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardContent className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Skeleton className="size-9 rounded-xl bg-foreground/10" />
<div className="space-y-2">
<Skeleton className="h-4 w-32 bg-foreground/10" />
<Skeleton className="h-3 w-24 bg-foreground/10" />
</div>
</div>
<Skeleton className="h-9 w-full bg-foreground/10" />
<Skeleton className="h-8 w-full bg-foreground/10" />
<div className="space-y-4">
{summaryRows.map((row) => (
<div className="flex gap-3" key={row}>
<Skeleton className="size-4 shrink-0 bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-24 bg-foreground/10" />
<Skeleton className="h-3 w-full bg-foreground/10" />
</div>
</div>
))}
</div>
<div className="space-y-3">
<Skeleton className="h-3 w-32 bg-foreground/10" />
<div className="flex items-center gap-3">
<Skeleton className="size-8 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-20 bg-foreground/10" />
<Skeleton className="h-3 w-32 bg-foreground/10" />
</div>
</div>
</div>
<Skeleton className="h-20 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-20 w-full rounded-2xl bg-foreground/10" />
</CardContent>
</Card>
</div>
</section>
</main> </main>
); );
} }

View File

@@ -1,10 +1,12 @@
import { connection } from "next/server"; import { connection } from "next/server";
import { fetchDashboardNavbarData } from "@/features/dashboard/lib/navbar-queries"; import { fetchDashboardNavbarData } from "@/features/dashboard/lib/navbar-queries";
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar"; import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { AppPreferencesProvider } from "@/shared/components/providers/app-preferences-provider";
import { LogoDevProvider } from "@/shared/components/providers/logo-dev-provider"; import { LogoDevProvider } from "@/shared/components/providers/logo-dev-provider";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider"; import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
import { getUserSession } from "@/shared/lib/auth/server"; import { getUserSession } from "@/shared/lib/auth/server";
import { isLogoDevEnabled } from "@/shared/lib/logo/server"; import { isLogoDevEnabled } from "@/shared/lib/logo/server";
import { fetchAppPreferences } from "@/shared/lib/preferences/queries";
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
@@ -13,17 +15,22 @@ export default async function DashboardLayout({
}>) { }>) {
await connection(); await connection();
const session = await getUserSession(); const session = await getUserSession();
const navbarData = await fetchDashboardNavbarData(session.user.id); const [navbarData, appPreferences] = await Promise.all([
fetchDashboardNavbarData(session.user.id),
fetchAppPreferences(session.user.id),
]);
const logoDevEnabled = isLogoDevEnabled(); const logoDevEnabled = isLogoDevEnabled();
return ( return (
<LogoDevProvider enabled={logoDevEnabled}> <LogoDevProvider enabled={logoDevEnabled}>
<AppPreferencesProvider {...appPreferences}>
<PrivacyProvider> <PrivacyProvider>
<AppNavbar <AppNavbar
user={{ ...session.user, image: session.user.image ?? null }} user={{ ...session.user, image: session.user.image ?? null }}
payerAvatarUrl={navbarData.payerAvatarUrl} payerAvatarUrl={navbarData.payerAvatarUrl}
inboxPendingCount={navbarData.inboxPendingCount} inboxPendingCount={navbarData.inboxPendingCount}
notificationsSnapshot={navbarData.notificationsSnapshot} notificationsSnapshot={navbarData.notificationsSnapshot}
financeLinks={navbarData.financeLinks}
/> />
<div className="relative flex flex-1 flex-col pt-16"> <div className="relative flex flex-1 flex-col pt-16">
<div className="@container/main flex flex-1 flex-col gap-2"> <div className="@container/main flex flex-1 flex-col gap-2">
@@ -33,6 +40,7 @@ export default async function DashboardLayout({
</div> </div>
</div> </div>
</PrivacyProvider> </PrivacyProvider>
</AppPreferencesProvider>
</LogoDevProvider> </LogoDevProvider>
); );
} }

View File

@@ -87,6 +87,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
dividedFilter: null, dividedFilter: null,
amountMinFilter: null, amountMinFilter: null,
amountMaxFilter: null, amountMaxFilter: null,
dateStartFilter: null,
dateEndFilter: null,
}; };
const createEmptySlugMaps = (): SlugMaps => ({ const createEmptySlugMaps = (): SlugMaps => ({

View File

@@ -0,0 +1,61 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
const installmentCards = ["first", "second", "third"];
export default function Loading() {
return (
<main className="flex flex-col gap-4 pb-8">
<Card className="border-none bg-primary/10 shadow-none">
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
<Skeleton className="h-4 w-64 bg-foreground/10" />
<Skeleton className="h-9 w-36 bg-foreground/10" />
<Skeleton className="h-4 w-32 bg-foreground/10" />
</CardContent>
</Card>
<Skeleton className="h-8 w-36 bg-foreground/10" />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{installmentCards.map((card) => (
<Card key={card} className="overflow-hidden">
<CardHeader className="pb-0">
<div className="flex items-start gap-2">
<Skeleton className="mt-1 size-4 shrink-0 bg-foreground/10" />
<Skeleton className="size-10 shrink-0 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-32 bg-foreground/10" />
<Skeleton className="h-4 w-24 bg-foreground/10" />
</div>
<Skeleton className="h-5 w-16 rounded-full bg-foreground/10" />
</div>
</CardHeader>
<CardContent>
<div className="mb-4 grid grid-cols-2 gap-4 rounded-lg bg-primary/5 p-4">
<div className="space-y-2">
<Skeleton className="h-3 w-24 bg-foreground/10" />
<Skeleton className="h-6 w-20 bg-foreground/10" />
</div>
<div className="flex flex-col items-end gap-2">
<Skeleton className="h-3 w-16 bg-foreground/10" />
<Skeleton className="h-6 w-20 bg-foreground/10" />
</div>
</div>
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-40 bg-foreground/10" />
<Skeleton className="h-3 w-16 bg-foreground/10" />
</div>
<Skeleton className="h-2.5 w-full bg-foreground/10" />
</div>
<Skeleton className="h-8 w-full bg-foreground/10" />
</CardContent>
</Card>
))}
</div>
</main>
);
}

View File

@@ -82,6 +82,9 @@ export default async function Page() {
userPreferences?.transactionsColumnOrder ?? null userPreferences?.transactionsColumnOrder ?? null
} }
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50} attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
showTransactionSummary={
userPreferences?.showTransactionSummary ?? true
}
/> />
</div> </div>
</Card> </Card>

View File

@@ -16,8 +16,7 @@ export default function RootLayout({
icon={<RiArrowLeftRightLine />} icon={<RiArrowLeftRightLine />}
title="Lançamentos" title="Lançamentos"
subtitle="Acompanhe todos os lançamentos financeiros do mês selecionado incluindo subtitle="Acompanhe todos os lançamentos financeiros do mês selecionado incluindo
receitas, despesas e transações previstas. Use o seletor abaixo para receitas, despesas e transações previstas."
navegar pelos meses e visualizar as movimentações correspondentes."
/> />
{children} {children}
</section> </section>

View File

@@ -86,7 +86,7 @@ export async function POST(request: Request) {
const body = await request.json(); const body = await request.json();
const { items } = inboxBatchSchema.parse(body); const { items } = inboxBatchSchema.parse(body);
// Processar todos os itens em paralelo // lançar todos os itens em paralelo
const settled = await Promise.allSettled( const settled = await Promise.allSettled(
items.map((item) => items.map((item) =>
db db
@@ -119,7 +119,7 @@ export async function POST(request: Request) {
return { return {
clientId: item?.clientId, clientId: item?.clientId,
success: false, success: false,
error: "Erro ao processar notificação", error: "Erro ao lançar notificação",
}; };
}); });
@@ -160,7 +160,7 @@ export async function POST(request: Request) {
console.error("[API] Error creating batch inbox items:", error); console.error("[API] Error creating batch inbox items:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erro ao processar notificações" }, { error: "Erro ao lançar notificações" },
{ status: 500 }, { status: 500 },
); );
} }

View File

@@ -127,7 +127,7 @@ export async function POST(request: Request) {
console.error("[API] Error creating inbox item:", error); console.error("[API] Error creating inbox item:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erro ao processar notificação" }, { error: "Erro ao lançar notificação" },
{ status: 500 }, { status: 500 },
); );
} }

View File

@@ -28,7 +28,7 @@
--accent: oklch(94.8% 0.009 65); --accent: oklch(94.8% 0.009 65);
--accent-foreground: var(--foreground); --accent-foreground: var(--foreground);
--success: oklch(61.685% 0.13077 162.978); --success: oklch(63.924% 0.1657 151.561);
--success-foreground: oklch(98% 0.01 150); --success-foreground: oklch(98% 0.01 150);
--warning: oklch(78.357% 0.15147 68.301); --warning: oklch(78.357% 0.15147 68.301);
--warning-foreground: oklch(20% 0.04 85); --warning-foreground: oklch(20% 0.04 85);
@@ -90,7 +90,7 @@
.dark { .dark {
--background: oklch(18% 0.004 55); --background: oklch(18% 0.004 55);
--foreground: oklch(93% 0.008 80); --foreground: #feefe1;
--card: oklch(21.531% 0.00369 48.293); --card: oklch(21.531% 0.00369 48.293);
--card-foreground: var(--foreground); --card-foreground: var(--foreground);
--popover: oklch(24% 0.004 55); --popover: oklch(24% 0.004 55);
@@ -117,7 +117,7 @@
--destructive: oklch(62% 0.2 28); --destructive: oklch(62% 0.2 28);
--destructive-foreground: oklch(98% 0.005 30); --destructive-foreground: oklch(98% 0.005 30);
--border: oklch(31.987% 0.00462 39.069); --border: oklch(24.576% 0.0072 67.399);
--input: var(--border); --input: var(--border);
--ring: var(--primary); --ring: var(--primary);
@@ -170,7 +170,7 @@
} }
@theme inline { @theme inline {
--default-font-family: var(--font-inter); --default-font-family: var(--font-bricolage);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);

View File

@@ -4,7 +4,7 @@ import { QueryProvider } from "@/shared/components/providers/query-provider";
import { ThemeProvider } from "@/shared/components/providers/theme-provider"; import { ThemeProvider } from "@/shared/components/providers/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner"; import { Toaster } from "@/shared/components/ui/sonner";
import "./globals.css"; import "./globals.css";
import { inter } from "@/public/fonts/font_index"; import { bricolage } from "@/public/fonts/font_index";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -24,7 +24,7 @@ export default function RootLayout({
<html <html
data-scroll-behavior="smooth" data-scroll-behavior="smooth"
lang="pt-BR" lang="pt-BR"
className={`${inter.variable}`} className={`${bricolage.className}`}
suppressHydrationWarning suppressHydrationWarning
> >
<head> <head>

View File

@@ -154,6 +154,9 @@ export const userPreferences = pgTable("preferencias_usuario", {
string[] | null string[] | null
>(), >(),
attachmentMaxSizeMb: integer("attachment_max_size_mb").notNull().default(50), attachmentMaxSizeMb: integer("attachment_max_size_mb").notNull().default(50),
showTransactionSummary: boolean("mostrar_resumo_lancamento")
.notNull()
.default(true),
dashboardWidgets: jsonb("dashboard_widgets").$type<{ dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[]; order: string[];
hidden: string[]; hidden: string[];
@@ -495,7 +498,7 @@ export const inboxItems = pgTable(
withTimezone: true, withTimezone: true,
}).notNull(), }).notNull(),
// Dados parseados (editáveis pelo usuário antes de processar) // Dados parseados (editáveis pelo usuário antes de lançar)
parsedName: text("parsed_name"), // Nome do estabelecimento parsedName: text("parsed_name"), // Nome do estabelecimento
parsedAmount: numeric("parsed_amount", { precision: 12, scale: 2 }), parsedAmount: numeric("parsed_amount", { precision: 12, scale: 2 }),

View File

@@ -32,9 +32,20 @@ import {
formatCurrency, formatCurrency,
formatDecimalForDbRequired, formatDecimalForDbRequired,
} from "@/shared/utils/currency"; } from "@/shared/utils/currency";
import { getBusinessTodayDate, getTodayInfo } from "@/shared/utils/date"; import {
getBusinessTodayDate,
getTodayInfo,
parseLocalDateString,
} from "@/shared/utils/date";
import { derivePeriodFromDate } from "@/shared/utils/period";
import { normalizeFilePath } from "@/shared/utils/string"; import { normalizeFilePath } from "@/shared/utils/string";
const ACCOUNT_YIELD_CATEGORY_NAME = "Rendimentos";
const ACCOUNT_YIELD_CATEGORY_ICON = "RiFundsLine";
const ACCOUNT_YIELD_TRANSACTION_NAME = "Rendimento";
const ACCOUNT_YIELD_CONDITION = INITIAL_BALANCE_CONDITION;
const ACCOUNT_YIELD_PAYMENT_METHOD = "Transferência bancária" as const;
const accountBaseSchema = z.object({ const accountBaseSchema = z.object({
name: z name: z
.string({ message: "Informe o nome da conta." }) .string({ message: "Informe o nome da conta." })
@@ -408,6 +419,107 @@ const adjustAccountBalanceSchema = z.object({
type AdjustAccountBalanceInput = z.infer<typeof adjustAccountBalanceSchema>; type AdjustAccountBalanceInput = z.infer<typeof adjustAccountBalanceSchema>;
const addAccountYieldSchema = z.object({
accountId: uuidSchema("FinancialAccount"),
amount: z
.number({ message: "Valor inválido." })
.positive("Informe um valor maior que zero."),
date: z
.string({ message: "Data inválida." })
.trim()
.regex(/^\d{4}-\d{2}-\d{2}$/u, "Data inválida."),
});
type AddAccountYieldInput = z.infer<typeof addAccountYieldSchema>;
export async function addAccountYieldAction(
input: AddAccountYieldInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = addAccountYieldSchema.parse(input);
const adminPayerId = await getAdminPayerId(user.id);
if (!adminPayerId) {
throw new Error(
"Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de adicionar rendimentos.",
);
}
const purchaseDate = parseLocalDateString(data.date);
if (Number.isNaN(purchaseDate.getTime())) {
throw new Error("Data inválida.");
}
await db.transaction(async (tx: typeof db) => {
const account = await tx.query.financialAccounts.findFirst({
columns: { id: true },
where: and(
eq(financialAccounts.id, data.accountId),
eq(financialAccounts.userId, user.id),
),
});
if (!account) {
throw new Error("Conta não encontrada.");
}
const existingCategory = await tx.query.categories.findFirst({
columns: { id: true },
where: and(
eq(categories.userId, user.id),
eq(categories.type, "receita"),
eq(categories.name, ACCOUNT_YIELD_CATEGORY_NAME),
),
});
const category =
existingCategory ??
(
await tx
.insert(categories)
.values({
name: ACCOUNT_YIELD_CATEGORY_NAME,
type: "receita",
icon: ACCOUNT_YIELD_CATEGORY_ICON,
userId: user.id,
})
.returning({ id: categories.id })
)[0];
if (!category) {
throw new Error(
"Não foi possível preparar a categoria de rendimentos.",
);
}
await tx.insert(transactions).values({
condition: ACCOUNT_YIELD_CONDITION,
name: ACCOUNT_YIELD_TRANSACTION_NAME,
paymentMethod: ACCOUNT_YIELD_PAYMENT_METHOD,
note: null,
amount: formatDecimalForDbRequired(data.amount),
purchaseDate,
transactionType: "Receita" as const,
period: derivePeriodFromDate(data.date),
isSettled: true,
userId: user.id,
accountId: data.accountId,
cardId: null,
categoryId: category.id,
payerId: adminPayerId,
});
});
revalidateForEntity("accounts", user.id);
revalidateForEntity("transactions", user.id);
return { success: true, message: "Rendimento adicionado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function adjustAccountBalanceAction( export async function adjustAccountBalanceAction(
input: AdjustAccountBalanceInput, input: AdjustAccountBalanceInput,
): Promise<ActionResult> { ): Promise<ActionResult> {

View File

@@ -23,7 +23,7 @@ import {
} from "@/shared/components/ui/dialog"; } from "@/shared/components/ui/dialog";
import { useControlledState } from "@/shared/hooks/use-controlled-state"; import { useControlledState } from "@/shared/hooks/use-controlled-state";
import { useFormState } from "@/shared/hooks/use-form-state"; import { useFormState } from "@/shared/hooks/use-form-state";
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo"; import { getLogoDisplayName, normalizeLogo } from "@/shared/lib/logo";
import { import {
formatInitialBalanceInput, formatInitialBalanceInput,
normalizeDecimalInput, normalizeDecimalInput,
@@ -66,7 +66,7 @@ const buildInitialValues = ({
}): AccountFormValues => { }): AccountFormValues => {
const fallbackLogo = logoOptions[0] ?? ""; const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo; const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo); const derivedName = getLogoDisplayName(selectedLogo);
return { return {
name: account?.name ?? derivedName, name: account?.name ?? derivedName,

View File

@@ -82,7 +82,7 @@ export function AccountStatementCard({
</div> </div>
{/* Linha 2 — saldo final (hero) */} {/* Linha 2 — saldo final (hero) */}
<div className="space-y-4"> <div className="space-y-3">
<p className="text-sm text-muted-foreground "> <p className="text-sm text-muted-foreground ">
Saldo ao final do período Saldo ao final do período
</p> </p>

View File

@@ -0,0 +1,155 @@
"use client";
import { RiCalculatorLine, RiFundsLine } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import { addAccountYieldAction } from "@/features/accounts/actions";
import { CalculatorDialogButton } from "@/shared/components/calculator/calculator-dialog";
import { Button } from "@/shared/components/ui/button";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import { DatePicker } from "@/shared/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
type AddYieldDialogProps = {
accountId: string;
defaultDate: string;
};
export function AddYieldDialog({
accountId,
defaultDate,
}: AddYieldDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [amount, setAmount] = useState("");
const [date, setDate] = useState(defaultDate);
useEffect(() => {
if (open) {
setAmount("");
setDate(defaultDate);
}
}, [open, defaultDate]);
const handleSave = () => {
const numericAmount = Number(amount);
if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
toast.error("Informe um valor maior que zero.");
return;
}
if (!date) {
toast.error("Informe a data do rendimento.");
return;
}
startTransition(async () => {
const result = await addAccountYieldAction({
accountId,
amount: numericAmount,
date,
});
if (result.success) {
toast.success(result.message);
setOpen(false);
router.refresh();
return;
}
toast.error(result.error);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-primary hover:text-primary"
aria-label="Adicionar rendimento"
>
<RiFundsLine className="size-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Adicionar rendimento</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Adicionar rendimento</DialogTitle>
<DialogDescription>
Registre um rendimento como receita paga nesta conta.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="yield-amount">Valor</Label>
<div className="relative">
<CurrencyInput
id="yield-amount"
value={amount}
onValueChange={setAmount}
autoFocus
className="pr-10"
placeholder="R$ 0,00"
/>
<CalculatorDialogButton
variant="ghost"
size="icon-sm"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
onSelectValue={setAmount}
>
<RiCalculatorLine className="h-4 w-4 text-muted-foreground" />
</CalculatorDialogButton>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="yield-date">Data</Label>
<DatePicker
id="yield-date"
value={date}
onChange={setDate}
placeholder="Data"
required
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="button" onClick={handleSave} disabled={isPending}>
{isPending ? "Salvando..." : "Adicionar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -17,6 +17,11 @@ import {
DialogTrigger, DialogTrigger,
} from "@/shared/components/ui/dialog"; } from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { formatCurrency } from "@/shared/utils/currency"; import { formatCurrency } from "@/shared/utils/currency";
type AdjustBalanceDialogProps = { type AdjustBalanceDialogProps = {
@@ -79,17 +84,22 @@ export function AdjustBalanceDialog({
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-muted-foreground hover:text-foreground" className="text-primary hover:text-primary"
aria-label="Ajustar saldo" aria-label="Ajustar saldo"
> >
<RiEqualizerLine className="size-4" /> <RiEqualizerLine className="size-4" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
</TooltipTrigger>
<TooltipContent>Ajustar saldo</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Ajustar saldo</DialogTitle> <DialogTitle>Ajustar saldo</DialogTitle>

View File

@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import { type FormEvent, useEffect, useState } from "react"; import { type FormEvent, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import { import {
Field, Field,
FieldDescription, FieldDescription,
@@ -38,6 +39,7 @@ export function LoginForm({
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false); const [loadingEmail, setLoadingEmail] = useState(false);
@@ -60,7 +62,7 @@ export function LoginForm({
email, email,
password, password,
callbackURL: "/dashboard", callbackURL: "/dashboard",
rememberMe: false, rememberMe,
}, },
{ {
onRequest: () => { onRequest: () => {
@@ -186,6 +188,24 @@ export function LoginForm({
/> />
</Field> </Field>
<div className="flex items-start gap-3">
<Checkbox
id="remember-me"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked === true)}
disabled={loadingEmail || loadingGoogle || loadingPasskey}
className="mt-0.5"
/>
<div className="grid gap-1">
<FieldLabel
htmlFor="remember-me"
className="cursor-pointer font-medium"
>
Manter conectado neste dispositivo
</FieldLabel>
</div>
</div>
<Field> <Field>
<Button <Button
type="submit" type="submit"

View File

@@ -81,6 +81,8 @@ const renderLancamento = (
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => { const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
const isPaid = Boolean(event.transaction.isSettled); const isPaid = Boolean(event.transaction.isSettled);
const isIncome = event.transaction.transactionType === "Receita";
const settlementLabel = isIncome ? "Recebido" : "Pago";
const dueDateLabel = formatFinancialDateLabel( const dueDateLabel = formatFinancialDateLabel(
event.transaction.dueDate, event.transaction.dueDate,
"Vence em", "Vence em",
@@ -89,7 +91,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
const paymentDateLabel = isPaid const paymentDateLabel = isPaid
? formatFinancialDateLabel( ? formatFinancialDateLabel(
event.transaction.boletoPaymentDate, event.transaction.boletoPaymentDate,
"Pago em", `${settlementLabel} em`,
DATE_FORMAT, DATE_FORMAT,
) )
: null; : null;
@@ -109,7 +111,9 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
<span className="text-success">{paymentDateLabel}</span> <span className="text-success">{paymentDateLabel}</span>
)} )}
</div> </div>
<Badge variant="outline">{isPaid ? "Pago" : "Pendente"}</Badge> <Badge variant="outline">
{isPaid ? settlementLabel : "Pendente"}
</Badge>
</div> </div>
<MoneyValues <MoneyValues
className="font-medium whitespace-nowrap" className="font-medium whitespace-nowrap"

View File

@@ -24,7 +24,7 @@ import {
DEFAULT_CARD_BRANDS, DEFAULT_CARD_BRANDS,
DEFAULT_CARD_STATUS, DEFAULT_CARD_STATUS,
} from "@/shared/lib/cards/constants"; } from "@/shared/lib/cards/constants";
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo"; import { getLogoDisplayName, normalizeLogo } from "@/shared/lib/logo";
import { import {
formatLimitInput, formatLimitInput,
normalizeDecimalInput, normalizeDecimalInput,
@@ -59,7 +59,7 @@ const buildInitialValues = ({
}): CardFormValues => { }): CardFormValues => {
const fallbackLogo = logoOptions[0] ?? ""; const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(card?.logo) || fallbackLogo; const selectedLogo = normalizeLogo(card?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo); const derivedName = getLogoDisplayName(selectedLogo);
return { return {
name: card?.name ?? derivedName, name: card?.name ?? derivedName,

View File

@@ -5,6 +5,7 @@ import { Card } from "@/shared/components/ui/card";
import type { CategoryType } from "@/shared/lib/categories/constants"; import type { CategoryType } from "@/shared/lib/categories/constants";
import { currencyFormatter } from "@/shared/utils/currency"; import { currencyFormatter } from "@/shared/utils/currency";
import { formatPercentage } from "@/shared/utils/percentage"; import { formatPercentage } from "@/shared/utils/percentage";
import { cn } from "@/shared/utils/ui";
type CategorySummary = { type CategorySummary = {
id: string; id: string;
@@ -32,19 +33,40 @@ export function CategoryDetailHeader({
percentageChange, percentageChange,
transactionCount, transactionCount,
}: CategoryDetailHeaderProps) { }: CategoryDetailHeaderProps) {
const absoluteChange = currentTotal - previousTotal;
const variationLabel = const variationLabel =
typeof percentageChange === "number" typeof percentageChange === "number"
? formatPercentage(percentageChange, { ? formatPercentage(percentageChange, {
minimumFractionDigits: 1, minimumFractionDigits: 1,
maximumFractionDigits: 1, maximumFractionDigits: 1,
absolute: true, absolute: true,
signDisplay: percentageChange === 0 ? "auto" : "always",
}) })
: "—"; : "—";
const hasComparison = typeof percentageChange === "number";
const isFlat = absoluteChange === 0;
const changeDirection =
absoluteChange > 0 ? "increase" : absoluteChange < 0 ? "decrease" : "flat";
const comparisonTone =
isFlat || !hasComparison
? "neutral"
: category.type === "receita"
? changeDirection === "increase"
? "positive"
: "negative"
: changeDirection === "decrease"
? "positive"
: "negative";
const statusLabel = !hasComparison
? "Sem comparação"
: isFlat
? "Estável"
: changeDirection === "increase"
? "Aumento"
: "Queda";
return ( return (
<Card className="px-4"> <Card className="px-5 py-5">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-5">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<CategoryIconBadge <CategoryIconBadge
icon={category.icon} icon={category.icon}
@@ -59,44 +81,62 @@ export function CategoryDetailHeader({
<TransactionTypeBadge kind={category.type} /> <TransactionTypeBadge kind={category.type} />
<span> <span>
{transactionCount}{" "} {transactionCount}{" "}
{transactionCount === 1 ? "lançamento" : "lançamentos"} no{" "} {transactionCount === 1 ? "lançamento" : "lançamentos"} em{" "}
período {currentPeriodLabel}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div className="grid w-full gap-4 sm:grid-cols-2 lg:w-auto lg:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<div> <div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {currentPeriodLabel} Total em {currentPeriodLabel}
</p> </p>
<p className="mt-1 text-2xl font-semibold"> <p className="mt-1 text-3xl font-semibold tracking-tight">
{currencyFormatter.format(currentTotal)} {currencyFormatter.format(currentTotal)}
</p> </p>
</div> </div>
<div>
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {previousPeriodLabel} Total em {previousPeriodLabel}
</p> </p>
<p className="mt-1 text-lg font-semibold text-muted-foreground"> <p className="mt-1 text-2xl font-semibold tracking-tight text-muted-foreground">
{currencyFormatter.format(previousTotal)} {currencyFormatter.format(previousTotal)}
</p> </p>
</div> </div>
<div>
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Variação vs mês anterior Variação
</p> </p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
"inline-flex h-6 items-center rounded-sm border px-2 text-xs font-medium",
comparisonTone === "positive" &&
"border-success/30 bg-success/5 text-success",
comparisonTone === "negative" &&
"border-destructive/30 bg-destructive/5 text-destructive",
comparisonTone === "neutral" &&
"border-muted-foreground/30 bg-muted/30 text-muted-foreground",
)}
>
{statusLabel}
</span>
<PercentageChangeIndicator <PercentageChangeIndicator
value={percentageChange} value={percentageChange}
label={variationLabel} label={variationLabel}
positiveTrend={category.type === "receita" ? "up" : "down"} positiveTrend={category.type === "receita" ? "up" : "down"}
className="mt-1 gap-1 text-lg font-semibold" className="gap-1 text-lg font-semibold"
iconClassName="size-4" iconClassName="size-4"
showFlatIcon
/> />
</div> </div>
</div> </div>
</div> </div>
</div>
</Card> </Card>
); );
} }

View File

@@ -1,18 +1,28 @@
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries"; import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import type { PaymentDialogState } from "@/features/dashboard/payments/use-payment-dialog-controller"; import type { PaymentDialogState } from "@/features/dashboard/payments/use-payment-dialog-controller";
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date"; import {
getBusinessDateString,
isDateOnlyPast,
parseUtcDateString,
toDateOnlyString,
} from "@/shared/utils/date";
import { import {
buildFinancialStatusLabel, buildFinancialStatusLabel,
buildRelativeFinancialStatusLabel, buildRelativeFinancialStatusLabel,
formatFinancialDateLabel, formatFinancialDateLabel,
formatRelativeFinancialDateLabel,
} from "@/shared/utils/financial-dates"; } from "@/shared/utils/financial-dates";
export type BillDialogState = PaymentDialogState; export type BillDialogState = PaymentDialogState;
type BillStatusDateItem = Pick< type BillStatusDateItem = Pick<
DashboardBill, DashboardBill,
"dueDate" | "boletoPaymentDate" | "isSettled" "dueDate" | "boletoPaymentDate" | "isSettled" | "transactionType"
>; >;
export const isIncomeBill = (bill: Pick<DashboardBill, "transactionType">) => {
return bill.transactionType === "Receita";
};
export const formatBillDateLabel = (value: string | null, prefix?: string) => { export const formatBillDateLabel = (value: string | null, prefix?: string) => {
return formatFinancialDateLabel(value, prefix); return formatFinancialDateLabel(value, prefix);
}; };
@@ -22,10 +32,15 @@ export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
isSettled: bill.isSettled, isSettled: bill.isSettled,
dueDate: bill.dueDate, dueDate: bill.dueDate,
paidAt: bill.boletoPaymentDate, paidAt: bill.boletoPaymentDate,
paidPrefix: isIncomeBill(bill) ? "Recebido em" : "Pago em",
}); });
}; };
export const buildBillWidgetStatusLabel = (bill: BillStatusDateItem) => { export const buildBillWidgetStatusLabel = (bill: BillStatusDateItem) => {
if (bill.isSettled && isIncomeBill(bill)) {
return formatRelativeFinancialDateLabel(bill.boletoPaymentDate, "received");
}
return buildRelativeFinancialStatusLabel({ return buildRelativeFinancialStatusLabel({
isSettled: bill.isSettled, isSettled: bill.isSettled,
dueDate: bill.dueDate, dueDate: bill.dueDate,
@@ -43,6 +58,34 @@ export const isBillOverdue = (bill: DashboardBill) => {
return isDateOnlyPast(bill.dueDate); return isDateOnlyPast(bill.dueDate);
}; };
export const formatBillWidgetOverdueLabel = (
bill: Pick<DashboardBill, "dueDate" | "isSettled" | "transactionType">,
): string | null => {
if (bill.isSettled) {
return null;
}
const dueDateValue = toDateOnlyString(bill.dueDate);
const todayValue = getBusinessDateString();
if (!dueDateValue || dueDateValue >= todayValue) {
return null;
}
const dueDate = parseUtcDateString(dueDateValue);
const today = parseUtcDateString(todayValue);
if (!dueDate || !today) {
return null;
}
const overdueDays = Math.round(
(today.getTime() - dueDate.getTime()) / (24 * 60 * 60 * 1000),
);
const overdueLabel = isIncomeBill(bill) ? "Atrasada" : "Atrasado";
return overdueDays === 1
? `${overdueLabel} · venceu ontem`
: `${overdueLabel} · venceu há ${overdueDays} dias`;
};
export const getBillStatusBadgeVariant = ( export const getBillStatusBadgeVariant = (
statusLabel: string, statusLabel: string,
): "success" | "info" => { ): "success" | "info" => {

View File

@@ -6,6 +6,7 @@ export type DashboardBill = {
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean; isSettled: boolean;
accountId: string | null; accountId: string | null;
transactionType: string;
}; };
export type BillPaymentAccountOption = { export type BillPaymentAccountOption = {

View File

@@ -1,9 +1,11 @@
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react"; import { RiCheckboxCircleFill } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import { import {
buildBillStatusLabel, buildBillStatusLabel,
buildBillWidgetStatusLabel, buildBillWidgetStatusLabel,
formatBillWidgetOverdueLabel,
isBillOverdue, isBillOverdue,
isIncomeBill,
} from "@/features/dashboard/bills/bills-helpers"; } from "@/features/dashboard/bills/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries"; import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
@@ -36,8 +38,10 @@ export function BillListItem({ bill, period, onPay }: BillListItemProps) {
const statusLabel = buildBillWidgetStatusLabel(bill); const statusLabel = buildBillWidgetStatusLabel(bill);
const absoluteStatusLabel = buildBillStatusLabel(bill); const absoluteStatusLabel = buildBillStatusLabel(bill);
const overdue = isBillOverdue(bill); const overdue = isBillOverdue(bill);
const income = isIncomeBill(bill);
const overdueLabel = formatBillWidgetOverdueLabel(bill);
const statusTooltipLabel = const statusTooltipLabel =
statusLabel && statusLabel !== absoluteStatusLabel overdueLabel || (statusLabel && statusLabel !== absoluteStatusLabel)
? absoluteStatusLabel ? absoluteStatusLabel
: null; : null;
const href = buildTransactionsHref(bill.name, period); const href = buildTransactionsHref(bill.name, period);
@@ -53,10 +57,6 @@ export function BillListItem({ bill, period, onPay }: BillListItemProps) {
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline" className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
> >
<span className="truncate">{bill.name}</span> <span className="truncate">{bill.name}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link> </Link>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{statusLabel ? ( {statusLabel ? (
@@ -67,9 +67,10 @@ export function BillListItem({ bill, period, onPay }: BillListItemProps) {
className={cn( className={cn(
"cursor-help rounded-full py-0.5", "cursor-help rounded-full py-0.5",
bill.isSettled && "text-success font-semibold", bill.isSettled && "text-success font-semibold",
overdue && "text-destructive font-semibold",
)} )}
> >
{statusLabel} {overdueLabel ?? statusLabel}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
@@ -81,9 +82,10 @@ export function BillListItem({ bill, period, onPay }: BillListItemProps) {
className={cn( className={cn(
"rounded-full py-0.5", "rounded-full py-0.5",
bill.isSettled && "text-success font-semibold", bill.isSettled && "text-success font-semibold",
overdue && "text-destructive font-semibold",
)} )}
> >
{statusLabel} {overdueLabel ?? statusLabel}
</span> </span>
) )
) : null} ) : null}
@@ -93,29 +95,35 @@ export function BillListItem({ bill, period, onPay }: BillListItemProps) {
<div className="flex shrink-0 flex-col items-end"> <div className="flex shrink-0 flex-col items-end">
<MoneyValues className="font-medium" amount={bill.amount} /> <MoneyValues className="font-medium" amount={bill.amount} />
{bill.isSettled ? (
<span className="flex h-7 items-center gap-0.5 text-xs font-medium text-success">
<RiCheckboxCircleFill className="size-3.5" />{" "}
{income ? "Recebido" : "Pago"}
</span>
) : (
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="link" variant="link"
className="h-auto p-0 disabled:opacity-100" className="-mr-1.5 h-7 px-1.5 py-0"
disabled={bill.isSettled}
onClick={() => onPay(bill.id)} onClick={() => onPay(bill.id)}
> >
{bill.isSettled ? ( {overdue ? (
<span className="flex items-center gap-0.5 text-success">
<RiCheckboxCircleFill className="size-3.5" /> Pago
</span>
) : overdue ? (
<span className="overdue-blink"> <span className="overdue-blink">
<span className="overdue-blink-primary text-destructive"> <span className="overdue-blink-primary text-destructive">
Atrasado {income ? "Atrasada" : "Atrasado"}
</span> </span>
<span className="overdue-blink-secondary">Pagar</span> <span className="overdue-blink-secondary">
{income ? "Receber" : "Pagar"}
</span> </span>
</span>
) : income ? (
"Receber"
) : ( ) : (
"Pagar" "Pagar"
)} )}
</Button> </Button>
)}
</div> </div>
</li> </li>
); );

View File

@@ -7,6 +7,7 @@ import {
type BillDialogState, type BillDialogState,
formatBillDateLabel, formatBillDateLabel,
getBillStatusBadgeVariant, getBillStatusBadgeVariant,
isIncomeBill,
} from "@/features/dashboard/bills/bills-helpers"; } from "@/features/dashboard/bills/bills-helpers";
import type { import type {
BillPaymentAccountOption, BillPaymentAccountOption,
@@ -66,11 +67,13 @@ export function BillPaymentDialog({
onConfirm, onConfirm,
}: BillPaymentDialogProps) { }: BillPaymentDialogProps) {
const isProcessing = modalState === "processing" || isPending; const isProcessing = modalState === "processing" || isPending;
const income = bill ? isIncomeBill(bill) : false;
const settlementLabel = income ? "Recebido" : "Pago";
const dueLabel = bill const dueLabel = bill
? formatBillDateLabel(bill.dueDate, "Vencimento:") ? formatBillDateLabel(bill.dueDate, "Vencimento:")
: null; : null;
const paidLabel = bill const paidLabel = bill
? formatBillDateLabel(bill.boletoPaymentDate, "Pago em:") ? formatBillDateLabel(bill.boletoPaymentDate, `${settlementLabel} em:`)
: null; : null;
const isBillPending = bill ? !bill.isSettled : false; const isBillPending = bill ? !bill.isSettled : false;
const paymentDateValue = paymentDate.toISOString().split("T")[0] ?? ""; const paymentDateValue = paymentDate.toISOString().split("T")[0] ?? "";
@@ -103,8 +106,8 @@ export function BillPaymentDialog({
> >
{modalState === "success" ? ( {modalState === "success" ? (
<PaymentSuccess <PaymentSuccess
title="Pagamento registrado!" title={income ? "Recebimento registrado!" : "Pagamento registrado!"}
description="Atualizamos o status do boleto para pago. Em instantes ele aparecerá como baixado no histórico." description={`Atualizamos o status do boleto para ${income ? "recebido" : "pago"}. Em instantes ele aparecerá como baixado no histórico.`}
onClose={onClose} onClose={onClose}
/> />
) : ( ) : (
@@ -112,10 +115,12 @@ export function BillPaymentDialog({
<DialogHeader> <DialogHeader>
<div className="mb-1 flex items-center gap-3"> <div className="mb-1 flex items-center gap-3">
<div> <div>
<DialogTitle>Confirmar pagamento</DialogTitle> <DialogTitle>
{income ? "Confirmar recebimento" : "Confirmar pagamento"}
</DialogTitle>
<DialogDescription className="mt-1 text-xs"> <DialogDescription className="mt-1 text-xs">
{isBillPending {isBillPending
? "Escolha a conta de origem e a data em que o boleto foi pago." ? `Escolha a conta de ${income ? "destino" : "origem"} e a data em que o boleto foi ${income ? "recebido" : "pago"}.`
: "Boleto"} : "Boleto"}
</DialogDescription> </DialogDescription>
</div> </div>
@@ -158,12 +163,15 @@ export function BillPaymentDialog({
<div className="flex items-center gap-1.5 text-muted-foreground"> <div className="flex items-center gap-1.5 text-muted-foreground">
<RiCalendarLine className="size-3.5" /> <RiCalendarLine className="size-3.5" />
<span className="text-xs font-medium uppercase"> <span className="text-xs font-medium uppercase">
{bill.isSettled ? "Pago em" : "Vencimento"} {bill.isSettled
? `${settlementLabel} em`
: "Vencimento"}
</span> </span>
</div> </div>
<p className="font-semibold"> <p className="font-semibold">
{bill.isSettled {bill.isSettled
? (paidLabel?.replace("Pago em: ", "") ?? "—") ? (paidLabel?.replace(`${settlementLabel} em: `, "") ??
"—")
: (dueLabel?.replace("Vencimento: ", "") ?? "—")} : (dueLabel?.replace("Vencimento: ", "") ?? "—")}
</p> </p>
</Card> </Card>
@@ -175,7 +183,7 @@ export function BillPaymentDialog({
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="bill-widget-payment-account"> <Label htmlFor="bill-widget-payment-account">
Conta de pagamento Conta de {income ? "recebimento" : "pagamento"}
</Label> </Label>
<Select <Select
value={paymentAccountId} value={paymentAccountId}
@@ -212,7 +220,7 @@ export function BillPaymentDialog({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="bill-widget-payment-date"> <Label htmlFor="bill-widget-payment-date">
Data do pagamento Data do {income ? "recebimento" : "pagamento"}
</Label> </Label>
<DatePicker <DatePicker
id="bill-widget-payment-date" id="bill-widget-payment-date"
@@ -231,8 +239,8 @@ export function BillPaymentDialog({
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Status atual Status atual
</span> </span>
<Badge variant={getBillStatusBadgeVariant("Pago")}> <Badge variant={getBillStatusBadgeVariant(settlementLabel)}>
Pago {settlementLabel}
</Badge> </Badge>
</div> </div>
)} )}

View File

@@ -15,7 +15,7 @@ export function BillsList({ bills, period, onPay }: BillsListProps) {
<WidgetEmptyState <WidgetEmptyState
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />} icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
title="Nenhum boleto cadastrado para o período selecionado" title="Nenhum boleto cadastrado para o período selecionado"
description="Cadastre boletos para monitorar os pagamentos aqui." description="Cadastre boletos para monitorar os vencimentos aqui."
/> />
); );
} }

View File

@@ -41,9 +41,7 @@ export function BillsWidgetView({
}: BillsWidgetViewProps) { }: BillsWidgetViewProps) {
return ( return (
<> <>
<div className="flex flex-col gap-4">
<BillsList bills={bills} period={period} onPay={onOpenPaymentDialog} /> <BillsList bills={bills} period={period} onPay={onOpenPaymentDialog} />
</div>
<BillPaymentDialog <BillPaymentDialog
bill={selectedBill} bill={selectedBill}

View File

@@ -96,7 +96,7 @@ export function CategoryBreakdownChart({
}, [categories, chartConfig]); }, [categories, chartConfig]);
return ( return (
<div className="flex items-center gap-4"> <div className="flex flex-col items-center gap-4 sm:flex-row">
<ChartContainer config={chartConfig} className="h-[280px] flex-1"> <ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart> <PieChart>
<Pie <Pie
@@ -143,7 +143,7 @@ export function CategoryBreakdownChart({
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
<div className="min-w-[140px] flex flex-col gap-2"> <div className="grid w-full grid-cols-2 gap-2 sm:min-w-[140px] sm:w-auto sm:grid-cols-1">
{chartData.map((entry, index) => ( {chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2"> <div key={`legend-${index}`} className="flex items-center gap-2">
<div <div

View File

@@ -1,4 +1,3 @@
import { RiExternalLinkLine, RiWallet3Line } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers"; import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
@@ -11,13 +10,14 @@ type CategoryBreakdownListItemConfig = {
shareLabel: string; shareLabel: string;
percentageDigits: number; percentageDigits: number;
positiveTrend: "up" | "down"; positiveTrend: "up" | "down";
includeBudgetAmount: boolean; showBudget: boolean;
}; };
type CategoryBreakdownListItemProps = { type CategoryBreakdownListItemProps = {
category: DashboardCategoryBreakdownItem; category: DashboardCategoryBreakdownItem;
periodParam: string; periodParam: string;
config: CategoryBreakdownListItemConfig; config: CategoryBreakdownListItemConfig;
position: number;
}; };
const formatPercentage = (value: number, digits: number) => const formatPercentage = (value: number, digits: number) =>
@@ -31,8 +31,9 @@ export function CategoryBreakdownListItem({
category, category,
periodParam, periodParam,
config, config,
position,
}: CategoryBreakdownListItemProps) { }: CategoryBreakdownListItemProps) {
const hasBudget = category.budgetAmount !== null; const hasBudget = config.showBudget && category.budgetAmount !== null;
const budgetExceeded = const budgetExceeded =
hasBudget && hasBudget &&
category.budgetUsedPercentage !== null && category.budgetUsedPercentage !== null &&
@@ -44,7 +45,10 @@ export function CategoryBreakdownListItem({
return ( return (
<div> <div>
<div className="flex items-center justify-between gap-3 transition-all duration-300 py-2"> <div className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5">
<span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{position}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge <CategoryIconBadge
icon={category.categoryIcon} icon={category.categoryIcon}
@@ -54,16 +58,12 @@ export function CategoryBreakdownListItem({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`} href={`/categories/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline" className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
> >
<span className="truncate">{category.categoryName}</span> <span className="truncate">{category.categoryName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link> </Link>
</div> </div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-1 text-xs text-muted-foreground">
<span> <span>
{formatPercentage( {formatPercentage(
category.percentageOfTotal, category.percentageOfTotal,
@@ -71,16 +71,14 @@ export function CategoryBreakdownListItem({
)}{" "} )}{" "}
da {config.shareLabel} da {config.shareLabel}
</span> </span>
</div>
{hasBudget && category.budgetUsedPercentage !== null ? ( {hasBudget && category.budgetUsedPercentage !== null ? (
<> <div
<span aria-hidden>·</span> className={`mt-0.5 text-xs ${budgetExceeded ? "text-destructive" : "text-info"}`}
<span
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
> >
<RiWallet3Line className="size-3 shrink-0" />
{budgetExceeded ? ( {budgetExceeded ? (
<> <>
excedeu{" "} Limite excedido em{" "}
<span className="font-medium"> <span className="font-medium">
{formatCurrency(exceededAmount)} {formatCurrency(exceededAmount)}
</span> </span>
@@ -91,17 +89,11 @@ export function CategoryBreakdownListItem({
category.budgetUsedPercentage, category.budgetUsedPercentage,
config.percentageDigits, config.percentageDigits,
)}{" "} )}{" "}
do limite do limite utilizado
{config.includeBudgetAmount &&
category.budgetAmount !== null
? ` ${formatCurrency(category.budgetAmount)}`
: ""}
</> </>
)} )}
</span>
</>
) : null}
</div> </div>
) : null}
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ type CategoryBreakdownListConfig = {
shareLabel: string; shareLabel: string;
percentageDigits: number; percentageDigits: number;
positiveTrend: "up" | "down"; positiveTrend: "up" | "down";
includeBudgetAmount: boolean; showBudget: boolean;
}; };
type CategoryBreakdownListProps = { type CategoryBreakdownListProps = {
@@ -20,13 +20,14 @@ export function CategoryBreakdownList({
config, config,
}: CategoryBreakdownListProps) { }: CategoryBreakdownListProps) {
return ( return (
<div> <div className="flex flex-col">
{categories.map((category) => ( {categories.map((category, index) => (
<CategoryBreakdownListItem <CategoryBreakdownListItem
key={category.categoryId} key={category.categoryId}
category={category} category={category}
periodParam={periodParam} periodParam={periodParam}
config={config} config={config}
position={index + 1}
/> />
))} ))}
</div> </div>

View File

@@ -34,7 +34,7 @@ const VARIANT_CONFIG = {
shareLabel: "receita total", shareLabel: "receita total",
percentageDigits: 1, percentageDigits: 1,
positiveTrend: "up", positiveTrend: "up",
includeBudgetAmount: true, showBudget: false,
}, },
expense: { expense: {
emptyTitle: "Nenhuma despesa encontrada", emptyTitle: "Nenhuma despesa encontrada",
@@ -43,7 +43,7 @@ const VARIANT_CONFIG = {
shareLabel: "despesa total", shareLabel: "despesa total",
percentageDigits: 0, percentageDigits: 0,
positiveTrend: "down", positiveTrend: "down",
includeBudgetAmount: false, showBudget: true,
}, },
} as const; } as const;

View File

@@ -21,6 +21,7 @@ import {
RiCloseLine, RiCloseLine,
RiDragMove2Line, RiDragMove2Line,
RiEyeOffLine, RiEyeOffLine,
RiSettings4Line,
RiTodoLine, RiTodoLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
@@ -41,6 +42,12 @@ import {
import { NoteDialog } from "@/features/notes/components/note-dialog"; import { NoteDialog } from "@/features/notes/components/note-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog"; import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { ExpandableWidgetCard } from "@/shared/components/widgets/expandable-widget-card"; import { ExpandableWidgetCard } from "@/shared/components/widgets/expandable-widget-card";
type DashboardGridEditableProps = { type DashboardGridEditableProps = {
@@ -60,6 +67,9 @@ export function DashboardGridEditable({
}: DashboardGridEditableProps) { }: DashboardGridEditableProps) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isMobileIncomeOpen, setIsMobileIncomeOpen] = useState(false);
const [isMobileExpenseOpen, setIsMobileExpenseOpen] = useState(false);
const [isMobileNoteOpen, setIsMobileNoteOpen] = useState(false);
// Initialize widget order and hidden state // Initialize widget order and hidden state
const [widgetOrder, setWidgetOrder] = useState<string[]>( const [widgetOrder, setWidgetOrder] = useState<string[]>(
@@ -132,14 +142,6 @@ export function DashboardGridEditable({
: [...hiddenWidgets, widgetId]; : [...hiddenWidgets, widgetId];
setHiddenWidgets(newHidden); setHiddenWidgets(newHidden);
// Salvar automaticamente ao toggle
startTransition(async () => {
await updateWidgetPreferences({
order: widgetOrder,
hidden: newHidden,
});
});
}; };
const handleHideWidget = (widgetId: string) => { const handleHideWidget = (widgetId: string) => {
@@ -182,6 +184,8 @@ export function DashboardGridEditable({
setWidgetOrder(DEFAULT_WIDGET_ORDER); setWidgetOrder(DEFAULT_WIDGET_ORDER);
setHiddenWidgets([]); setHiddenWidgets([]);
setMyAccountsShowExcluded(true); setMyAccountsShowExcluded(true);
setOriginalOrder(DEFAULT_WIDGET_ORDER);
setOriginalHidden([]);
toast.success("Preferências restauradas!"); toast.success("Preferências restauradas!");
} else { } else {
toast.error(result.error ?? "Erro ao restaurar"); toast.error(result.error ?? "Erro ao restaurar");
@@ -195,7 +199,68 @@ export function DashboardGridEditable({
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
{!isEditing ? ( {!isEditing ? (
<div className="flex w-full min-w-0 flex-col gap-1 sm:w-auto sm:flex-row sm:items-center sm:gap-2"> <div className="flex w-full min-w-0 flex-col gap-1 sm:w-auto sm:flex-row sm:items-center sm:gap-2">
<div className="-mb-1 grid w-full grid-cols-3 gap-1 pb-1 sm:mb-0 sm:flex sm:w-auto sm:items-center sm:gap-2 sm:overflow-visible sm:pb-0"> <div className="sm:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="w-full gap-2">
<RiAddFill className="size-4 text-primary" />
Adicionar
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem
onSelect={() => setIsMobileIncomeOpen(true)}
>
<RiAddFill className="text-success/80" />
Nova receita
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setIsMobileExpenseOpen(true)}
>
<RiAddFill className="text-destructive/80" />
Nova despesa
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setIsMobileNoteOpen(true)}>
<RiTodoLine className="text-info/80" />
Nova anotação
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TransactionDialog
mode="create"
open={isMobileIncomeOpen}
onOpenChange={setIsMobileIncomeOpen}
payerOptions={quickActionOptions.payerOptions}
splitPayerOptions={quickActionOptions.splitPayerOptions}
defaultPayerId={quickActionOptions.defaultPayerId}
accountOptions={quickActionOptions.accountOptions}
cardOptions={quickActionOptions.cardOptions}
categoryOptions={quickActionOptions.categoryOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPeriod={period}
defaultTransactionType="Receita"
/>
<TransactionDialog
mode="create"
open={isMobileExpenseOpen}
onOpenChange={setIsMobileExpenseOpen}
payerOptions={quickActionOptions.payerOptions}
splitPayerOptions={quickActionOptions.splitPayerOptions}
defaultPayerId={quickActionOptions.defaultPayerId}
accountOptions={quickActionOptions.accountOptions}
cardOptions={quickActionOptions.cardOptions}
categoryOptions={quickActionOptions.categoryOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPeriod={period}
defaultTransactionType="Despesa"
/>
<NoteDialog
mode="create"
open={isMobileNoteOpen}
onOpenChange={setIsMobileNoteOpen}
/>
</div>
<div className="hidden items-center gap-2 sm:flex">
<TransactionDialog <TransactionDialog
mode="create" mode="create"
payerOptions={quickActionOptions.payerOptions} payerOptions={quickActionOptions.payerOptions}
@@ -269,6 +334,12 @@ export function DashboardGridEditable({
<div className="flex w-full items-center justify-end gap-2 sm:w-auto"> <div className="flex w-full items-center justify-end gap-2 sm:w-auto">
{isEditing ? ( {isEditing ? (
<> <>
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
triggerLabel="Visibilidade"
/>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -290,21 +361,15 @@ export function DashboardGridEditable({
</Button> </Button>
</> </>
) : ( ) : (
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto"> <div className="w-full sm:w-auto">
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
triggerClassName="w-full sm:w-auto"
/>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleStartEditing} onClick={handleStartEditing}
className="w-full gap-2 sm:w-auto" className="w-full gap-2 sm:w-auto"
> >
<RiDragMove2Line className="size-4" /> <RiSettings4Line className="size-4" />
Reordenar Personalizar
</Button> </Button>
</div> </div>
)} )}

View File

@@ -1,9 +1,11 @@
import { import {
RiArrowLeftRightLine, RiArrowLeftRightLine,
RiArrowRightDownLine, RiArrowRightDownLine,
RiArrowRightLine,
RiArrowRightUpLine, RiArrowRightUpLine,
RiCalendar2Line, RiCalendar2Line,
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link";
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button"; import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries"; import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
@@ -18,10 +20,13 @@ import {
} from "@/shared/components/ui/card"; } from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator"; import { Separator } from "@/shared/components/ui/separator";
import { formatPercentage } from "@/shared/utils/percentage"; import { formatPercentage } from "@/shared/utils/percentage";
import { formatPeriodForUrl } from "@/shared/utils/period";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
type DashboardMetricsCardsProps = { type DashboardMetricsCardsProps = {
metrics: DashboardCardMetrics; metrics: DashboardCardMetrics;
period: string;
adminPayerSlug: string | null;
}; };
type Trend = "up" | "down" | "flat"; type Trend = "up" | "down" | "flat";
@@ -36,6 +41,7 @@ const CARDS = [
icon: RiArrowRightDownLine, icon: RiArrowRightDownLine,
invertTrend: false, invertTrend: false,
iconClass: "text-success", iconClass: "text-success",
transactionType: "receita",
helpTitle: "Como calculamos receitas", helpTitle: "Como calculamos receitas",
helpLines: [ helpLines: [
"Somamos os lançamentos do tipo Receita no período selecionado.", "Somamos os lançamentos do tipo Receita no período selecionado.",
@@ -53,6 +59,7 @@ const CARDS = [
icon: RiArrowRightUpLine, icon: RiArrowRightUpLine,
invertTrend: true, invertTrend: true,
iconClass: "text-destructive", iconClass: "text-destructive",
transactionType: "despesa",
helpTitle: "Como calculamos despesas", helpTitle: "Como calculamos despesas",
helpLines: [ helpLines: [
"Somamos os lançamentos do tipo Despesa no período selecionado.", "Somamos os lançamentos do tipo Despesa no período selecionado.",
@@ -70,6 +77,7 @@ const CARDS = [
icon: RiArrowLeftRightLine, icon: RiArrowLeftRightLine,
invertTrend: false, invertTrend: false,
iconClass: "text-warning", iconClass: "text-warning",
transactionType: null,
helpTitle: "Como calculamos o balanço", helpTitle: "Como calculamos o balanço",
helpLines: [ helpLines: [
"Partimos de receitas menos despesas do período.", "Partimos de receitas menos despesas do período.",
@@ -86,6 +94,7 @@ const CARDS = [
icon: RiCalendar2Line, icon: RiCalendar2Line,
invertTrend: false, invertTrend: false,
iconClass: "text-cyan-600", iconClass: "text-cyan-600",
transactionType: null,
helpTitle: "Como calculamos o previsto", helpTitle: "Como calculamos o previsto",
helpLines: [ helpLines: [
"Acumulamos o balanço mês a mês até o período atual.", "Acumulamos o balanço mês a mês até o período atual.",
@@ -123,7 +132,11 @@ const getPercentChange = (current: number, previous: number): string | null => {
}); });
}; };
export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) { export function DashboardMetricsCards({
metrics,
period,
adminPayerSlug,
}: DashboardMetricsCardsProps) {
return ( return (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"> <div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{CARDS.map( {CARDS.map(
@@ -134,6 +147,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
icon: Icon, icon: Icon,
invertTrend, invertTrend,
iconClass, iconClass,
transactionType,
helpTitle, helpTitle,
helpLines, helpLines,
}) => { }) => {
@@ -143,10 +157,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
metric.current, metric.current,
metric.previous, metric.previous,
); );
const transactionsHref = transactionType
? `/transactions?periodo=${formatPeriodForUrl(period)}&type=${transactionType}${adminPayerSlug ? `&payer=${adminPayerSlug}` : ""}`
: null;
return ( return (
<Card key={label} className="gap-2 overflow-hidden"> <Card key={label} className="gap-2 overflow-hidden py-6">
<CardHeader className="gap-1"> <CardHeader className="gap-1">
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center gap-1"> <CardTitle className="flex items-center gap-1">
<Icon className={cn("size-4", iconClass)} aria-hidden /> <Icon className={cn("size-4", iconClass)} aria-hidden />
{label} {label}
@@ -156,6 +174,16 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
helpLines={helpLines} helpLines={helpLines}
/> />
</CardTitle> </CardTitle>
{transactionsHref ? (
<Link
href={transactionsHref}
className="rounded-sm px-1 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px]"
aria-label={`Ver lançamentos de ${label.toLowerCase()}`}
>
<RiArrowRightLine className="size-4" aria-hidden />
</Link>
) : null}
</div>
<CardDescription className="mt-1 tracking-tight"> <CardDescription className="mt-1 tracking-tight">
{subtitle} {subtitle}
</CardDescription> </CardDescription>

View File

@@ -1,5 +1,5 @@
import { RiPencilLine } from "@remixicon/react"; import { RiPencilLine } from "@remixicon/react";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import Link from "next/link";
import { import {
clampGoalProgress, clampGoalProgress,
formatGoalProgressPercentage, formatGoalProgressPercentage,
@@ -9,24 +9,28 @@ import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Progress } from "@/shared/components/ui/progress"; import { Progress } from "@/shared/components/ui/progress";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils";
import { formatPeriodForUrl } from "@/shared/utils/period";
type GoalProgressItemProps = { type GoalProgressItemProps = {
item: GoalProgressItemData; item: GoalProgressItemData;
index: number;
onEdit: (item: GoalProgressItemData) => void; onEdit: (item: GoalProgressItemData) => void;
}; };
export function GoalProgressItem({ export function GoalProgressItem({ item, onEdit }: GoalProgressItemProps) {
item,
index,
onEdit,
}: GoalProgressItemProps) {
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100); const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
const percentageDelta = item.usedPercentage - 100;
const isExceeded = item.status === "exceeded"; const isExceeded = item.status === "exceeded";
const isCritical = item.status === "critical";
const exceededAmount = Math.max(item.spentAmount - item.budgetAmount, 0);
const usedPercentageLabel = formatGoalProgressPercentage(item.usedPercentage);
return ( return (
<div className="group transition-all duration-300 py-2"> <li className="group py-2 transition-all duration-300">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-start gap-2"> <div className="flex min-w-0 flex-1 items-start gap-2">
<CategoryIconBadge <CategoryIconBadge
@@ -35,46 +39,72 @@ export function GoalProgressItem({
size="md" size="md"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{item.categoryId ? (
<Link
href={`/categories/${item.categoryId}?periodo=${formatPeriodForUrl(item.period)}`}
className="block truncate text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
{item.categoryName}
</Link>
) : (
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{item.categoryName} {item.categoryName}
</p> </p>
)}
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
<MoneyValues className="font-medium" amount={item.spentAmount} />{" "} <MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
de{" "} de{" "}
<MoneyValues className="font-medium" amount={item.budgetAmount} /> <MoneyValues className="font-medium" amount={item.budgetAmount} />
<PercentageChangeIndicator <span aria-hidden> · </span>
value={percentageDelta} <span
label={formatGoalProgressPercentage(percentageDelta, true)} className={cn(
positiveTrend="down" "font-medium",
className="ml-1.5 align-middle" isExceeded && "text-destructive",
/> isCritical && "text-warning",
)}
>
{isExceeded ? (
<>
<MoneyValues amount={exceededAmount} /> acima do limite
</>
) : (
`${usedPercentageLabel} utilizado`
)}
</span>
</p> </p>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <Tooltip>
<TooltipTrigger asChild>
<Button <Button
type="button" type="button"
variant="link" variant="ghost"
size="icon-sm" size="icon-sm"
className="transition-opacity text-primary hover:opacity-80" className="shrink-0 text-primary/70 opacity-70 transition-all hover:text-primary hover:opacity-100 focus-visible:text-primary focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
onClick={() => onEdit(item)} onClick={() => onEdit(item)}
aria-label={`Atualizar orçamento de ${item.categoryName}`} aria-label={`Atualizar orçamento de ${item.categoryName}`}
> >
<RiPencilLine className="size-3.5" /> <RiPencilLine className="size-3.5" />
</Button> </Button>
</div> </TooltipTrigger>
<TooltipContent side="top">Atualizar orçamento</TooltipContent>
</Tooltip>
</div> </div>
<div className="ml-11 mt-1.5"> <div className="ml-11 mt-1.5">
<Progress <Progress
value={progressValue} value={progressValue}
className={ className={cn(
isExceeded isExceeded && "bg-destructive/20",
? "**:data-[slot=progress-indicator]:bg-destructive bg-destructive/20" isCritical && "bg-warning/20",
: undefined )}
} indicatorClassName={cn(
isExceeded && "bg-destructive",
isCritical && "bg-warning",
)}
aria-label={`${usedPercentageLabel} do orçamento utilizado em ${item.categoryName}`}
/> />
</div> </div>
</div> </li>
); );
} }

View File

@@ -21,13 +21,8 @@ export function GoalsProgressList({ items, onEdit }: GoalsProgressListProps) {
return ( return (
<ul className="flex flex-col"> <ul className="flex flex-col">
{items.map((item, index) => ( {items.map((item) => (
<GoalProgressListItem <GoalProgressListItem key={item.id} item={item} onEdit={onEdit} />
key={item.id}
item={item}
index={index}
onEdit={onEdit}
/>
))} ))}
</ul> </ul>
); );

View File

@@ -207,32 +207,23 @@ export function InstallmentGroupCard({
)} )}
</div> </div>
{/* Valor selecionado */}
{hasSelection && (
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/5 border border-primary/20 mb-4">
<span className="text-sm font-medium text-foreground">
{selectedInstallments.size}{" "}
{selectedInstallments.size === 1
? "parcela selecionada"
: "parcelas selecionadas"}
</span>
<MoneyValues
amount={selectedAmount}
className="text-base font-semibold text-primary"
/>
</div>
)}
{/* Botão para abrir detalhes */} {/* Botão para abrir detalhes */}
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
size="sm" size="sm"
className="w-full gap-1.5" className="relative w-full justify-center gap-1.5"
onClick={() => setIsDetailsOpen(true)} onClick={() => setIsDetailsOpen(true)}
> >
<span className="inline-flex items-center gap-1.5">
<RiFileList2Line className="size-4" /> <RiFileList2Line className="size-4" />
detalhes ({group.pendingInstallments.length} parcelas) detalhes
</span>
{hasSelection && (
<span className="absolute right-2 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{selectedInstallments.size} sel.
</span>
)}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -9,6 +9,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/shared/components/ui/tooltip"; } from "@/shared/components/ui/tooltip";
import { getPaymentMethodIcon } from "@/shared/utils/icons";
type InstallmentExpenseListItemProps = { type InstallmentExpenseListItemProps = {
expense: InstallmentExpense; expense: InstallmentExpense;
@@ -20,6 +21,7 @@ export function InstallmentExpenseListItem({
const { const {
compactLabel, compactLabel,
isLast, isLast,
remainingLabel,
remainingInstallments, remainingInstallments,
remainingAmount, remainingAmount,
endDate, endDate,
@@ -27,7 +29,7 @@ export function InstallmentExpenseListItem({
} = buildInstallmentExpenseDisplay(expense); } = buildInstallmentExpenseDisplay(expense);
return ( return (
<div className="flex items-center gap-3 transition-all duration-300 py-2"> <div className="flex items-center gap-2 transition-all duration-300 py-1.5">
<EstablishmentLogo name={expense.name} size={37} /> <EstablishmentLogo name={expense.name} size={37} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -65,15 +67,32 @@ export function InstallmentExpenseListItem({
/> />
</div> </div>
<p className="text-xs text-muted-foreground"> <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
{endDate ? `Termina em ${endDate}` : null} <span className="inline-flex min-w-0 items-center gap-1">
{" · Restante "} <span
className="inline-flex shrink-0 [&_svg]:size-3.5"
title={expense.paymentMethod}
>
{getPaymentMethodIcon(expense.paymentMethod)}
<span className="sr-only">{expense.paymentMethod}</span>
</span>
{endDate ? <span className="shrink-0">Até {endDate}</span> : null}
</span>
<span className="shrink-0">
{remainingInstallments === 0 ? (
"Quitado"
) : (
<>
{remainingLabel}:{" "}
<MoneyValues <MoneyValues
amount={remainingAmount} amount={remainingAmount}
className="inline-block font-semibold" className="inline-block font-semibold"
/>{" "} />{" "}
({remainingInstallments}) ({remainingInstallments}x)
</p> </>
)}
</span>
</div>
<Progress value={progress} className="mt-1 h-2" /> <Progress value={progress} className="mt-1 h-2" />
</div> </div>

View File

@@ -14,17 +14,17 @@ export function InstallmentExpensesList({
return ( return (
<WidgetEmptyState <WidgetEmptyState
icon={<RiNumbersLine className="size-6 text-muted-foreground" />} icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa parcelada" title="Nenhuma despesa parcelada encontrada"
description="Lançamentos parcelados aparecerão aqui conforme forem registrados." description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
/> />
); );
} }
return ( return (
<ul className="flex flex-col"> <div className="flex flex-col">
{expenses.map((expense) => ( {expenses.map((expense) => (
<InstallmentExpenseListItem key={expense.id} expense={expense} /> <InstallmentExpenseListItem key={expense.id} expense={expense} />
))} ))}
</ul> </div>
); );
} }

View File

@@ -1,10 +1,11 @@
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react"; import { RiCheckboxCircleFill, RiGroupLine } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { import {
buildInvoiceDetailsHref, buildInvoiceDetailsHref,
buildInvoiceInitials, buildInvoiceInitials,
formatInvoicePaymentDate, formatInvoicePaymentDate,
formatInvoiceWidgetOverdueLabel,
formatInvoiceWidgetPaymentDate, formatInvoiceWidgetPaymentDate,
getInvoiceShareLabel, getInvoiceShareLabel,
parseInvoiceDueDate, parseInvoiceDueDate,
@@ -48,9 +49,13 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
const absolutePaymentInfo = formatInvoicePaymentDate(invoice.paidAt); const absolutePaymentInfo = formatInvoicePaymentDate(invoice.paidAt);
const breakdown = invoice.pagadorBreakdown ?? []; const breakdown = invoice.pagadorBreakdown ?? [];
const hasBreakdown = breakdown.length > 0; const hasBreakdown = breakdown.length > 0;
const hasMultiplePayers = breakdown.length > 1;
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period); const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
const overdueLabel = formatInvoiceWidgetOverdueLabel(dueInfo.date);
const dueTooltipLabel = const dueTooltipLabel =
dueInfo.label !== absoluteDueInfo.label ? absoluteDueInfo.label : null; overdueLabel || dueInfo.label !== absoluteDueInfo.label
? absoluteDueInfo.label
: null;
const paymentTooltipLabel = const paymentTooltipLabel =
paymentInfo?.label && paymentInfo.label !== absolutePaymentInfo?.label paymentInfo?.label && paymentInfo.label !== absolutePaymentInfo?.label
? absolutePaymentInfo?.label ? absolutePaymentInfo?.label
@@ -63,15 +68,11 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline" className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
> >
<span className="truncate">{invoice.cardName}</span> <span className="truncate">{invoice.cardName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link> </Link>
); );
return ( return (
<div className="flex items-center justify-between transition-all duration-300 py-1.5"> <li className="flex items-center justify-between transition-all duration-300 py-1.5">
<div className="flex min-w-0 flex-1 items-center gap-2 py-1"> <div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<InvoiceLogo <InvoiceLogo
cardName={invoice.cardName} cardName={invoice.cardName}
@@ -81,6 +82,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
/> />
<div className="min-w-0"> <div className="min-w-0">
<div className="flex max-w-full items-center gap-1">
{hasBreakdown ? ( {hasBreakdown ? (
<HoverCard openDelay={150}> <HoverCard openDelay={150}>
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger> <HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
@@ -133,18 +135,46 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
) : ( ) : (
linkNode linkNode
)} )}
{hasMultiplePayers ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 cursor-help text-muted-foreground">
<RiGroupLine className="size-3.5" aria-hidden />
<span className="sr-only">Ver distribuição por pessoa</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Ver distribuição por pessoa
</TooltipContent>
</Tooltip>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{!isPaid ? ( {!isPaid ? (
dueTooltipLabel ? ( dueTooltipLabel ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="cursor-help">{dueInfo.label}</span> <span
className={
isOverdue
? "cursor-help font-semibold text-destructive"
: "cursor-help"
}
>
{overdueLabel ?? dueInfo.label}
</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">{dueTooltipLabel}</TooltipContent> <TooltipContent side="top">{dueTooltipLabel}</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<span>{dueInfo.label}</span> <span
className={
isOverdue ? "font-semibold text-destructive" : undefined
}
>
{overdueLabel ?? dueInfo.label}
</span>
) )
) : null} ) : null}
{isPaid && paymentInfo ? ( {isPaid && paymentInfo ? (
@@ -174,19 +204,19 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
className="font-medium" className="font-medium"
amount={Math.abs(invoice.totalAmount)} amount={Math.abs(invoice.totalAmount)}
/> />
{isPaid ? (
<span className="flex h-7 items-center gap-0.5 text-xs font-medium text-success">
<RiCheckboxCircleFill className="size-3.5" /> Pago
</span>
) : (
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="link" variant="link"
className="h-auto p-0 disabled:opacity-100" className="-mr-1.5 h-7 px-1.5 py-0"
disabled={isPaid}
onClick={() => onPay(invoice.id)} onClick={() => onPay(invoice.id)}
> >
{isPaid ? ( {isOverdue ? (
<span className="flex items-center gap-0.5 text-success">
<RiCheckboxCircleFill className="size-3.5" /> Pago
</span>
) : isOverdue ? (
<span className="overdue-blink"> <span className="overdue-blink">
<span className="overdue-blink-primary text-destructive"> <span className="overdue-blink-primary text-destructive">
Atrasado Atrasado
@@ -197,7 +227,8 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
<span>Pagar</span> <span>Pagar</span>
)} )}
</Button> </Button>
)}
</div> </div>
</div> </li>
); );
} }

View File

@@ -39,9 +39,7 @@ export function InvoicesWidgetView({
}: InvoicesWidgetViewProps) { }: InvoicesWidgetViewProps) {
return ( return (
<> <>
<div className="flex flex-col gap-4">
<InvoicesList invoices={invoices} onPay={onOpenPaymentDialog} /> <InvoicesList invoices={invoices} onPay={onOpenPaymentDialog} />
</div>
<InvoicePaymentDialog <InvoicePaymentDialog
invoice={selectedInvoice} invoice={selectedInvoice}

View File

@@ -2,10 +2,10 @@
import { RiInformationLine } from "@remixicon/react"; import { RiInformationLine } from "@remixicon/react";
import { import {
HoverCard, Tooltip,
HoverCardContent, TooltipContent,
HoverCardTrigger, TooltipTrigger,
} from "@/shared/components/ui/hover-card"; } from "@/shared/components/ui/tooltip";
type MetricsCardInfoButtonProps = { type MetricsCardInfoButtonProps = {
label: string; label: string;
@@ -19,8 +19,8 @@ export function MetricsCardInfoButton({
helpLines, helpLines,
}: MetricsCardInfoButtonProps) { }: MetricsCardInfoButtonProps) {
return ( return (
<HoverCard openDelay={150}> <Tooltip>
<HoverCardTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className="inline-flex items-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" className="inline-flex items-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
@@ -28,17 +28,22 @@ export function MetricsCardInfoButton({
> >
<RiInformationLine className="size-4" aria-hidden /> <RiInformationLine className="size-4" aria-hidden />
</button> </button>
</HoverCardTrigger> </TooltipTrigger>
<HoverCardContent align="start" className="w-80 space-y-3"> <TooltipContent
align="start"
side="bottom"
sideOffset={8}
className="max-w-80 space-y-3 p-3 text-left"
>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-foreground">{helpTitle}</p> <p className="text-sm font-medium text-background">{helpTitle}</p>
</div> </div>
<ul className="space-y-2 text-xs text-muted-foreground"> <ul className="space-y-2 text-xs text-background/80">
{helpLines.map((line) => ( {helpLines.map((line) => (
<li key={`${label}-${line}`}>{line}</li> <li key={`${label}-${line}`}>{line}</li>
))} ))}
</ul> </ul>
</HoverCardContent> </TooltipContent>
</HoverCard> </Tooltip>
); );
} }

View File

@@ -1,4 +1,8 @@
import { RiFileList2Line, RiPencilLine } from "@remixicon/react"; import {
RiCalendarLine,
RiFileList2Line,
RiPencilLine,
} from "@remixicon/react";
import type { Note } from "@/features/notes/components/types"; import type { Note } from "@/features/notes/components/types";
import { import {
buildNoteDisplayTitle, buildNoteDisplayTitle,
@@ -7,6 +11,11 @@ import {
} from "@/features/notes/lib/formatters"; } from "@/features/notes/lib/formatters";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
type NoteListItemProps = { type NoteListItemProps = {
note: Note; note: Note;
@@ -21,43 +30,59 @@ export function NoteListItem({
}: NoteListItemProps) { }: NoteListItemProps) {
const displayTitle = buildNoteDisplayTitle(note.title); const displayTitle = buildNoteDisplayTitle(note.title);
const createdAtLabel = formatNoteCreatedAt(note.createdAt); const createdAtLabel = formatNoteCreatedAt(note.createdAt);
const isTask = note.type === "tarefa";
return ( return (
<div className="group flex items-center justify-between gap-2 transition-all duration-300 py-2"> <li className="group flex items-center justify-between gap-2 py-1.5 transition-all duration-300">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{displayTitle} {displayTitle}
</p> </p>
<div className="mt-1 flex items-center gap-2"> <div className="mt-1 flex min-w-0 items-center gap-2">
{isTask ? (
<Badge variant="outline" className="h-5 px-1.5 text-xs"> <Badge variant="outline" className="h-5 px-1.5 text-xs">
{getNoteTasksSummary(note)} {getNoteTasksSummary(note)}
</Badge> </Badge>
) : null}
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<RiCalendarLine className="size-3.5 shrink-0" />
{createdAtLabel} {createdAtLabel}
</span>
</p> </p>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="link" variant="ghost"
size="icon-sm" size="icon-sm"
className="transition-opacity text-primary hover:opacity-80" className="text-primary/70 opacity-70 transition-all hover:text-primary hover:opacity-100 focus-visible:text-primary focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
onClick={() => onOpenEdit(note)} onClick={() => onOpenEdit(note)}
aria-label={`Editar anotação ${displayTitle}`} aria-label={`Editar anotação ${displayTitle}`}
> >
<RiPencilLine className="size-4" /> <RiPencilLine className="size-4" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent side="top">Editar anotação</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="link" variant="ghost"
size="icon-sm" size="icon-sm"
className="transition-opacity text-primary hover:opacity-80" className="text-primary/70 opacity-70 transition-all hover:text-primary hover:opacity-100 focus-visible:text-primary focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
onClick={() => onOpenDetails(note)} onClick={() => onOpenDetails(note)}
aria-label={`Ver detalhes da anotação ${displayTitle}`} aria-label={`Ver detalhes da anotação ${displayTitle}`}
> >
<RiFileList2Line className="size-4" /> <RiFileList2Line className="size-4" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent side="top">Ver detalhes</TooltipContent>
</Tooltip>
</div> </div>
</div> </li>
); );
} }

View File

@@ -27,7 +27,7 @@ export function NotesWidgetView({
}: NotesWidgetViewProps) { }: NotesWidgetViewProps) {
return ( return (
<> <>
<div className="flex flex-col gap-4 px-0"> <div className="flex flex-col px-0">
<NotesList <NotesList
notes={notes} notes={notes}
onOpenEdit={onOpenEdit} onOpenEdit={onOpenEdit}

View File

@@ -1,4 +1,3 @@
import { RiExternalLinkLine } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { import {
@@ -24,13 +23,18 @@ export type PaymentBreakdownListItemData = {
type PaymentBreakdownListItemProps = { type PaymentBreakdownListItemProps = {
item: PaymentBreakdownListItemData; item: PaymentBreakdownListItemData;
position: number;
}; };
export function PaymentBreakdownListItem({ export function PaymentBreakdownListItem({
item, item,
position,
}: PaymentBreakdownListItemProps) { }: PaymentBreakdownListItemProps) {
return ( return (
<div className="flex items-center gap-3 transition-all duration-300 py-1.5"> <div className="flex items-center gap-2 transition-all duration-300 py-1">
<span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{position}
</span>
<div <div
className="flex size-9.5 shrink-0 items-center justify-center rounded-full" className="flex size-9.5 shrink-0 items-center justify-center rounded-full"
style={{ style={{
@@ -49,22 +53,20 @@ export function PaymentBreakdownListItem({
className="inline-flex items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline" className="inline-flex items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
> >
<span className="truncate">{item.title}</span> <span className="truncate">{item.title}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link> </Link>
) : ( ) : (
<p className="text-sm font-medium text-foreground">{item.title}</p> <p className="text-sm font-medium text-foreground">{item.title}</p>
)} )}
<MoneyValues className="font-medium" amount={item.amount} /> <MoneyValues className="shrink-0 font-medium" amount={item.amount} />
</div> </div>
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span> <span>
{formatPaymentBreakdownTransactionsLabel(item.transactions)} {formatPaymentBreakdownTransactionsLabel(item.transactions)}
</span> </span>
<span>{formatPaymentBreakdownPercentage(item.percentage)}</span> <span>
{formatPaymentBreakdownPercentage(item.percentage)} do total
</span>
</div> </div>
<div className="mt-1"> <div className="mt-1">

View File

@@ -31,10 +31,14 @@ export function PaymentBreakdownList({
} }
return ( return (
<div className="flex flex-col gap-4 px-0"> <div className="flex flex-col px-0">
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{items.map((item) => ( {items.map((item, index) => (
<PaymentBreakdownListItem key={item.id} item={item} /> <PaymentBreakdownListItem
key={item.id}
item={item}
position={index + 1}
/>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -43,7 +43,7 @@ export function PaymentOverviewWidgetView({
className="text-xs data-[state=active]:bg-transparent" className="text-xs data-[state=active]:bg-transparent"
> >
<RiMoneyDollarCircleLine className="mr-1 size-3.5" /> <RiMoneyDollarCircleLine className="mr-1 size-3.5" />
Formas Formas de pagamento
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>

View File

@@ -1,16 +1,18 @@
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import StatusDot from "@/shared/components/feedback/status-dot"; import StatusDot from "@/shared/components/feedback/status-dot";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Progress } from "@/shared/components/ui/progress"; import { Progress } from "@/shared/components/ui/progress";
import { formatPercentage } from "@/shared/utils/percentage";
type PaymentStatusCategorySectionProps = { type PaymentStatusCategorySectionProps = {
title: string; type: "income" | "expenses";
total: number; total: number;
confirmed: number; confirmed: number;
pending: number; pending: number;
}; };
export function PaymentStatusCategorySection({ export function PaymentStatusCategorySection({
title, type,
total, total,
confirmed, confirmed,
pending, pending,
@@ -19,27 +21,51 @@ export function PaymentStatusCategorySection({
const absConfirmed = Math.abs(confirmed); const absConfirmed = Math.abs(confirmed);
const confirmedPercentage = const confirmedPercentage =
absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0; absTotal > 0 ? (absConfirmed / absTotal) * 100 : 0;
const income = type === "income";
const title = income ? "A receber" : "A pagar";
const confirmedLabel = income ? "recebidos" : "pagos";
const pendingLabel = income ? "a receber" : "a pagar";
const percentageLabel = income ? "recebido" : "pago";
const TitleIcon = income ? RiArrowDownLine : RiArrowUpLine;
return ( return (
<div className="mt-4 space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{title}</span> <span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<TitleIcon className="size-4 text-primary" aria-hidden />
{title}
</span>
<span className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatPercentage(confirmedPercentage, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}{" "}
{percentageLabel}
</span>
<MoneyValues amount={total} className="font-medium" /> <MoneyValues amount={total} className="font-medium" />
</span>
</div> </div>
<Progress value={confirmedPercentage} className="h-2" /> <Progress
value={confirmedPercentage}
className="h-2"
indicatorClassName="bg-primary"
/>
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4"> <div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<StatusDot color="bg-primary" /> <StatusDot color="bg-primary" />
<MoneyValues amount={confirmed} className="font-medium" /> <MoneyValues amount={confirmed} className="font-medium" />
<span className="text-xs text-muted-foreground">confirmados</span> <span className="text-xs text-muted-foreground">
{confirmedLabel}
</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<StatusDot color="bg-warning/40" /> <StatusDot color="bg-warning/40" />
<MoneyValues amount={pending} className="font-medium" /> <MoneyValues amount={pending} className="font-medium" />
<span className="text-xs text-muted-foreground">pendentes</span> <span className="text-xs text-muted-foreground">{pendingLabel}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -28,7 +28,7 @@ export function PaymentStatusWidgetView({
return ( return (
<CardContent className="space-y-6 px-0"> <CardContent className="space-y-6 px-0">
<PaymentStatusCategorySection <PaymentStatusCategorySection
title="A Receber" type="income"
total={data.income.total} total={data.income.total}
confirmed={data.income.confirmed} confirmed={data.income.confirmed}
pending={data.income.pending} pending={data.income.pending}
@@ -37,7 +37,7 @@ export function PaymentStatusWidgetView({
<div className="border-t" /> <div className="border-t" />
<PaymentStatusCategorySection <PaymentStatusCategorySection
title="A Pagar" type="expenses"
total={data.expenses.total} total={data.expenses.total}
confirmed={data.expenses.confirmed} confirmed={data.expenses.confirmed}
pending={data.expenses.pending} pending={data.expenses.pending}

View File

@@ -1,6 +1,11 @@
"use client"; "use client";
import { RiLineChartLine } from "@remixicon/react"; import {
RiArrowRightLine,
RiCalendarLine,
RiHistoryLine,
RiLineChartLine,
} from "@remixicon/react";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers"; import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { CategoryIconBadge } from "@/shared/components/entity-avatar"; import { CategoryIconBadge } from "@/shared/components/entity-avatar";
@@ -50,12 +55,30 @@ export function CategoryTrendsWidget({
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{category.categoryName} {category.categoryName}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MoneyValues amount={category.previousAmount} /> vs{" "} <span
className="inline-flex items-center gap-1"
title="Mês anterior"
>
<RiHistoryLine className="size-3.5" aria-hidden />
<span className="sr-only">Mês anterior:</span>
<MoneyValues amount={category.previousAmount} />
</span>
<RiArrowRightLine className="size-3" aria-hidden />
<span
className="inline-flex items-center gap-1 text-foreground"
title="Mês atual"
>
<RiCalendarLine
className="size-3.5 text-primary"
aria-hidden
/>
<span className="sr-only">Mês atual:</span>
<MoneyValues <MoneyValues
amount={category.currentAmount} amount={category.currentAmount}
className="font-semibold" className="font-semibold"
/> />
</span>
</p> </p>
</div> </div>
<PercentageChangeIndicator <PercentageChangeIndicator

View File

@@ -6,6 +6,7 @@ import {
RiDeleteBinLine, RiDeleteBinLine,
} from "@remixicon/react"; } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -19,6 +20,11 @@ import { TransactionDialog } from "@/features/transactions/components/dialogs/tr
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { resolveLogoSrc } from "@/shared/lib/logo"; import { resolveLogoSrc } from "@/shared/lib/logo";
@@ -46,6 +52,24 @@ function getDateString(date: Date | string | null | undefined): string | null {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
function findMatchingLogo(
sourceAppName: string | null,
logoMap: Record<string, string>,
): string | null {
if (!sourceAppName) return null;
const appName = sourceAppName.toLowerCase();
if (logoMap[appName]) return resolveLogoSrc(logoMap[appName]);
for (const [name, logo] of Object.entries(logoMap)) {
if (name.includes(appName) || appName.includes(name)) {
return resolveLogoSrc(logo);
}
}
return null;
}
export function InboxWidget({ export function InboxWidget({
snapshot, snapshot,
quickActionOptions, quickActionOptions,
@@ -149,13 +173,18 @@ export function InboxWidget({
if (snapshot.pendingCount === 0) { if (snapshot.pendingCount === 0) {
return ( return (
<WidgetEmptyState <WidgetEmptyState
icon={<RiCheckboxCircleFill color="green" className="size-6" />} icon={<RiCheckboxCircleFill className="size-6 text-success" />}
title="Tudo em dia" title="Tudo em dia"
description="Nenhum pré-lançamento aguardando revisão." description="Nenhum pré-lançamento aguardando revisão."
/> />
); );
} }
const remainingCount = Math.max(
snapshot.pendingCount - snapshot.recentItems.length,
0,
);
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
{snapshot.recentItems.map((item) => { {snapshot.recentItems.map((item) => {
@@ -168,17 +197,12 @@ export function InboxWidget({
parsedAmount !== null && Number.isFinite(parsedAmount) parsedAmount !== null && Number.isFinite(parsedAmount)
? parsedAmount ? parsedAmount
: null; : null;
const logoKey = item.sourceAppName?.toLowerCase() ?? ""; const logoSrc = findMatchingLogo(item.sourceAppName, snapshot.logoMap);
const rawLogo = snapshot.logoMap[logoKey] ?? null;
const logoSrc = resolveLogoSrc(rawLogo);
const displayLogo = logoSrc ?? DEFAULT_INBOX_APP_LOGO; const displayLogo = logoSrc ?? DEFAULT_INBOX_APP_LOGO;
return ( return (
<div <div key={item.id} className="flex items-center justify-between py-2">
key={item.id} <div className="flex min-w-0 flex-1 items-center gap-2">
className="flex items-center justify-between py-1.5"
>
<div className="flex flex-1 items-center gap-2">
<Image <Image
src={displayLogo} src={displayLogo}
alt={item.sourceAppName ?? ""} alt={item.sourceAppName ?? ""}
@@ -188,52 +212,74 @@ export function InboxWidget({
unoptimized unoptimized
/> />
<div> <div className="min-w-0">
<p className="text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{displayName.length > 30 {displayName}
? `${displayName.slice(0, 30)}...`
: displayName}
</p> </p>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
{item.sourceAppName && <span>{item.sourceAppName}</span>} {item.sourceAppName && (
<span className="truncate">{item.sourceAppName}</span>
)}
<span className="text-muted-foreground/60"> <span className="text-muted-foreground/60">
{relativeTime(item.createdAt)} {relativeTime(item.notificationTimestamp)}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div className="flex shrink-0 flex-col items-end"> <div className="ml-2 flex shrink-0 items-center gap-1">
{amount !== null && ( {amount !== null && (
<MoneyValues className="font-medium" amount={amount} /> <MoneyValues className="font-medium" amount={amount} />
)} )}
{amount === null && (
<span className="max-w-20 text-right text-xs leading-tight text-muted-foreground">
Valor não identificado
</span>
)}
<div className="flex items-center"> <div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
className="size-6 text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
onClick={() => handleProcessRequest(item)} onClick={() => handleProcessRequest(item)}
aria-label="Processar notificação" aria-label="Lançar notificação"
title="Processar"
> >
<RiCheckLine className="size-3.5" /> <RiCheckLine className="size-3.5" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent side="top">Lançar</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
className="size-6 text-muted-foreground hover:text-destructive" className="text-muted-foreground hover:text-destructive"
onClick={() => handleDiscardRequest(item)} onClick={() => handleDiscardRequest(item)}
aria-label="Descartar notificação" aria-label="Descartar notificação"
title="Descartar"
> >
<RiDeleteBinLine className="size-3.5" /> <RiDeleteBinLine className="size-3.5" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent side="top">Descartar</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
</div> </div>
); );
})} })}
{remainingCount > 0 && (
<Link
href="/inbox"
className="mt-2 inline-flex items-center justify-center text-xs font-medium text-muted-foreground transition-colors hover:text-primary"
>
+ {remainingCount} pendentes · Revisar todos
</Link>
)}
<TransactionDialog <TransactionDialog
mode="create" mode="create"
open={processOpen} open={processOpen}

View File

@@ -1,7 +1,14 @@
"use client"; "use client";
import { RiLineChartLine } from "@remixicon/react"; import { RiLineChartLine } from "@remixicon/react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; import {
Bar,
CartesianGrid,
ComposedChart,
Line,
ReferenceLine,
XAxis,
} from "recharts";
import type { IncomeExpenseBalanceData } from "@/features/dashboard/overview/income-expense-balance-queries"; import type { IncomeExpenseBalanceData } from "@/features/dashboard/overview/income-expense-balance-queries";
import { CardContent } from "@/shared/components/ui/card"; import { CardContent } from "@/shared/components/ui/card";
import { import {
@@ -11,6 +18,7 @@ import {
} from "@/shared/components/ui/chart"; } from "@/shared/components/ui/chart";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { formatCurrency } from "@/shared/utils/currency"; import { formatCurrency } from "@/shared/utils/currency";
import { formatCompactPeriodLabel } from "@/shared/utils/period";
type IncomeExpenseBalanceWidgetProps = { type IncomeExpenseBalanceWidgetProps = {
data: IncomeExpenseBalanceData; data: IncomeExpenseBalanceData;
@@ -19,15 +27,15 @@ type IncomeExpenseBalanceWidgetProps = {
const chartConfig = { const chartConfig = {
receita: { receita: {
label: "Receita", label: "Receita",
color: "var(--chart-1)", color: "var(--success)",
}, },
despesa: { despesa: {
label: "Despesa", label: "Despesa",
color: "var(--chart-2)", color: "var(--destructive)",
}, },
balanco: { balanco: {
label: "Balanço", label: "Balanço",
color: "var(--chart-3)", color: "var(--primary)",
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
@@ -35,7 +43,7 @@ export function IncomeExpenseBalanceWidget({
data, data,
}: IncomeExpenseBalanceWidgetProps) { }: IncomeExpenseBalanceWidgetProps) {
const chartData = data.months.map((month) => ({ const chartData = data.months.map((month) => ({
month: month.monthLabel, month: formatCompactPeriodLabel(month.month).toLowerCase(),
receita: month.income, receita: month.income,
despesa: month.expense, despesa: month.expense,
balanco: month.balance, balanco: month.balance,
@@ -59,16 +67,18 @@ export function IncomeExpenseBalanceWidget({
} }
return ( return (
<CardContent className="space-y-4 px-0"> <CardContent className="space-y-2 px-0">
<ChartContainer <ChartContainer
config={chartConfig} config={chartConfig}
className="h-[270px] w-full aspect-auto" className="h-[270px] w-full aspect-auto"
> >
<BarChart <ComposedChart
data={chartData} data={chartData}
margin={{ top: 20, right: 10, left: 10, bottom: 5 }} margin={{ top: 20, right: 10, left: 10, bottom: 5 }}
accessibilityLayer
> >
<CartesianGrid strokeDasharray="3 3" vertical={false} /> <CartesianGrid strokeDasharray="3 3" vertical={false} />
<ReferenceLine y={0} stroke="var(--border)" />
<XAxis <XAxis
dataKey="month" dataKey="month"
tickLine={false} tickLine={false}
@@ -81,8 +91,15 @@ export function IncomeExpenseBalanceWidget({
return null; return null;
} }
const month = payload[0]?.payload.month as string | undefined;
return ( return (
<div className="rounded-lg border bg-background p-2 shadow-sm"> <div className="rounded-lg border bg-background p-2 shadow-sm">
{month ? (
<p className="mb-2 text-xs font-medium text-muted-foreground">
{month}
</p>
) : null}
<div className="grid gap-2"> <div className="grid gap-2">
{payload.map((entry) => { {payload.map((entry) => {
const config = const config =
@@ -111,7 +128,7 @@ export function IncomeExpenseBalanceWidget({
</div> </div>
); );
}} }}
cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }} cursor={{ fill: "var(--muted)", opacity: 0.3 }}
/> />
<Bar <Bar
dataKey="receita" dataKey="receita"
@@ -125,42 +142,26 @@ export function IncomeExpenseBalanceWidget({
radius={[4, 4, 0, 0]} radius={[4, 4, 0, 0]}
maxBarSize={60} maxBarSize={60}
/> />
<Bar <Line
dataKey="balanco" dataKey="balanco"
fill={chartConfig.balanco.color} type="monotone"
radius={[4, 4, 0, 0]} stroke={chartConfig.balanco.color}
maxBarSize={60} strokeWidth={2}
dot={{ fill: chartConfig.balanco.color, r: 3 }}
activeDot={{ r: 5 }}
/> />
</BarChart> </ComposedChart>
</ChartContainer> </ChartContainer>
<div className="flex items-center justify-center gap-6"> <div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2"> {Object.values(chartConfig).map((config) => (
<div key={config.label} className="flex items-center gap-1.5">
<div <div
className="size-2 rounded-full" className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.receita.color }} style={{ backgroundColor: config.color }}
/> />
<span className="text-sm text-muted-foreground"> <span>{config.label}</span>
{chartConfig.receita.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.despesa.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.despesa.label}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="size-2 rounded-full"
style={{ backgroundColor: chartConfig.balanco.color }}
/>
<span className="text-sm text-muted-foreground">
{chartConfig.balanco.label}
</span>
</div> </div>
))}
</div> </div>
</CardContent> </CardContent>
); );

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { import {
RiArrowRightLine,
RiBarChartBoxLine, RiBarChartBoxLine,
RiExternalLinkLine,
RiEyeLine, RiEyeLine,
RiEyeOffLine, RiEyeOffLine,
} from "@remixicon/react"; } from "@remixicon/react";
@@ -24,7 +24,9 @@ import {
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { isAccountInactive } from "@/shared/lib/accounts/constants"; import { isAccountInactive } from "@/shared/lib/accounts/constants";
import { resolveLogoSrc } from "@/shared/lib/logo"; import { resolveLogoSrc } from "@/shared/lib/logo";
import { buildInitials } from "@/shared/utils/initials";
import { formatPeriodForUrl } from "@/shared/utils/period"; import { formatPeriodForUrl } from "@/shared/utils/period";
import { cn } from "@/shared/utils/ui";
type MyAccountsWidgetProps = { type MyAccountsWidgetProps = {
accounts: DashboardAccount[]; accounts: DashboardAccount[];
@@ -54,9 +56,6 @@ export function MyAccountsWidget({
: activeAccounts.filter((account) => !account.excludeFromBalance); : activeAccounts.filter((account) => !account.excludeFromBalance);
const displayedAccounts = visibleAccounts.slice(0, 5); const displayedAccounts = visibleAccounts.slice(0, 5);
const remainingCount = visibleAccounts.length - displayedAccounts.length; const remainingCount = visibleAccounts.length - displayedAccounts.length;
const hiddenExcludedAccountsCount = showExcludedAccounts
? 0
: excludedAccountsCount;
const toggleButtonLabel = showExcludedAccounts const toggleButtonLabel = showExcludedAccounts
? "Ocultar contas não consideradas" ? "Ocultar contas não consideradas"
: "Mostrar contas não consideradas"; : "Mostrar contas não consideradas";
@@ -81,7 +80,7 @@ export function MyAccountsWidget({
<> <>
<div className="flex items-start justify-between gap-3 py-1"> <div className="flex items-start justify-between gap-3 py-1">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">Saldo Total</p> <p className="text-sm text-muted-foreground">Saldo total</p>
<MoneyValues className="text-2xl font-medium" amount={totalBalance} /> <MoneyValues className="text-2xl font-medium" amount={totalBalance} />
</div> </div>
@@ -106,23 +105,21 @@ export function MyAccountsWidget({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left" className="max-w-xs"> <TooltipContent side="left" className="max-w-xs">
<p className="text-xs">{toggleButtonLabel}</p> <p className="text-xs">{toggleButtonLabel}</p>
{!showExcludedAccounts ? (
<p className="mt-1 text-xs text-background/70">
{excludedAccountsCount}{" "}
{excludedAccountsCount === 1
? "conta não considerada oculta"
: "contas não consideradas ocultas"}
</p>
) : null}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
</div> </div>
{hiddenExcludedAccountsCount > 0 ? (
<p className="pb-2 text-xs text-muted-foreground">
{hiddenExcludedAccountsCount}{" "}
{hiddenExcludedAccountsCount === 1
? "conta não considerada oculta"
: "contas não consideradas ocultas"}
</p>
) : null}
<div> <div>
{activeAccounts.length === 0 ? ( {activeAccounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState <WidgetEmptyState
icon={ icon={
<RiBarChartBoxLine className="size-6 text-muted-foreground" /> <RiBarChartBoxLine className="size-6 text-muted-foreground" />
@@ -130,27 +127,24 @@ export function MyAccountsWidget({
title="Você ainda não adicionou nenhuma conta" title="Você ainda não adicionou nenhuma conta"
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações." description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
/> />
</div>
) : displayedAccounts.length === 0 ? ( ) : displayedAccounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState <WidgetEmptyState
icon={<RiEyeOffLine className="size-6 text-muted-foreground" />} icon={<RiEyeOffLine className="size-6 text-muted-foreground" />}
title="As contas não consideradas estão ocultas" title="As contas não consideradas estão ocultas"
description="Use o botão no topo do widget para mostrá-las novamente." description="Use o botão no topo do widget para mostrá-las novamente."
/> />
</div>
) : ( ) : (
<ul className="flex flex-col"> <ul className="flex flex-col">
{displayedAccounts.map((account, index) => { {displayedAccounts.map((account, index) => {
const logoSrc = resolveLogoSrc(account.logo); const logoSrc = resolveLogoSrc(account.logo);
return ( return (
<div <li
key={account.id} key={account.id}
className="flex items-center justify-between transition-all duration-300 py-1.5 " className="flex items-center justify-between py-1.5 transition-all duration-300"
> >
<div className="flex min-w-0 flex-1 items-center gap-2 py-1"> <div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<div className="relative size-9.5 overflow-hidden"> <div className="relative flex size-9.5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary/10">
{logoSrc ? ( {logoSrc ? (
<Image <Image
src={logoSrc} src={logoSrc}
@@ -160,7 +154,11 @@ export function MyAccountsWidget({
className="object-contain rounded-full" className="object-contain rounded-full"
priority={index === 0} priority={index === 0}
/> />
) : null} ) : (
<span className="text-xs font-medium text-primary">
{buildInitials(account.name)}
</span>
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -172,16 +170,14 @@ export function MyAccountsWidget({
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline" className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
> >
<span className="truncate">{account.name}</span> <span className="truncate">{account.name}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link> </Link>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{account.accountType}</span>
{account.excludeFromBalance ? ( {account.excludeFromBalance ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex cursor-help ml-2"> <span className="inline-flex cursor-help">
<Badge className="font-normal" variant="info"> <Badge className="font-normal" variant="info">
Não considerada Não considerada
</Badge> </Badge>
@@ -190,26 +186,25 @@ export function MyAccountsWidget({
<TooltipContent side="top" className="max-w-xs"> <TooltipContent side="top" className="max-w-xs">
<p className="text-xs"> <p className="text-xs">
Esta conta aparece na lista, mas não entra no Esta conta aparece na lista, mas não entra no
cálculo do saldo total porque está marcada para cálculo do saldo total.
desconsiderar do saldo total.
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{account.accountType}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-0.5 text-right"> <div className="flex flex-col items-end gap-0.5 text-right">
<MoneyValues <MoneyValues
className="font-medium" className={cn(
"font-medium",
account.balance < 0 && "text-destructive",
)}
amount={account.balance} amount={account.balance}
/> />
</div> </div>
</div> </li>
); );
})} })}
</ul> </ul>
@@ -217,8 +212,14 @@ export function MyAccountsWidget({
</div> </div>
{remainingCount > 0 ? ( {remainingCount > 0 ? (
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground"> <CardFooter className="border-border/60 border-t pt-4">
<Link
href="/accounts"
className="inline-flex items-center gap-1 text-sm font-medium text-muted-foreground transition-colors hover:text-primary"
>
+{remainingCount} contas não exibidas +{remainingCount} contas não exibidas
<RiArrowRightLine className="size-4" aria-hidden />
</Link>
</CardFooter> </CardFooter>
) : null} ) : null}
</> </>

View File

@@ -1,10 +1,6 @@
"use client"; "use client";
import { import { RiGroupLine, RiVerifiedBadgeFill } from "@remixicon/react";
RiExternalLinkLine,
RiGroupLine,
RiVerifiedBadgeFill,
} from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import type { DashboardPagador } from "@/features/dashboard/lib/payers-queries"; import type { DashboardPagador } from "@/features/dashboard/lib/payers-queries";
@@ -14,6 +10,11 @@ import {
AvatarFallback, AvatarFallback,
AvatarImage, AvatarImage,
} from "@/shared/components/ui/avatar"; } from "@/shared/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { getAvatarSrc } from "@/shared/lib/payers/utils"; import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { buildInitials } from "@/shared/utils/initials"; import { buildInitials } from "@/shared/utils/initials";
@@ -33,7 +34,7 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
/> />
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
{payers.map((payer) => { {payers.map((payer, index) => {
const initials = buildInitials(payer.name); const initials = buildInitials(payer.name);
const hasValidPercentageChange = const hasValidPercentageChange =
typeof payer.percentageChange === "number" && typeof payer.percentageChange === "number" &&
@@ -45,8 +46,11 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
return ( return (
<div <div
key={payer.id} key={payer.id}
className="flex items-center justify-between transition-all duration-300 py-1.5" className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5"
> >
<span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 py-1"> <div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<Avatar className="size-9.5 shrink-0"> <Avatar className="size-9.5 shrink-0">
<AvatarImage <AvatarImage
@@ -64,18 +68,24 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
> >
<span className="truncate font-medium">{payer.name}</span> <span className="truncate font-medium">{payer.name}</span>
{payer.isAdmin && ( {payer.isAdmin && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<RiVerifiedBadgeFill <RiVerifiedBadgeFill
className="size-4 shrink-0 text-blue-500" className="size-4 text-blue-500"
aria-hidden aria-hidden
/> />
<span className="sr-only">Pessoa principal</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Pessoa principal
</TooltipContent>
</Tooltip>
)} )}
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link> </Link>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{payer.email ?? "Sem email cadastrado"} Despesas no período
</p> </p>
</div> </div>
</div> </div>
@@ -85,7 +95,12 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
className="font-medium" className="font-medium"
amount={payer.totalExpenses} amount={payer.totalExpenses}
/> />
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<PercentageChangeIndicator value={percentageChange} /> <PercentageChangeIndicator value={percentageChange} />
{percentageChange !== null ? (
<span>vs. mês ant.</span>
) : null}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { RiArrowDownSFill, RiStore3Line } from "@remixicon/react"; import { RiFileList2Line, RiStore3Line } from "@remixicon/react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries"; import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
@@ -8,7 +8,9 @@ import MoneyValues from "@/shared/components/money-values";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/components/ui/select"; } from "@/shared/components/ui/select";
@@ -129,18 +131,18 @@ export function PurchasesByCategoryWidget({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(categoriesByType).map(([type, categories]) => ( {Object.entries(categoriesByType).map(([type, categories]) => (
<div key={type}> <SelectGroup key={type}>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground"> <SelectLabel className="font-medium">
{CATEGORY_TYPE_LABEL[ {CATEGORY_TYPE_LABEL[
type as keyof typeof CATEGORY_TYPE_LABEL type as keyof typeof CATEGORY_TYPE_LABEL
] ?? type} ] ?? type}
</div> </SelectLabel>
{categories.map((category) => ( {categories.map((category) => (
<SelectItem key={category.id} value={category.id}> <SelectItem key={category.id} value={category.id}>
{category.name} {category.name}
</SelectItem> </SelectItem>
))} ))}
</div> </SelectGroup>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -148,12 +150,12 @@ export function PurchasesByCategoryWidget({
{currentTransactions.length === 0 ? ( {currentTransactions.length === 0 ? (
<WidgetEmptyState <WidgetEmptyState
icon={<RiArrowDownSFill className="size-6 text-muted-foreground" />} icon={<RiFileList2Line className="size-6 text-muted-foreground" />}
title="Nenhuma compra encontrada" title="Nenhum lançamento encontrado"
description={ description={
selectedCategory selectedCategory
? `Não há lançamentos na categoria "${selectedCategory.name}".` ? `Não há lançamentos na categoria "${selectedCategory.name}".`
: "Selecione uma categoria para visualizar as compras." : "Selecione uma categoria para visualizar os lançamentos."
} }
/> />
) : ( ) : (
@@ -162,9 +164,9 @@ export function PurchasesByCategoryWidget({
return ( return (
<div <div
key={transaction.id} key={transaction.id}
className="flex items-center justify-between gap-3 transition-all duration-300 py-2" className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5"
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-2">
<EstablishmentLogo name={transaction.name} size={37} /> <EstablishmentLogo name={transaction.name} size={37} />
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -3,6 +3,7 @@ import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurr
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { getPaymentMethodIcon } from "@/shared/utils/icons";
type RecurringExpensesWidgetProps = { type RecurringExpensesWidgetProps = {
data: RecurringExpensesData; data: RecurringExpensesData;
@@ -10,10 +11,10 @@ type RecurringExpensesWidgetProps = {
const formatOccurrences = (value: number | null) => { const formatOccurrences = (value: number | null) => {
if (!value) { if (!value) {
return "Recorrência contínua"; return "Repete mensalmente";
} }
return `${value} recorrências mensais`; return `Repete por ${value} ${value === 1 ? "mês" : "meses"}`;
}; };
export function RecurringExpensesWidget({ export function RecurringExpensesWidget({
@@ -23,7 +24,7 @@ export function RecurringExpensesWidget({
return ( return (
<WidgetEmptyState <WidgetEmptyState
icon={<RiRefreshLine className="size-6 text-muted-foreground" />} icon={<RiRefreshLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa recorrente" title="Nenhuma despesa recorrente encontrada"
description="Lançamentos recorrentes aparecerão aqui conforme forem registrados." description="Lançamentos recorrentes aparecerão aqui conforme forem registrados."
/> />
); );
@@ -31,7 +32,9 @@ export function RecurringExpensesWidget({
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
{data.expenses.map((expense) => { {[...data.expenses]
.sort((a, b) => b.amount - a.amount)
.map((expense) => {
return ( return (
<div <div
key={expense.id} key={expense.id}
@@ -45,11 +48,15 @@ export function RecurringExpensesWidget({
{expense.name} {expense.name}
</p> </p>
<MoneyValues className="font-medium" amount={expense.amount} /> <MoneyValues
className="font-medium"
amount={expense.amount}
/>
</div> </div>
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1 [&_svg]:size-3.5">
{getPaymentMethodIcon(expense.paymentMethod)}
{expense.paymentMethod} {expense.paymentMethod}
</span> </span>
<span>{formatOccurrences(expense.recurrenceCount)}</span> <span>{formatOccurrences(expense.recurrenceCount)}</span>

View File

@@ -15,13 +15,11 @@ import { TopExpensesWidget } from "./top-expenses-widget";
type SpendingOverviewWidgetProps = { type SpendingOverviewWidgetProps = {
topExpensesAll: TopExpensesData; topExpensesAll: TopExpensesData;
topExpensesCardOnly: TopExpensesData;
topEstablishmentsData: TopEstablishmentsData; topEstablishmentsData: TopEstablishmentsData;
}; };
export function SpendingOverviewWidget({ export function SpendingOverviewWidget({
topExpensesAll, topExpensesAll,
topExpensesCardOnly,
topEstablishmentsData, topEstablishmentsData,
}: SpendingOverviewWidgetProps) { }: SpendingOverviewWidgetProps) {
const [activeTab, setActiveTab] = useState<"expenses" | "establishments">( const [activeTab, setActiveTab] = useState<"expenses" | "establishments">(
@@ -54,10 +52,7 @@ export function SpendingOverviewWidget({
</TabsList> </TabsList>
<TabsContent value="expenses" className="mt-2"> <TabsContent value="expenses" className="mt-2">
<TopExpensesWidget <TopExpensesWidget data={topExpensesAll} />
allExpenses={topExpensesAll}
cardOnlyExpenses={topExpensesCardOnly}
/>
</TabsContent> </TabsContent>
<TabsContent value="establishments" className="mt-2"> <TabsContent value="establishments" className="mt-2">

View File

@@ -28,13 +28,16 @@ export function TopEstablishmentsWidget({
/> />
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
{data.establishments.map((establishment) => { {data.establishments.map((establishment, index) => {
return ( return (
<div <div
key={establishment.id} key={establishment.id}
className="flex items-center justify-between gap-3 transition-all duration-300 py-2" className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5"
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<EstablishmentLogo name={establishment.name} size={37} /> <EstablishmentLogo name={establishment.name} size={37} />
<div className="min-w-0"> <div className="min-w-0">
@@ -42,7 +45,8 @@ export function TopEstablishmentsWidget({
{establishment.name} {establishment.name}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatOccurrencesLabel(establishment.occurrences)} {formatOccurrencesLabel(establishment.occurrences)} ·
total acumulado
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,20 +1,18 @@
"use client"; "use client";
import { RiArrowUpDoubleLine } from "@remixicon/react"; import { RiArrowUpDoubleLine } from "@remixicon/react";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import type { import type {
TopExpense, TopExpense,
TopExpensesData, TopExpensesData,
} from "@/features/dashboard/expenses/top-expenses-queries"; } from "@/features/dashboard/expenses/top-expenses-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Switch } from "@/shared/components/ui/switch";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { formatTransactionDate } from "@/shared/utils/date"; import { formatTransactionDate } from "@/shared/utils/date";
type TopExpensesWidgetProps = { type TopExpensesWidgetProps = {
allExpenses: TopExpensesData; data: TopExpensesData;
cardOnlyExpenses: TopExpensesData;
}; };
const shouldIncludeExpense = (expense: TopExpense) => { const shouldIncludeExpense = (expense: TopExpense) => {
@@ -31,58 +29,15 @@ const shouldIncludeExpense = (expense: TopExpense) => {
return true; return true;
}; };
const isCardExpense = (expense: TopExpense) => export function TopExpensesWidget({ data }: TopExpensesWidgetProps) {
expense.paymentMethod?.toLowerCase().includes("cartão") ?? false; const expenses = useMemo(
() => data.expenses.filter(shouldIncludeExpense),
export function TopExpensesWidget({ [data.expenses],
allExpenses, );
cardOnlyExpenses,
}: TopExpensesWidgetProps) {
const [cardOnly, setCardOnly] = useState(false);
const normalizedAllExpenses = useMemo(() => {
return allExpenses.expenses.filter(shouldIncludeExpense);
}, [allExpenses]);
const normalizedCardOnlyExpenses = useMemo(() => {
const merged = [...cardOnlyExpenses.expenses, ...normalizedAllExpenses];
const seen = new Set<string>();
return merged.filter((expense) => {
if (seen.has(expense.id)) {
return false;
}
if (!isCardExpense(expense) || !shouldIncludeExpense(expense)) {
return false;
}
seen.add(expense.id);
return true;
});
}, [cardOnlyExpenses, normalizedAllExpenses]);
const data = cardOnly
? { expenses: normalizedCardOnlyExpenses }
: { expenses: normalizedAllExpenses };
return ( return (
<div className="flex flex-col gap-4 px-0"> <div className="flex flex-col px-0">
<div className="flex items-center justify-between gap-3"> {expenses.length === 0 ? (
<label
htmlFor="card-only-toggle"
className="text-sm text-muted-foreground"
>
Apenas cartões
</label>
<Switch
id="card-only-toggle"
checked={cardOnly}
onCheckedChange={setCardOnly}
/>
</div>
{data.expenses.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState <WidgetEmptyState
icon={ icon={
<RiArrowUpDoubleLine className="size-6 text-muted-foreground" /> <RiArrowUpDoubleLine className="size-6 text-muted-foreground" />
@@ -90,16 +45,18 @@ export function TopExpensesWidget({
title="Nenhuma despesa encontrada" title="Nenhuma despesa encontrada"
description="Quando houver despesas registradas, elas aparecerão aqui." description="Quando houver despesas registradas, elas aparecerão aqui."
/> />
</div>
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
{data.expenses.map((expense) => { {expenses.map((expense, index) => {
return ( return (
<div <div
key={expense.id} key={expense.id}
className="flex items-center justify-between gap-3 transition-all duration-300 py-2" className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5"
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<EstablishmentLogo name={expense.name} size={37} /> <EstablishmentLogo name={expense.name} size={37} />
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -21,6 +21,7 @@ type WidgetSettingsDialogProps = {
onToggleWidget: (widgetId: string) => void; onToggleWidget: (widgetId: string) => void;
onReset: () => void; onReset: () => void;
triggerClassName?: string; triggerClassName?: string;
triggerLabel?: string;
}; };
export function WidgetSettingsDialog({ export function WidgetSettingsDialog({
@@ -28,6 +29,7 @@ export function WidgetSettingsDialog({
onToggleWidget, onToggleWidget,
onReset, onReset,
triggerClassName, triggerClassName,
triggerLabel = "Widgets",
}: WidgetSettingsDialogProps) { }: WidgetSettingsDialogProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -40,12 +42,12 @@ export function WidgetSettingsDialog({
className={cn("gap-2", triggerClassName)} className={cn("gap-2", triggerClassName)}
> >
<RiSettings4Line className="size-4" /> <RiSettings4Line className="size-4" />
Widgets {triggerLabel}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Configurar Widgets</DialogTitle> <DialogTitle>Configurar widgets</DialogTitle>
<DialogDescription> <DialogDescription>
Escolha quais widgets deseja exibir no seu dashboard. Escolha quais widgets deseja exibir no seu dashboard.
</DialogDescription> </DialogDescription>
@@ -73,6 +75,7 @@ export function WidgetSettingsDialog({
</div> </div>
</div> </div>
<Switch <Switch
aria-label={`${isVisible ? "Ocultar" : "Exibir"} widget ${widget.title}`}
checked={isVisible} checked={isVisible}
onCheckedChange={() => onToggleWidget(widget.id)} onCheckedChange={() => onToggleWidget(widget.id)}
/> />
@@ -90,7 +93,7 @@ export function WidgetSettingsDialog({
className="gap-2" className="gap-2"
> >
<RiRefreshLine className="size-4" /> <RiRefreshLine className="size-4" />
Restaurar Padrão Restaurar padrão
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -192,6 +192,22 @@ export async function fetchInstallmentAnalysis(
(i) => !i.isSettled, (i) => !i.isSettled,
); );
return hasUnpaidInstallments; return hasUnpaidInstallments;
})
.sort((a, b) => {
const progressA =
a.trackedInstallments > 0
? a.paidInstallments / a.trackedInstallments
: 0;
const progressB =
b.trackedInstallments > 0
? b.paidInstallments / b.trackedInstallments
: 0;
if (progressA !== progressB) {
return progressB - progressA;
}
return a.firstPurchaseDate.getTime() - b.firstPurchaseDate.getTime();
}); });
// Calcular totais // Calcular totais

View File

@@ -1,12 +1,11 @@
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries"; import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
import { import { calculateLastInstallmentDate } from "@/shared/lib/installments/utils";
calculateLastInstallmentDate, import { capitalize } from "@/shared/utils/string";
formatLastInstallmentDate,
} from "@/shared/lib/installments/utils";
type InstallmentExpenseDisplay = { type InstallmentExpenseDisplay = {
compactLabel: string | null; compactLabel: string | null;
isLast: boolean; isLast: boolean;
remainingLabel: "Próximas" | "Em aberto";
remainingInstallments: number; remainingInstallments: number;
remainingAmount: number; remainingAmount: number;
endDate: string | null; endDate: string | null;
@@ -18,7 +17,7 @@ const buildInstallmentCompactLabel = (
installmentCount: number | null, installmentCount: number | null,
) => { ) => {
if (currentInstallment && installmentCount) { if (currentInstallment && installmentCount) {
return `${currentInstallment} de ${installmentCount}`; return `Parcela ${currentInstallment} de ${installmentCount}`;
} }
return null; return null;
@@ -38,21 +37,30 @@ const isInstallmentLast = (
const calculateInstallmentRemainingCount = ( const calculateInstallmentRemainingCount = (
currentInstallment: number | null, currentInstallment: number | null,
installmentCount: number | null, installmentCount: number | null,
isSettled: boolean | null,
) => { ) => {
if (!currentInstallment || !installmentCount) { if (!currentInstallment || !installmentCount) {
return 0; return 0;
} }
return Math.max(0, installmentCount - currentInstallment); const includeCurrentInstallment = isSettled !== true;
const currentOffset = includeCurrentInstallment ? 1 : 0;
return Math.max(0, installmentCount - currentInstallment + currentOffset);
}; };
const calculateInstallmentRemainingAmount = ( const calculateInstallmentRemainingAmount = (
amount: number, amount: number,
currentInstallment: number | null, currentInstallment: number | null,
installmentCount: number | null, installmentCount: number | null,
isSettled: boolean | null,
) => ) =>
amount * amount *
calculateInstallmentRemainingCount(currentInstallment, installmentCount); calculateInstallmentRemainingCount(
currentInstallment,
installmentCount,
isSettled,
);
const formatInstallmentEndDate = ( const formatInstallmentEndDate = (
period: string, period: string,
@@ -69,7 +77,12 @@ const formatInstallmentEndDate = (
installmentCount, installmentCount,
); );
return formatLastInstallmentDate(lastDate); const month = new Intl.DateTimeFormat("pt-BR", {
month: "short",
timeZone: "UTC",
}).format(lastDate);
return `${capitalize(month)} de ${lastDate.getFullYear()}`;
}; };
const buildInstallmentProgress = ( const buildInstallmentProgress = (
@@ -89,7 +102,8 @@ const buildInstallmentProgress = (
export const buildInstallmentExpenseDisplay = ( export const buildInstallmentExpenseDisplay = (
expense: InstallmentExpense, expense: InstallmentExpense,
): InstallmentExpenseDisplay => { ): InstallmentExpenseDisplay => {
const { amount, currentInstallment, installmentCount, period } = expense; const { amount, currentInstallment, installmentCount, isSettled, period } =
expense;
return { return {
compactLabel: buildInstallmentCompactLabel( compactLabel: buildInstallmentCompactLabel(
@@ -97,14 +111,17 @@ export const buildInstallmentExpenseDisplay = (
installmentCount, installmentCount,
), ),
isLast: isInstallmentLast(currentInstallment, installmentCount), isLast: isInstallmentLast(currentInstallment, installmentCount),
remainingLabel: isSettled === true ? "Próximas" : "Em aberto",
remainingInstallments: calculateInstallmentRemainingCount( remainingInstallments: calculateInstallmentRemainingCount(
currentInstallment, currentInstallment,
installmentCount, installmentCount,
isSettled,
), ),
remainingAmount: calculateInstallmentRemainingAmount( remainingAmount: calculateInstallmentRemainingAmount(
amount, amount,
currentInstallment, currentInstallment,
installmentCount, installmentCount,
isSettled,
), ),
endDate: formatInstallmentEndDate( endDate: formatInstallmentEndDate(
period, period,

View File

@@ -8,6 +8,7 @@ export type InstallmentExpense = {
dueDate: Date | null; dueDate: Date | null;
purchaseDate: Date; purchaseDate: Date;
period: string; period: string;
isSettled: boolean | null;
}; };
export type InstallmentExpensesData = { export type InstallmentExpensesData = {

View File

@@ -65,7 +65,6 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
installmentExpensesData: currentPeriodOverview.installmentExpensesData, installmentExpensesData: currentPeriodOverview.installmentExpensesData,
topEstablishmentsData: currentPeriodOverview.topEstablishmentsData, topEstablishmentsData: currentPeriodOverview.topEstablishmentsData,
topExpensesAll: currentPeriodOverview.topExpensesAll, topExpensesAll: currentPeriodOverview.topExpensesAll,
topExpensesCardOnly: currentPeriodOverview.topExpensesCardOnly,
purchasesByCategoryData: currentPeriodOverview.purchasesByCategoryData, purchasesByCategoryData: currentPeriodOverview.purchasesByCategoryData,
incomeByCategoryData: categoryOverview.incomeByCategoryData, incomeByCategoryData: categoryOverview.incomeByCategoryData,
expensesByCategoryData: categoryOverview.expensesByCategoryData, expensesByCategoryData: categoryOverview.expensesByCategoryData,

View File

@@ -4,7 +4,11 @@ import {
INVOICE_PAYMENT_STATUS, INVOICE_PAYMENT_STATUS,
type InvoicePaymentStatus, type InvoicePaymentStatus,
} from "@/shared/lib/invoices"; } from "@/shared/lib/invoices";
import { getBusinessDateString } from "@/shared/utils/date"; import {
getBusinessDateString,
parseUtcDateString,
toDateOnlyString,
} from "@/shared/utils/date";
import { import {
buildDueDateInfoFromPeriodDay, buildDueDateInfoFromPeriodDay,
buildRelativeDueDateInfoFromPeriodDay, buildRelativeDueDateInfoFromPeriodDay,
@@ -80,6 +84,29 @@ export const formatInvoiceWidgetPaymentDate = (
}; };
}; };
export const formatInvoiceWidgetOverdueLabel = (
value: string | null,
): string | null => {
const dueDateValue = toDateOnlyString(value);
const todayValue = getBusinessDateString();
if (!dueDateValue || dueDateValue >= todayValue) {
return null;
}
const dueDate = parseUtcDateString(dueDateValue);
const today = parseUtcDateString(todayValue);
if (!dueDate || !today) {
return null;
}
const overdueDays = Math.round(
(today.getTime() - dueDate.getTime()) / (24 * 60 * 60 * 1000),
);
return overdueDays === 1
? "Atrasada · venceu ontem"
: `Atrasada · venceu há ${overdueDays} dias`;
};
export const getCurrentDateString = () => getBusinessDateString(); export const getCurrentDateString = () => getBusinessDateString();
const formatInvoiceSharePercentage = (value: number) => { const formatInvoiceSharePercentage = (value: number) => {

View File

@@ -1,6 +1,9 @@
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema"; import { financialAccounts, transactions } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants"; import {
INITIAL_BALANCE_NOTE,
isAccountInactive,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number"; import { safeToNumber as toNumber } from "@/shared/utils/number";
@@ -101,7 +104,10 @@ export async function fetchDashboardAccounts(
.sort((a, b) => b.balance - a.balance); .sort((a, b) => b.balance - a.balance);
const totalBalance = accounts const totalBalance = accounts
.filter((account) => !account.excludeFromBalance) .filter(
(account) =>
!account.excludeFromBalance && !isAccountInactive(account.status),
)
.reduce((total, account) => total + account.balance, 0); .reduce((total, account) => total + account.balance, 0);
return { return {

View File

@@ -16,8 +16,6 @@ export function extractDashboardLogoNames(data: DashboardData): string[] {
for (const establishment of data.topEstablishmentsData.establishments) for (const establishment of data.topEstablishmentsData.establishments)
names.push(establishment.name); names.push(establishment.name);
for (const expense of data.topExpensesAll.expenses) names.push(expense.name); for (const expense of data.topExpensesAll.expenses) names.push(expense.name);
for (const expense of data.topExpensesCardOnly.expenses)
names.push(expense.name);
for (const transactions of Object.values( for (const transactions of Object.values(
data.purchasesByCategoryData.transactionsByCategory, data.purchasesByCategoryData.transactionsByCategory,
)) { )) {

View File

@@ -1,10 +1,13 @@
import { eq } from "drizzle-orm"; import { and, asc, eq, ilike, not, sql } from "drizzle-orm";
import { cacheLife, cacheTag } from "next/cache"; import { cacheLife, cacheTag } from "next/cache";
import { payers } from "@/db/schema"; import { cards, financialAccounts, payers, transactions } from "@/db/schema";
import { fetchPendingInboxCount } from "@/features/inbox/queries"; import { fetchPendingInboxCount } from "@/features/inbox/queries";
import type { NavbarFinanceLinks } from "@/shared/components/navigation/navbar/nav-items";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { getBusinessDateString } from "@/shared/utils/date"; import { getBusinessDateString } from "@/shared/utils/date";
import { safeToNumber } from "@/shared/utils/number";
import { import {
type DashboardNotificationsSnapshot, type DashboardNotificationsSnapshot,
fetchDashboardNotifications, fetchDashboardNotifications,
@@ -14,13 +17,13 @@ type DashboardNavbarData = {
payerAvatarUrl: string | null; payerAvatarUrl: string | null;
inboxPendingCount: number; inboxPendingCount: number;
notificationsSnapshot: DashboardNotificationsSnapshot; notificationsSnapshot: DashboardNotificationsSnapshot;
financeLinks: NavbarFinanceLinks;
}; };
async function fetchAdminPayerAvatarUrl( async function fetchAdminPayerAvatarUrl(
userId: string, userId: string,
adminPayerId: string | null,
): Promise<string | null> { ): Promise<string | null> {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) { if (!adminPayerId) {
return null; return null;
} }
@@ -29,7 +32,7 @@ async function fetchAdminPayerAvatarUrl(
columns: { columns: {
avatarUrl: true, avatarUrl: true,
}, },
where: eq(payers.id, adminPayerId), where: and(eq(payers.id, adminPayerId), eq(payers.userId, userId)),
}); });
return payer?.avatarUrl ?? null; return payer?.avatarUrl ?? null;
@@ -39,17 +42,97 @@ async function fetchDashboardNavbarDataInternal(
userId: string, userId: string,
): Promise<DashboardNavbarData> { ): Promise<DashboardNavbarData> {
const currentPeriod = getBusinessDateString().slice(0, 7); const currentPeriod = getBusinessDateString().slice(0, 7);
const [payerAvatarUrl, notificationsSnapshot, inboxPendingCount] = const adminPayerId = await getAdminPayerId(userId);
await Promise.all([ const [
fetchAdminPayerAvatarUrl(userId), payerAvatarUrl,
notificationsSnapshot,
inboxPendingCount,
activeCards,
activeAccounts,
] = await Promise.all([
fetchAdminPayerAvatarUrl(userId, adminPayerId),
fetchDashboardNotifications(userId, currentPeriod), fetchDashboardNotifications(userId, currentPeriod),
fetchPendingInboxCount(userId), fetchPendingInboxCount(userId),
db
.select({
id: cards.id,
name: cards.name,
logo: cards.logo,
amount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(cards)
.leftJoin(
transactions,
and(
eq(transactions.cardId, cards.id),
eq(transactions.userId, userId),
eq(transactions.period, currentPeriod),
),
)
.where(and(eq(cards.userId, userId), not(ilike(cards.status, "inativo"))))
.groupBy(cards.id, cards.name, cards.logo)
.orderBy(asc(cards.name)),
db
.select({
id: financialAccounts.id,
name: financialAccounts.name,
logo: financialAccounts.logo,
initialBalance: financialAccounts.initialBalance,
balanceMovements: sql<number>`
coalesce(
sum(
case
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${transactions.amount}
end
),
0
)
`,
})
.from(financialAccounts)
.leftJoin(
transactions,
and(
eq(transactions.accountId, financialAccounts.id),
eq(transactions.userId, userId),
eq(transactions.isSettled, true),
adminPayerId ? eq(transactions.payerId, adminPayerId) : sql`false`,
),
)
.where(
and(
eq(financialAccounts.userId, userId),
not(ilike(financialAccounts.status, "inativa")),
),
)
.groupBy(
financialAccounts.id,
financialAccounts.name,
financialAccounts.logo,
financialAccounts.initialBalance,
)
.orderBy(asc(financialAccounts.name)),
]); ]);
return { return {
payerAvatarUrl, payerAvatarUrl,
inboxPendingCount, inboxPendingCount,
notificationsSnapshot, notificationsSnapshot,
financeLinks: {
cards: activeCards.map((card) => ({
...card,
amount: Math.abs(safeToNumber(card.amount)),
})),
accounts: activeAccounts.map((account) => ({
id: account.id,
name: account.name,
logo: account.logo,
amount:
safeToNumber(account.initialBalance) +
safeToNumber(account.balanceMovements),
})),
},
}; };
} }

View File

@@ -245,7 +245,7 @@ export async function fetchDashboardNotifications(
]); ]);
// ===================== // =====================
// Processar notificações // lançar notificações
// ===================== // =====================
const notifications: DashboardNotification[] = []; const notifications: DashboardNotification[] = [];

View File

@@ -34,7 +34,6 @@ import {
import { safeToNumber as toNumber } from "@/shared/utils/number"; import { safeToNumber as toNumber } from "@/shared/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto"; const PAYMENT_METHOD_BOLETO = "Boleto";
const PAYMENT_METHOD_CARD = "Cartão de Crédito";
const TRANSACTION_TYPE_EXPENSE = "Despesa"; const TRANSACTION_TYPE_EXPENSE = "Despesa";
const TRANSACTION_TYPE_INCOME = "Receita"; const TRANSACTION_TYPE_INCOME = "Receita";
const CONDITION_RECURRING = "Recorrente"; const CONDITION_RECURRING = "Recorrente";
@@ -79,7 +78,6 @@ type DashboardCurrentPeriodOverview = {
installmentExpensesData: InstallmentExpensesData; installmentExpensesData: InstallmentExpensesData;
topEstablishmentsData: TopEstablishmentsData; topEstablishmentsData: TopEstablishmentsData;
topExpensesAll: TopExpensesData; topExpensesAll: TopExpensesData;
topExpensesCardOnly: TopExpensesData;
purchasesByCategoryData: PurchasesByCategoryData; purchasesByCategoryData: PurchasesByCategoryData;
}; };
@@ -99,7 +97,6 @@ const emptyOverview = (): DashboardCurrentPeriodOverview => ({
installmentExpensesData: { expenses: [] }, installmentExpensesData: { expenses: [] },
topEstablishmentsData: { establishments: [] }, topEstablishmentsData: { establishments: [] },
topExpensesAll: { expenses: [] }, topExpensesAll: { expenses: [] },
topExpensesCardOnly: { expenses: [] },
purchasesByCategoryData: { purchasesByCategoryData: {
categories: [], categories: [],
transactionsByCategory: {}, transactionsByCategory: {},
@@ -190,6 +187,7 @@ const buildBillsSnapshot = (
: null, : null,
isSettled: Boolean(row.isSettled), isSettled: Boolean(row.isSettled),
accountId: row.accountId ?? null, accountId: row.accountId ?? null,
transactionType: row.transactionType,
})) }))
.sort((a, b) => { .sort((a, b) => {
if (a.isSettled !== b.isSettled) { if (a.isSettled !== b.isSettled) {
@@ -405,6 +403,7 @@ const buildInstallmentExpensesData = (
dueDate: row.dueDate, dueDate: row.dueDate,
purchaseDate: row.purchaseDate, purchaseDate: row.purchaseDate,
period: row.period, period: row.period,
isSettled: row.isSettled,
})) }))
.sort((a, b) => { .sort((a, b) => {
const remainingA = const remainingA =
@@ -431,14 +430,12 @@ const mapTopExpense = (row: CurrentPeriodTransactionRow): TopExpense => ({
const buildTopExpensesData = ( const buildTopExpensesData = (
rows: CurrentPeriodTransactionRow[], rows: CurrentPeriodTransactionRow[],
cardOnly: boolean,
): TopExpensesData => ({ ): TopExpensesData => ({
expenses: rows expenses: rows
.filter( .filter(
(row) => (row) =>
row.transactionType === TRANSACTION_TYPE_EXPENSE && row.transactionType === TRANSACTION_TYPE_EXPENSE &&
shouldIncludeWithoutAutoGenerated(row.note) && shouldIncludeWithoutAutoGenerated(row.note),
(!cardOnly || row.paymentMethod === PAYMENT_METHOD_CARD),
) )
.sort((a, b) => toNumber(a.amount) - toNumber(b.amount)) .sort((a, b) => toNumber(a.amount) - toNumber(b.amount))
.slice(0, 10) .slice(0, 10)
@@ -616,8 +613,7 @@ export async function fetchDashboardCurrentPeriodOverview(
recurringExpensesData: buildRecurringExpensesData(rows), recurringExpensesData: buildRecurringExpensesData(rows),
installmentExpensesData: buildInstallmentExpensesData(rows), installmentExpensesData: buildInstallmentExpensesData(rows),
topEstablishmentsData: buildTopEstablishmentsData(rows), topEstablishmentsData: buildTopEstablishmentsData(rows),
topExpensesAll: buildTopExpensesData(rows, false), topExpensesAll: buildTopExpensesData(rows),
topExpensesCardOnly: buildTopExpensesData(rows, true),
purchasesByCategoryData: buildPurchasesByCategoryData(rows), purchasesByCategoryData: buildPurchasesByCategoryData(rows),
}; };
} }

View File

@@ -69,8 +69,8 @@ export type WidgetConfig = {
export const widgetsConfig: WidgetConfig[] = [ export const widgetsConfig: WidgetConfig[] = [
{ {
id: "my-accounts", id: "my-accounts",
title: "Minhas Contas", title: "Minhas contas",
subtitle: "Saldo consolidado disponível", subtitle: "Saldo atualizado das contas ativas",
icon: <RiBarChartBoxLine className="size-4" />, icon: <RiBarChartBoxLine className="size-4" />,
component: ({ component: ({
data, data,
@@ -114,7 +114,7 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "payment-status", id: "payment-status",
title: "Status de Pagamento", title: "Status de pagamento",
subtitle: "Valores confirmados e pendentes", subtitle: "Valores confirmados e pendentes",
icon: <RiWallet3Line className="size-4" />, icon: <RiWallet3Line className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
@@ -144,8 +144,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "income-expense-balance", id: "income-expense-balance",
title: "Receita, Despesa e Balanço", title: "Receita, despesa e balanço",
subtitle: "Últimos 6 meses", subtitle: "Últimos 6 meses até o período selecionado",
icon: <RiLineChartLine className="size-4" />, icon: <RiLineChartLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} /> <IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} />
@@ -153,8 +153,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "goals-progress", id: "goals-progress",
title: "Progresso de Orçamentos", title: "Progresso de orçamentos",
subtitle: "Orçamentos por categoria no período", subtitle: "Categorias mais próximas do limite",
icon: <RiExchangeLine className="size-4" />, icon: <RiExchangeLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<GoalsProgressWidget data={data.goalsProgressData} /> <GoalsProgressWidget data={data.goalsProgressData} />
@@ -171,7 +171,7 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "category-trends", id: "category-trends",
title: "Tendências de Categorias", title: "Tendências de categorias",
subtitle: "Top 10 maiores variações vs. mês anterior", subtitle: "Top 10 maiores variações vs. mês anterior",
icon: <RiLineChartLine className="size-4" />, icon: <RiLineChartLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
@@ -182,21 +182,20 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "spending-overview", id: "spending-overview",
title: "Panorama de Gastos", title: "Panorama de gastos",
subtitle: "Principais despesas e frequência por local", subtitle: "Principais despesas e frequência por local",
icon: <RiArrowUpDoubleLine className="size-4" />, icon: <RiArrowUpDoubleLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<SpendingOverviewWidget <SpendingOverviewWidget
topExpensesAll={data.topExpensesAll} topExpensesAll={data.topExpensesAll}
topExpensesCardOnly={data.topExpensesCardOnly}
topEstablishmentsData={data.topEstablishmentsData} topEstablishmentsData={data.topEstablishmentsData}
/> />
), ),
}, },
{ {
id: "payment-overview", id: "payment-overview",
title: "Comportamento de Pagamento", title: "Distribuição de despesas",
subtitle: "Despesas por condição e forma de pagamento", subtitle: "Por condição e forma de pagamento",
icon: <RiWallet3Line className="size-4" />, icon: <RiWallet3Line className="size-4" />,
component: ({ data, period, adminPayerSlug }) => ( component: ({ data, period, adminPayerSlug }) => (
<PaymentOverviewWidget <PaymentOverviewWidget
@@ -209,8 +208,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "expenses-by-category", id: "expenses-by-category",
title: "Categorias por Despesas", title: "Despesas por categoria",
subtitle: "Distribuição de despesas por categoria", subtitle: "Maiores despesas por categoria",
icon: <RiPieChartLine className="size-4" />, icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => ( component: ({ data, period }) => (
<ExpensesByCategoryWidgetWithChart <ExpensesByCategoryWidgetWithChart
@@ -221,8 +220,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "income-by-category", id: "income-by-category",
title: "Categorias por Receitas", title: "Receitas por categoria",
subtitle: "Distribuição de receitas por categoria", subtitle: "Maiores receitas por categoria",
icon: <RiPieChartLine className="size-4" />, icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => ( component: ({ data, period }) => (
<IncomeByCategoryWidgetWithChart <IncomeByCategoryWidgetWithChart
@@ -233,8 +232,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "purchases-by-category", id: "purchases-by-category",
title: "Lançamentos por Categorias", title: "Lançamentos por categoria",
subtitle: "Distribuição de lançamentos por categoria", subtitle: "Lançamentos recentes da categoria selecionada",
icon: <RiStore3Line className="size-4" />, icon: <RiStore3Line className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} /> <PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
@@ -242,8 +241,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "recurring-expenses", id: "recurring-expenses",
title: "Lançamentos Recorrentes", title: "Despesas recorrentes",
subtitle: "Despesas recorrentes do período", subtitle: "Despesas recorrentes deste mês",
icon: <RiRefreshLine className="size-4" />, icon: <RiRefreshLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<RecurringExpensesWidget data={data.recurringExpensesData} /> <RecurringExpensesWidget data={data.recurringExpensesData} />
@@ -251,8 +250,8 @@ export const widgetsConfig: WidgetConfig[] = [
}, },
{ {
id: "installment-expenses", id: "installment-expenses",
title: "Lançamentos Parcelados", title: "Despesas parceladas",
subtitle: "Acompanhe as parcelas abertas", subtitle: "Parcelamentos mais próximos da quitação",
icon: <RiNumbersLine className="size-4" />, icon: <RiNumbersLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<InstallmentExpensesWidget data={data.installmentExpensesData} /> <InstallmentExpensesWidget data={data.installmentExpensesData} />
@@ -261,7 +260,7 @@ export const widgetsConfig: WidgetConfig[] = [
{ {
id: "pagadores", id: "pagadores",
title: "Pessoas", title: "Pessoas",
subtitle: "Despesas por pessoa no período", subtitle: "Maiores despesas por pessoa no período",
icon: <RiGroupLine className="size-4" />, icon: <RiGroupLine className="size-4" />,
component: ({ data }) => ( component: ({ data }) => (
<PayersWidget payers={data.pagadoresSnapshot.payers} /> <PayersWidget payers={data.pagadoresSnapshot.payers} />
@@ -279,7 +278,7 @@ export const widgetsConfig: WidgetConfig[] = [
{ {
id: "notes", id: "notes",
title: "Anotações", title: "Anotações",
subtitle: "Últimas anotações ativas", subtitle: "Anotações ativas adicionadas recentemente",
icon: <RiTodoLine className="size-4" />, icon: <RiTodoLine className="size-4" />,
component: ({ data }) => <NotesWidget notes={data.notesData} />, component: ({ data }) => <NotesWidget notes={data.notesData} />,
action: ( action: (

View File

@@ -206,7 +206,7 @@ export const InboxCard = memo(function InboxCard({
onClick={() => onProcess?.(item)} onClick={() => onProcess?.(item)}
> >
<RiCheckLine className="mr-1.5 size-4" /> <RiCheckLine className="mr-1.5 size-4" />
Processar Lançar
</Button> </Button>
<Button <Button
size="icon-sm" size="icon-sm"

View File

@@ -129,7 +129,7 @@ export function InboxDetailsDialog({
onProcess(item); onProcess(item);
}} }}
> >
Processar Lançar
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>

View File

@@ -1,16 +1,14 @@
"use server"; "use server";
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import { openai } from "@ai-sdk/openai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { generateObject } from "ai"; import { generateObject } from "ai";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { import {
type InsightsResponse, type InsightsResponse,
InsightsResponseSchema, InsightsResponseSchema,
} from "@/shared/lib/schemas/insights"; } from "@/shared/lib/schemas/insights";
import { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "../constants"; import { INSIGHTS_SYSTEM_PROMPT } from "../constants";
import { resolveInsightsModel } from "../lib/model-provider";
import { USER_INSTRUCTIONS_MAX_LENGTH } from "../lib/user-instructions";
import { aggregateMonthData } from "./aggregate"; import { aggregateMonthData } from "./aggregate";
import type { ActionResult } from "./types"; import type { ActionResult } from "./types";
@@ -19,6 +17,7 @@ const PERIOD_REGEX = /^\d{4}-\d{2}$/;
export async function generateInsightsAction( export async function generateInsightsAction(
period: string, period: string,
modelId: string, modelId: string,
userInstructions?: string,
): Promise<ActionResult<InsightsResponse>> { ): Promise<ActionResult<InsightsResponse>> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -30,50 +29,23 @@ export async function generateInsightsAction(
}; };
} }
const selectedModel = AVAILABLE_MODELS.find((m) => m.id === modelId); const normalizedUserInstructions = userInstructions?.trim() ?? "";
const isOpenRouterFormat = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/.test( if (normalizedUserInstructions.length > USER_INSTRUCTIONS_MAX_LENGTH) {
modelId,
);
if (!selectedModel && !isOpenRouterFormat) {
return { return {
success: false, success: false,
error: "Modelo inválido.", error: `As orientações devem ter no máximo ${USER_INSTRUCTIONS_MAX_LENGTH} caracteres.`,
}; };
} }
const resolvedModel = resolveInsightsModel(modelId);
if (!resolvedModel.success) {
return resolvedModel;
}
const aggregatedData = await aggregateMonthData(user.id, period); const aggregatedData = await aggregateMonthData(user.id, period);
let model: ReturnType<typeof google>;
if (isOpenRouterFormat && !selectedModel) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return {
success: false,
error:
"OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env",
};
}
const openrouter = createOpenRouter({
apiKey,
});
model = openrouter.chat(modelId);
} else if (selectedModel?.provider === "openai") {
model = openai(modelId);
} else if (selectedModel?.provider === "anthropic") {
model = anthropic(modelId);
} else if (selectedModel?.provider === "google") {
model = google(modelId);
} else {
return {
success: false,
error: "Provider de modelo não suportado.",
};
}
const result = await generateObject({ const result = await generateObject({
model, model: resolvedModel.model,
schema: InsightsResponseSchema, schema: InsightsResponseSchema,
system: INSIGHTS_SYSTEM_PROMPT, system: INSIGHTS_SYSTEM_PROMPT,
prompt: `Analise os seguintes dados financeiros agregados do período ${period}. prompt: `Analise os seguintes dados financeiros agregados do período ${period}.
@@ -98,6 +70,11 @@ DADOS IMPORTANTES PARA SUA ANÁLISE:
- Comprometimento futuro de R$ ${aggregatedData.installments.futureCommitment.toFixed(2)} - Comprometimento futuro de R$ ${aggregatedData.installments.futureCommitment.toFixed(2)}
- Use isso para alertas sobre comprometimento de renda futura - Use isso para alertas sobre comprometimento de renda futura
ORIENTAÇÕES DO USUÁRIO PARA ESTA ANÁLISE:
${normalizedUserInstructions || "Nenhuma orientação adicional."}
Use as orientações do usuário apenas para priorizar achados, ajustar foco e calibrar o tom da análise. Não ignore o schema obrigatório, não invente dados que não estejam nos agregados e não execute ações ou alterações no sistema.
Organize suas observações nas 4 categories especificadas no prompt do sistema: Organize suas observações nas 4 categories especificadas no prompt do sistema:
1. Comportamentos Observados (behaviors): 3-6 itens 1. Comportamentos Observados (behaviors): 3-6 itens
2. Gatilhos de Consumo (triggers): 3-6 itens 2. Gatilhos de Consumo (triggers): 3-6 itens

View File

@@ -0,0 +1,246 @@
import {
RiCalendarLine,
RiDatabase2Line,
RiEditLine,
RiInformationLine,
RiSearchLine,
RiShieldCheckLine,
RiSparklingLine,
} from "@remixicon/react";
import type React from "react";
import { type AIProvider, PROVIDERS } from "@/features/insights/constants";
import { USER_INSTRUCTIONS_MAX_LENGTH } from "@/features/insights/lib/user-instructions";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Textarea } from "@/shared/components/ui/textarea";
import { displayPeriod } from "@/shared/utils/period";
import { cn } from "@/shared/utils/ui";
import { ProviderIcon } from "./provider-icon";
interface AnalysisSummaryCardProps {
period: string;
currentProvider: AIProvider;
selectedModelLabel: string;
userInstructions: string;
onUserInstructionsChange: (value: string) => void;
}
export function AnalysisSummaryCard({
period,
currentProvider,
selectedModelLabel,
userInstructions,
onUserInstructionsChange,
}: AnalysisSummaryCardProps) {
const hasUserInstructions = userInstructions.trim().length > 0;
const handleUserInstructionsChange = (value: string) => {
onUserInstructionsChange(value.slice(0, USER_INSTRUCTIONS_MAX_LENGTH));
};
return (
<aside>
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardContent className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<RiSparklingLine className="size-4" />
</div>
<div>
<h3 className="font-semibold text-sm">Resumo da análise</h3>
<p className="text-muted-foreground text-xs">
Configuração atual
</p>
</div>
</div>
<Dialog>
<DialogTrigger asChild>
<Button
className="w-full justify-start"
type="button"
variant="secondary"
>
<RiEditLine className="size-4" />
{hasUserInstructions
? "Editar orientações da IA"
: "Adicionar orientações da IA"}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Orientações para a IA</DialogTitle>
<DialogDescription>
Use este campo para direcionar o foco e o tom desta análise.
Essas orientações não alteram os dados analisados nem
substituem o formato obrigatório da resposta.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-2xl bg-warning/15 p-4">
<div className="flex gap-3">
<RiInformationLine className="mt-0.5 size-5 shrink-0 text-warning" />
<div className="space-y-1">
<p className="font-medium text-sm">
O que pode ser ajustado
</p>
<p className="text-muted-foreground text-xs leading-relaxed">
Você pode pedir mais foco em parcelamentos, gastos
recorrentes, cartão de crédito, oportunidades de
economia ou preferir um tom mais direto. A IA ainda deve
seguir o schema e usar apenas os dados agregados do
período.
</p>
</div>
</div>
</div>
<Textarea
className="min-h-52 resize-y"
maxLength={USER_INSTRUCTIONS_MAX_LENGTH}
onChange={(event) =>
handleUserInstructionsChange(event.target.value)
}
placeholder="Ex: foque em parcelamentos e despesas recorrentes; seja mais direto; ignore gastos de mercado."
value={userInstructions}
/>
<div className="flex items-center justify-between gap-3 text-muted-foreground text-xs">
<span>
Exemplos bons: priorize economia, mais atenção ao
cartão, seja objetivo.
</span>
<span className="shrink-0">
{userInstructions.length}/{USER_INSTRUCTIONS_MAX_LENGTH}
</span>
</div>
</div>
<DialogFooter>
<Button
onClick={() => onUserInstructionsChange("")}
type="button"
variant="outline"
>
Limpar
</Button>
<DialogClose asChild>
<Button type="button">Aplicar orientações</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<p
className={cn(
"min-h-8 text-xs leading-relaxed",
hasUserInstructions ? "text-primary" : "text-info",
)}
>
{hasUserInstructions
? "Prompt personalizado ativo. As orientações serão consideradas nesta análise."
: "Prompt padrão ativo. A análise seguirá o formato e as prioridades originais."}
</p>
<div className="grid gap-2">
<SummaryRow
icon={<RiCalendarLine className="size-4" />}
label="Período"
value={displayPeriod(period)}
/>
<SummaryRow
icon={<RiDatabase2Line className="size-4" />}
label="Fonte dos dados"
value="Transações, categorias, cartões, contas, orçamentos, recorrências e parcelamentos do mês."
/>
</div>
<div>
<p className="mb-2 font-medium text-muted-foreground text-xs uppercase tracking-wide">
Modelo selecionado
</p>
<div className="flex items-center gap-3">
<ProviderIcon provider={currentProvider} />
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm">
{PROVIDERS[currentProvider].name}
</p>
<p className="truncate text-muted-foreground text-xs">
{selectedModelLabel || "Nenhum modelo selecionado"}
</p>
</div>
{currentProvider === "ollama" && (
<Badge
className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-none"
variant="outline"
>
Local
</Badge>
)}
</div>
</div>
<div>
<div className="rounded-2xl bg-warning/15 p-4">
<div className="flex gap-3">
<RiSearchLine className="mt-0.5 size-4 shrink-0 text-warning" />
<div className="space-y-1">
<p className="font-medium text-xs">Escopo da análise</p>
<p className="text-muted-foreground text-xs leading-relaxed">
Busca comportamentos, gatilhos, recomendações e melhorias.
</p>
</div>
</div>
</div>
</div>
<div className="rounded-2xl bg-violet-500/10 p-4">
<div className="flex gap-3">
<RiShieldCheckLine className="mt-0.5 size-4 shrink-0 text-violet-600 dark:text-violet-300" />
<div className="space-y-1">
<p className="font-medium text-xs">Privacidade dos dados</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{currentProvider === "ollama"
? "Dados enviados para sua instância Ollama."
: "Dados enviados ao provedor externo escolhido."}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</aside>
);
}
function SummaryRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex gap-3">
<div className="mt-0.5 text-muted-foreground">{icon}</div>
<div className="space-y-1">
<p className="font-semibold text-xs">{label}</p>
<p className="text-muted-foreground text-xs leading-relaxed">{value}</p>
</div>
</div>
);
}

View File

@@ -5,7 +5,9 @@ import {
RiFlashlightLine, RiFlashlightLine,
RiLightbulbLine, RiLightbulbLine,
RiRocketLine, RiRocketLine,
RiSparklingLine,
} from "@remixicon/react"; } from "@remixicon/react";
import type React from "react";
import { import {
Card, Card,
CardContent, CardContent,
@@ -22,6 +24,7 @@ import { cn } from "@/shared/utils/ui";
interface InsightsGridProps { interface InsightsGridProps {
insights: InsightsResponse; insights: InsightsResponse;
action?: React.ReactNode;
} }
const CATEGORY_ICONS: Record<InsightCategoryId, RemixiconComponentType> = { const CATEGORY_ICONS: Record<InsightCategoryId, RemixiconComponentType> = {
@@ -53,21 +56,34 @@ const CATEGORY_COLORS: Record<
}, },
}; };
export function InsightsGrid({ insights }: InsightsGridProps) { export function InsightsGrid({ insights, action }: InsightsGridProps) {
const formattedPeriod = displayPeriod(insights.month); const formattedPeriod = displayPeriod(insights.month);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2 px-1 text-muted-foreground"> <Card className="overflow-hidden border-primary/10 bg-linear-to-br from-primary/10 via-card to-card">
<p> <CardContent className="px-4 py-1">
No período selecionado ({formattedPeriod}), identificamos os <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
principais comportamentos e gatilhos que impactaram seu padrão de <div className="flex gap-3">
consumo. <div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
</p> <RiSparklingLine className="size-5" />
<p>Segue um panorama prático com recomendações acionáveis.</p>
</div> </div>
<div className="space-y-1">
<p className="font-semibold text-lg tracking-tight">
Análise pronta para {formattedPeriod}
</p>
<p className="max-w-2xl text-muted-foreground text-sm leading-relaxed">
Organizamos os sinais mais relevantes do período em quatro
blocos: comportamentos, gatilhos, recomendações e
oportunidades de melhoria.
</p>
</div>
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
</CardContent>
</Card>
{/* Grid de Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{insights.categories.map((categoryData) => { {insights.categories.map((categoryData) => {
const categoryConfig = INSIGHT_CATEGORIES[categoryData.category]; const categoryConfig = INSIGHT_CATEGORIES[categoryData.category];

View File

@@ -1,15 +1,18 @@
"use client"; "use client";
import { import {
RiAlertLine,
RiDeleteBinLine, RiDeleteBinLine,
RiEyeLine,
RiFlashlightLine,
RiLightbulbLine,
RiLoader4Line,
RiRocketLine,
RiSaveLine, RiSaveLine,
RiSparklingLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns"; import { format } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useRef, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deleteSavedInsightsAction, deleteSavedInsightsAction,
@@ -21,8 +24,6 @@ import {
savedInsightsQueryKey, savedInsightsQueryKey,
useSavedInsights, useSavedInsights,
} from "@/features/insights/hooks/use-saved-insights"; } from "@/features/insights/hooks/use-saved-insights";
import { EmptyState } from "@/shared/components/feedback/empty-state";
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"; import { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton"; import { Skeleton } from "@/shared/components/ui/skeleton";
@@ -47,6 +48,9 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
string | null string | null
>(null); >(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [userInstructions, setUserInstructions] = useState("");
const [shouldScrollToAnalysis, setShouldScrollToAnalysis] = useState(false);
const analysisAreaRef = useRef<HTMLDivElement>(null);
const savedInsights = savedInsightsQuery.data ?? null; const savedInsights = savedInsightsQuery.data ?? null;
const insights = draftInsights ?? savedInsights?.insights ?? null; const insights = draftInsights ?? savedInsights?.insights ?? null;
const selectedModel = const selectedModel =
@@ -59,20 +63,45 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
draftInsights === null && savedInsightsQuery.error instanceof Error draftInsights === null && savedInsightsQuery.error instanceof Error
? savedInsightsQuery.error.message ? savedInsightsQuery.error.message
: null; : null;
const shouldShowAnalysisArea = Boolean(
isPending ||
isLoadingSavedInsights ||
insights ||
error ||
savedInsightsError,
);
useEffect(() => { useEffect(() => {
void period; void period;
setDraftInsights(null); setDraftInsights(null);
setSelectedModelOverride(null); setSelectedModelOverride(null);
setError(null); setError(null);
setShouldScrollToAnalysis(false);
}, [period]); }, [period]);
useEffect(() => {
if (!shouldScrollToAnalysis || !shouldShowAnalysisArea) return;
requestAnimationFrame(() => {
analysisAreaRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
setShouldScrollToAnalysis(false);
}, [shouldScrollToAnalysis, shouldShowAnalysisArea]);
const handleAnalyze = () => { const handleAnalyze = () => {
setError(null); setError(null);
setShouldScrollToAnalysis(true);
onAnalyze?.(); onAnalyze?.();
startTransition(async () => { startTransition(async () => {
try { try {
const result = await generateInsightsAction(period, selectedModel); const result = await generateInsightsAction(
period,
selectedModel,
userInstructions,
);
if (result.success) { if (result.success) {
setDraftInsights(result.data); setDraftInsights(result.data);
@@ -145,83 +174,24 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Privacy Warning */}
<Alert className="border-none bg-primary/15">
<RiAlertLine className="size-4" color="red" />
<AlertDescription className="text-sm text-card-foreground">
<strong>Aviso de privacidade:</strong> Ao gerar insights, seus dados
financeiros serão enviados para o provedor de IA selecionado
(Anthropic, OpenAI, Google ou OpenRouter) para processamento.
Certifique-se de que você confia no provedor escolhido antes de
prosseguir.
</AlertDescription>
</Alert>
{/* Model Selector */}
<ModelSelector <ModelSelector
value={selectedModel} value={selectedModel}
onValueChange={setSelectedModelOverride} onValueChange={setSelectedModelOverride}
period={period}
onAnalyze={handleAnalyze}
userInstructions={userInstructions}
onUserInstructionsChange={setUserInstructions}
onCancel={() => {
setSelectedModelOverride(null);
setError(null);
}}
disabled={isPending} disabled={isPending}
isLoadingSavedInsights={isLoadingSavedInsights}
/> />
{/* Analyze Button */} {shouldShowAnalysisArea && (
<div className="flex items-center gap-3 flex-wrap"> <div className="min-h-[320px] scroll-mt-6" ref={analysisAreaRef}>
<Button
onClick={handleAnalyze}
disabled={isPending || isLoadingSavedInsights}
className="bg-linear-to-r from-primary via-violet-400 to-cyan-400 dark:from-primary-dark dark:to-cyan-600"
>
<RiSparklingLine className="mr-2 size-5" aria-hidden="true" />
{isPending ? "Analisando..." : "Gerar análise inteligente"}
</Button>
{insights && !error && (
<Button
onClick={isSaved ? handleDelete : handleSave}
disabled={isSaving || isPending || isLoadingSavedInsights}
variant={isSaved ? "destructive" : "outline"}
>
{isSaved ? (
<>
<RiDeleteBinLine className="mr-2 size-4" />
{isSaving ? "Removendo..." : "Remover análise"}
</>
) : (
<>
<RiSaveLine className="mr-2 size-4" />
{isSaving ? "Salvando..." : "Salvar análise"}
</>
)}
</Button>
)}
{isSaved && savedDate && (
<span className="text-sm text-muted-foreground">
Salva em{" "}
{format(new Date(savedDate), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR,
})}
</span>
)}
</div>
{/* Content Area */}
<div className="min-h-[400px]">
{(isPending || isLoadingSavedInsights) && <LoadingState />} {(isPending || isLoadingSavedInsights) && <LoadingState />}
{!isPending &&
!isLoadingSavedInsights &&
!insights &&
!error &&
!savedInsightsError && (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiSparklingLine className="size-6 text-primary" />}
title="Nenhuma análise realizada"
description="Clique no botão acima para gerar insights inteligentes sobre seus
dados financeiros do mês selecionado."
/>
</Card>
)}
{!isPending && !isLoadingSavedInsights && error && ( {!isPending && !isLoadingSavedInsights && error && (
<ErrorState <ErrorState
title="Erro ao gerar insights" title="Erro ao gerar insights"
@@ -243,48 +213,135 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
!isLoadingSavedInsights && !isLoadingSavedInsights &&
insights && insights &&
!error && !error &&
!savedInsightsError && <InsightsGrid insights={insights} />} !savedInsightsError && (
<InsightsGrid
insights={insights}
action={
<div className="flex flex-col items-start sm:items-end">
<Button
onClick={isSaved ? handleDelete : handleSave}
disabled={isSaving || isPending || isLoadingSavedInsights}
variant={isSaved ? "destructive" : "secondary"}
>
{isSaved ? (
<>
<RiDeleteBinLine className="mr-2 size-4" />
{isSaving ? "Removendo..." : "Remover análise"}
</>
) : (
<>
<RiSaveLine className="mr-2 size-4" />
{isSaving ? "Salvando..." : "Salvar análise"}
</>
)}
</Button>
{isSaved && savedDate && (
<span className="text-muted-foreground text-xs">
Salva em{" "}
{format(new Date(savedDate), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR,
})}
</span>
)}
</div> </div>
}
/>
)}
</div>
)}
</div> </div>
); );
} }
function LoadingState() { function LoadingState() {
return ( const categories = [
<div className="space-y-6"> {
{/* Intro text skeleton */} label: "Comportamentos",
<div className="space-y-2 px-1"> icon: RiEyeLine,
<Skeleton className="h-5 w-full max-w-2xl" /> color: "text-orange-600 dark:text-orange-400",
<Skeleton className="h-5 w-full max-w-md" /> },
</div> {
label: "Gatilhos",
icon: RiFlashlightLine,
color: "text-amber-600 dark:text-amber-400",
},
{
label: "Recomendações",
icon: RiLightbulbLine,
color: "text-sky-600 dark:text-sky-400",
},
{
label: "Melhorias",
icon: RiRocketLine,
color: "text-emerald-600 dark:text-emerald-400",
},
];
{/* Grid de Cards */} return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => ( <Card className="overflow-hidden border-primary/10 bg-linear-to-br from-primary/10 via-card to-card">
<Card key={i} className="relative overflow-hidden"> <CardContent className="px-4 py-1">
<CardHeader> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2"> <div className="flex gap-3">
<Skeleton className="size-5 rounded" /> <div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Skeleton className="h-5 w-32" /> <RiLoader4Line className="size-5 animate-spin" />
</div>
<div className="space-y-2">
<div className="space-y-1">
<p className="font-semibold text-lg tracking-tight">
Preparando sua análise
</p>
<p className="max-w-2xl text-muted-foreground text-sm leading-relaxed">
Estamos consolidando os dados do período e organizando os
achados em comportamentos, gatilhos, recomendações e
melhorias.
</p>
</div>
</div> </div>
</CardHeader>
<CardContent>
{Array.from({ length: 4 }).map((_, j) => (
<div
key={j}
className="flex flex-1 border-b border-dashed py-2.5 gap-2 items-start last:border-0"
>
<Skeleton className="size-4 shrink-0 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div> </div>
</div> </div>
))}
</CardContent> </CardContent>
</Card> </Card>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{categories.map((category) => {
const Icon = category.icon;
return (
<Card
key={category.label}
className="relative min-h-[390px] overflow-hidden"
>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Icon className={`size-5 ${category.color}`} />
<span className={`font-semibold ${category.color}`}>
{category.label}
</span>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, index) => (
<div className="space-y-2" key={index}>
<div className="flex items-start gap-2">
<Skeleton className="mt-0.5 size-4 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-[82%]" />
</div>
</div>
{(index === 1 || index === 3) && (
<Skeleton className="ml-6 h-10 w-[72%] rounded-xl" />
)}
</div>
))} ))}
</div> </div>
</CardContent>
</Card>
);
})}
</div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,154 @@
import { RiExternalLinkLine, RiSparklingLine } from "@remixicon/react";
import Link from "next/link";
import type {
AIProvider,
AVAILABLE_MODELS,
} from "@/features/insights/constants";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { Input } from "@/shared/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
interface ModelSelectionCardProps {
currentProvider: AIProvider;
providerModels: Array<(typeof AVAILABLE_MODELS)[number]>;
selectValue: string;
customModel: string;
isCustomModelActive: boolean;
canUseCustomModel: boolean;
canAnalyze: boolean;
disabled?: boolean;
onModelSelect: (modelId: string) => void;
onCustomModelChange: (modelName: string) => void;
onCancel?: () => void;
onAnalyze: () => void;
}
export const CUSTOM_MODEL_VALUE = "custom";
export function ModelSelectionCard({
currentProvider,
providerModels,
selectValue,
customModel,
isCustomModelActive,
canUseCustomModel,
canAnalyze,
disabled,
onModelSelect,
onCustomModelChange,
onCancel,
onAnalyze,
}: ModelSelectionCardProps) {
return (
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardContent className="space-y-6">
<div className="space-y-3">
<div className="space-y-1">
<h3 className="font-semibold text-sm">2. Modelo específico</h3>
<p className="text-muted-foreground text-xs">
Escolha o modelo do provedor selecionado para esta análise.
</p>
</div>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="flex min-w-0 flex-col gap-2 lg:flex-row">
<div className="w-full lg:w-72">
{currentProvider === "openrouter" ? (
<Input
value={customModel}
onChange={(event) =>
onCustomModelChange(event.target.value)
}
placeholder="anthropic/claude-opus-4.8-fast"
disabled={disabled}
className="h-9 w-full border-border/70 bg-background"
/>
) : (
<Select
value={selectValue}
onValueChange={onModelSelect}
disabled={disabled}
>
<SelectTrigger className="h-9 w-full border-border/70 bg-background">
<SelectValue placeholder="Selecione um modelo" />
</SelectTrigger>
<SelectContent>
{providerModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
{model.id === "gpt-5.5" ? " (Recomendado)" : ""}
</SelectItem>
))}
{canUseCustomModel && (
<SelectItem value={CUSTOM_MODEL_VALUE}>
Modelo customizado
</SelectItem>
)}
</SelectContent>
</Select>
)}
</div>
{isCustomModelActive && currentProvider === "ollama" && (
<div className="w-full lg:w-72">
<Input
value={customModel}
onChange={(event) =>
onCustomModelChange(event.target.value)
}
placeholder="Ex: llama3.2"
disabled={disabled}
className="h-9 w-full border-border/70 bg-background"
/>
</div>
)}
</div>
<div className="flex min-h-9 shrink-0 items-center text-muted-foreground text-xs lg:max-w-none">
{currentProvider === "openrouter" && (
<Link
href="https://openrouter.ai/models"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
>
<RiExternalLinkLine className="size-3" />
Ver modelos do OpenRouter
</Link>
)}
{currentProvider === "ollama" && (
<span>
O modelo precisa estar instalado na instância Ollama
configurada.
</span>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<Button
disabled={disabled}
onClick={onCancel}
type="button"
variant="outline"
>
Cancelar
</Button>
<Button onClick={onAnalyze} disabled={!canAnalyze}>
<RiSparklingLine className="size-4" />
{disabled ? "Analisando..." : "Gerar insights"}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,94 +1,96 @@
"use client"; "use client";
import { RiExternalLinkLine } from "@remixicon/react";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
type AIProvider, type AIProvider,
AVAILABLE_MODELS, AVAILABLE_MODELS,
DEFAULT_PROVIDER, DEFAULT_PROVIDER,
PROVIDERS,
} from "@/features/insights/constants"; } from "@/features/insights/constants";
import { Card } from "@/shared/components/ui/card"; import { AnalysisSummaryCard } from "./analysis-summary-card";
import { Input } from "@/shared/components/ui/input"; import { CUSTOM_MODEL_VALUE, ModelSelectionCard } from "./model-selection-card";
import { Label } from "@/shared/components/ui/label"; import { ProviderSelectionCard } from "./provider-selection-card";
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
interface ModelSelectorProps { interface ModelSelectorProps {
value: string; value: string;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
period: string;
onAnalyze: () => void;
userInstructions: string;
onUserInstructionsChange: (value: string) => void;
onCancel?: () => void;
disabled?: boolean; disabled?: boolean;
isLoadingSavedInsights?: boolean;
} }
const PROVIDER_ICON_PATHS: Record< const CUSTOM_MODEL_PROVIDERS = ["openrouter", "ollama"] as const;
AIProvider,
{ light: string; dark?: string }
> = {
openai: {
light: "/providers/chatgpt.svg",
dark: "/providers/chatgpt_dark_mode.svg",
},
anthropic: {
light: "/providers/claude.svg",
},
google: {
light: "/providers/gemini.svg",
},
openrouter: {
light: "/providers/openrouter_light.svg",
dark: "/providers/openrouter_dark.svg",
},
};
export function ModelSelector({ function isCustomModelProvider(
value, provider: AIProvider,
onValueChange, ): provider is (typeof CUSTOM_MODEL_PROVIDERS)[number] {
disabled, return CUSTOM_MODEL_PROVIDERS.includes(
}: ModelSelectorProps) { provider as (typeof CUSTOM_MODEL_PROVIDERS)[number],
// Estado para armazenar o provider selecionado manualmente
const [selectedProvider, setSelectedProvider] = useState<AIProvider | null>(
null,
); );
const [customModel, setCustomModel] = useState(value); }
// Sincronizar customModel quando value mudar (importante para pré-carregamento) function getProviderFromValue(value: string): AIProvider | null {
useEffect(() => { if (value.startsWith("openrouter:")) {
// Se o value tem "/" é um modelo OpenRouter customizado return "openrouter";
if (value.includes("/")) { }
setCustomModel(value);
setSelectedProvider("openrouter"); if (value.startsWith("ollama:")) {
} else { return "ollama";
setCustomModel(value);
// Limpar selectedProvider para deixar o useMemo detectar automaticamente
setSelectedProvider(null);
}
}, [value]);
// Determinar provider atual baseado no modelo selecionado ou provider manual
const currentProvider = useMemo(() => {
// Se há um provider selecionado manualmente, use-o
if (selectedProvider) {
return selectedProvider;
} }
// Se o modelo tem "/" é OpenRouter
if (value.includes("/")) { if (value.includes("/")) {
return "openrouter"; return "openrouter";
} }
// Caso contrário, tente detectar baseado no modelo return AVAILABLE_MODELS.find((model) => model.id === value)?.provider ?? null;
const model = AVAILABLE_MODELS.find((m) => m.id === value); }
return model?.provider ?? DEFAULT_PROVIDER;
}, [value, selectedProvider]); function stripCustomProviderPrefix(value: string, provider: AIProvider) {
if (!isCustomModelProvider(provider)) {
return value;
}
return value.startsWith(`${provider}:`)
? value.slice(`${provider}:`.length)
: value;
}
function getModelLabel(modelId: string) {
const model = AVAILABLE_MODELS.find((item) => item.id === modelId);
if (model) return model.name;
const provider = getProviderFromValue(modelId);
return provider ? stripCustomProviderPrefix(modelId, provider) : modelId;
}
export function ModelSelector({
value,
onValueChange,
period,
onAnalyze,
userInstructions,
onUserInstructionsChange,
onCancel,
disabled,
isLoadingSavedInsights,
}: ModelSelectorProps) {
const [customModel, setCustomModel] = useState(value);
useEffect(() => {
const detectedProvider = getProviderFromValue(value);
if (detectedProvider && isCustomModelProvider(detectedProvider)) {
setCustomModel(stripCustomProviderPrefix(value, detectedProvider));
return;
}
setCustomModel(value);
}, [value]);
const currentProvider = getProviderFromValue(value) ?? DEFAULT_PROVIDER;
// Agrupar modelos por provider
const modelsByProvider = useMemo(() => { const modelsByProvider = useMemo(() => {
const grouped: Record< const grouped: Record<
AIProvider, AIProvider,
@@ -97,7 +99,9 @@ export function ModelSelector({
openai: [], openai: [],
anthropic: [], anthropic: [],
google: [], google: [],
minimax: [],
openrouter: [], openrouter: [],
ollama: [],
}; };
AVAILABLE_MODELS.forEach((model) => { AVAILABLE_MODELS.forEach((model) => {
@@ -107,130 +111,88 @@ export function ModelSelector({
return grouped; return grouped;
}, []); }, []);
// Atualizar provider (seleciona primeiro modelo daquele provider) const providerModels = modelsByProvider[currentProvider];
const handleProviderChange = (newProvider: AIProvider) => { const selectedModelIsKnown = providerModels.some(
setSelectedProvider(newProvider); (model) => model.id === value,
);
const selectValue = selectedModelIsKnown ? value : CUSTOM_MODEL_VALUE;
const isCustomModelActive =
isCustomModelProvider(currentProvider) && !selectedModelIsKnown;
const selectedModelLabel = getModelLabel(value);
const canAnalyze =
!disabled &&
!isLoadingSavedInsights &&
selectedModelLabel.trim().length > 0;
const handleProviderChange = (newProvider: AIProvider) => {
if (newProvider === "openrouter") { if (newProvider === "openrouter") {
// Para OpenRouter, usa o modelo customizado ou limpa o valor setCustomModel("");
onValueChange(customModel || ""); onValueChange("openrouter:");
return; return;
} }
const firstModel = modelsByProvider[newProvider][0]; const firstModel = modelsByProvider[newProvider][0];
if (firstModel) { if (firstModel) {
onValueChange(firstModel.id); onValueChange(firstModel.id);
return;
}
if (isCustomModelProvider(newProvider)) {
onValueChange(
customModel ? `${newProvider}:${customModel}` : `${newProvider}:`,
);
} }
}; };
// Atualizar modelo customizado do OpenRouter const handleModelSelect = (modelId: string) => {
if (modelId === CUSTOM_MODEL_VALUE) {
setCustomModel("");
onValueChange(`${currentProvider}:`);
return;
}
onValueChange(modelId);
};
const handleCustomModelChange = (modelName: string) => { const handleCustomModelChange = (modelName: string) => {
setCustomModel(modelName); setCustomModel(modelName);
onValueChange(modelName); onValueChange(`${currentProvider}:${modelName}`);
}; };
return ( return (
<Card className="grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start p-6"> <section className="space-y-4">
{/* Descrição */} <div className="grid items-stretch gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="space-y-2"> <div className="space-y-4">
<h3 className="text-lg font-semibold">Definir modelo de análise</h3> <ProviderSelectionCard
<p className="text-sm text-muted-foreground leading-relaxed"> currentProvider={currentProvider}
Escolha o provedor de IA e o modelo específico que será utilizado para disabled={disabled}
gerar insights sobre seus dados financeiros. <br /> onProviderChange={handleProviderChange}
Diferentes modelos podem oferecer perspectivas variadas na análise. />
</p>
<ModelSelectionCard
currentProvider={currentProvider}
providerModels={providerModels}
selectValue={selectValue}
customModel={customModel}
isCustomModelActive={isCustomModelActive}
canUseCustomModel={isCustomModelProvider(currentProvider)}
canAnalyze={canAnalyze}
disabled={disabled}
onModelSelect={handleModelSelect}
onCustomModelChange={handleCustomModelChange}
onCancel={onCancel}
onAnalyze={onAnalyze}
/>
</div> </div>
{/* Seletor */} <AnalysisSummaryCard
<div className="flex flex-col gap-4 min-w-xs"> period={period}
<RadioGroup currentProvider={currentProvider}
value={currentProvider} selectedModelLabel={selectedModelLabel}
onValueChange={(v) => handleProviderChange(v as AIProvider)} userInstructions={userInstructions}
disabled={disabled} onUserInstructionsChange={onUserInstructionsChange}
className="gap-3"
>
{(Object.keys(PROVIDERS) as AIProvider[]).map((providerId) => {
const provider = PROVIDERS[providerId];
const iconPaths = PROVIDER_ICON_PATHS[providerId];
return (
<div key={providerId} className="flex items-center gap-3">
<RadioGroupItem
value={providerId}
id={`provider-${providerId}`}
disabled={disabled}
/> />
<div className="size-6 relative">
<Image
src={iconPaths.light}
alt={provider.name}
width={22}
height={22}
className={iconPaths.dark ? "dark:hidden" : ""}
/>
{iconPaths.dark && (
<Image
src={iconPaths.dark}
alt={provider.name}
width={22}
height={22}
className="hidden dark:block"
/>
)}
</div> </div>
<Label </section>
htmlFor={`provider-${providerId}`}
className="text-sm font-medium cursor-pointer flex-1"
>
{provider.name}
</Label>
</div>
);
})}
</RadioGroup>
{/* Seletor de Modelo */}
{currentProvider === "openrouter" ? (
<div className="space-y-2">
<Input
value={customModel}
onChange={(e) => handleCustomModelChange(e.target.value)}
placeholder="Ex: anthropic/claude-3.5-sonnet"
disabled={disabled}
className="border-none bg-neutral-200 dark:bg-neutral-800"
/>
<a
href="https://openrouter.ai/models"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RiExternalLinkLine className="h-3 w-3" />
Ver modelos disponíveis no OpenRouter
</a>
</div>
) : (
<Select
value={value}
onValueChange={onValueChange}
disabled={disabled}
>
<SelectTrigger
disabled={disabled}
className="border-none bg-neutral-200 dark:bg-neutral-800"
>
<SelectValue placeholder="Selecione um modelo" />
</SelectTrigger>
<SelectContent>
{modelsByProvider[currentProvider].map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</Card>
); );
} }

View File

@@ -0,0 +1,59 @@
import Image from "next/image";
import { type AIProvider, PROVIDERS } from "@/features/insights/constants";
const PROVIDER_ICON_PATHS: Partial<
Record<
AIProvider,
{ light: string; dark?: string; width?: number; height?: number }
>
> = {
openai: {
light: "/providers/chatgpt.svg",
dark: "/providers/chatgpt_dark_mode.svg",
},
anthropic: {
light: "/providers/claude.svg",
},
google: {
light: "/providers/gemini.svg",
},
minimax: {
light: "/providers/minimax.svg",
},
openrouter: {
light: "/providers/openrouter_light.svg",
dark: "/providers/openrouter_dark.svg",
},
ollama: {
light: "/providers/ollama_light.svg",
dark: "/providers/ollama_dark.svg",
width: 17,
height: 22,
},
};
export function ProviderIcon({ provider }: { provider: AIProvider }) {
const iconPaths = PROVIDER_ICON_PATHS[provider];
if (!iconPaths) return null;
return (
<div className="relative flex size-10 items-center justify-center">
<Image
src={iconPaths.light}
alt={PROVIDERS[provider].name}
width={iconPaths.width ?? 32}
height={iconPaths.height ?? 32}
className={iconPaths.dark ? "dark:hidden" : ""}
/>
{iconPaths.dark && (
<Image
src={iconPaths.dark}
alt={PROVIDERS[provider].name}
width={iconPaths.width ?? 32}
height={iconPaths.height ?? 32}
className="hidden dark:block"
/>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More