mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-10 07:16:01 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e50eeba36e | ||
|
|
26cb18a9ad | ||
|
|
382727a96d | ||
|
|
0df648c7f3 | ||
|
|
27f361923c | ||
|
|
60b2612e8a | ||
|
|
0171b0ce2f | ||
|
|
311369f81b | ||
|
|
ef2c8c50e8 | ||
|
|
5319d8a5a6 | ||
|
|
37247e319c | ||
|
|
766af2b347 | ||
|
|
5dcd30010e | ||
|
|
d589df6993 | ||
|
|
8a19f0f311 | ||
|
|
887885cd98 | ||
|
|
7a0e33efd8 | ||
|
|
b9557961e5 | ||
|
|
53c8e47981 | ||
|
|
adc9292cd8 | ||
|
|
b95d6f6752 | ||
|
|
c9f667a065 | ||
|
|
01d9c6ea05 | ||
|
|
d383d2db91 | ||
|
|
7a8d01debe | ||
|
|
3be15d3b15 | ||
|
|
fea9cf81d8 | ||
|
|
7a10d431ab | ||
|
|
b7343eb235 | ||
|
|
3bcc392f38 | ||
|
|
5241de44af | ||
|
|
1a75662120 | ||
|
|
7ca3f92467 | ||
|
|
6b044f3bc5 | ||
|
|
4e8f9cc5fa | ||
|
|
b6659ef66e | ||
|
|
21d7396c80 | ||
|
|
3a768bc8ba | ||
|
|
8a03a50132 | ||
|
|
246bb14a00 | ||
|
|
86bcffec66 | ||
|
|
81e7151876 |
@@ -17,6 +17,11 @@ POSTGRES_DB=openmonetis_db
|
|||||||
# Gere com: openssl rand -base64 32
|
# Gere com: openssl rand -base64 32
|
||||||
BETTER_AUTH_SECRET=your-secret-key-here-change-this
|
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
|
||||||
|
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
|
||||||
@@ -54,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
|
||||||
|
|||||||
9
.github/workflows/docker-publish.yml
vendored
9
.github/workflows/docker-publish.yml
vendored
@@ -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
|
||||||
|
|||||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
32
.vscode/settings.json
vendored
32
.vscode/settings.json
vendored
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"files.exclude": {
|
|
||||||
"**/.git": true,
|
|
||||||
"**/.svn": true,
|
|
||||||
"**/.hg": true,
|
|
||||||
"**/.DS_Store": true,
|
|
||||||
"**/Thumbs.db": true,
|
|
||||||
"**/node_modules": true,
|
|
||||||
"node_modules": true,
|
|
||||||
"**/.vscode": true,
|
|
||||||
".vscode": true,
|
|
||||||
"**/.next": true,
|
|
||||||
".next": true
|
|
||||||
},
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.organizeImports": "never",
|
|
||||||
"source.organizeImports.biome": "always",
|
|
||||||
"source.fixAll": "never",
|
|
||||||
"source.fixAll.biome": "always",
|
|
||||||
"source.fixAll.eslint": "never"
|
|
||||||
},
|
|
||||||
"[json]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
},
|
|
||||||
"prettier.enable": false,
|
|
||||||
"editor.fontSize": 15,
|
|
||||||
"[jsonc]": {
|
|
||||||
"editor.defaultFormatter": "vscode.json-language-features"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
127
CHANGELOG.md
127
CHANGELOG.md
@@ -5,6 +5,133 @@ 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.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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
- Autenticação: nova variável `DISABLE_SIGNUP=true` para bloquear novos cadastros. Quando ativa, a tela de cadastro deixa de aparecer na navegação, `/signup` redireciona para login/dashboard e a API de signup responde `403`.
|
||||||
|
- Lançamentos: compras parceladas agora podem começar em uma parcela intermediária, como `5 de 10`. O sistema gera apenas as parcelas restantes e preserva o cálculo do valor unitário com base no total original.
|
||||||
|
- Logos: adicionado o logo da Bipa à biblioteca local de marcas.
|
||||||
|
- Relatórios: a análise de parcelas agora separa parcelas acompanhadas daquelas que ficaram fora do acompanhamento quando o parcelamento começa no meio da série.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
- Contas: a página de extrato em `/accounts/[accountId]` voltou a exibir os botões "Nova Receita" e "Nova Despesa", alinhando o fluxo com as demais telas de lançamentos.
|
||||||
|
- Cartões: os cards de `/cards` agora mostram o valor da fatura do mês atual junto dos indicadores de limite. O limite utilizado passa a considerar faturas em aberto, não apenas o status interno do lançamento.
|
||||||
|
- Lançamentos: ao criar um lançamento a partir do extrato de uma conta, o diálogo já abre com essa conta selecionada como destino padrão.
|
||||||
|
- Importação: os controles globais da revisão de extrato foram realinhados à esquerda, com espaçamento mais compacto e larguras mais consistentes.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- Dashboard: o widget "Status de Pagamento" voltou a mostrar corretamente os valores em "A Pagar", somando despesas pelo valor absoluto e mantendo reembolsos como abatimento.
|
||||||
|
- Importação: datas vindas de planilhas agora preservam o dia informado no Excel, evitando que `20/05/2026` apareça como `19/05/2026` em fusos como `America/Sao_Paulo`.
|
||||||
|
- Importação: o seletor de categoria por linha agora mostra apenas categorias compatíveis com o tipo detectado do lançamento, separando receitas e despesas durante a revisão do extrato.
|
||||||
|
- Importação: cada linha da revisão de extrato agora permite escolher uma pessoa específica, enquanto o campo global continua servindo como atalho para aplicar a pessoa nos lançamentos selecionados.
|
||||||
|
- Lançamentos: despesas comuns na categoria `Pagamentos` voltaram a poder ser editadas, removidas, copiadas e importadas. A proteção continua valendo apenas para pagamentos automáticos de fatura com nota técnica `AUTO_FATURA:`.
|
||||||
|
|
||||||
|
### Dependências
|
||||||
|
- Stack core: `pnpm` 10.33.0 → 11.1.3.
|
||||||
|
- Auth: `better-auth` e `@better-auth/passkey` 1.6.10 → 1.6.11.
|
||||||
|
- AI SDKs: `@ai-sdk/anthropic` 3.0.76 → 3.0.78, `@ai-sdk/google` 3.0.71 → 3.0.75, `@ai-sdk/openai` 3.0.63 → 3.0.64 e `ai` 6.0.177 → 6.0.185.
|
||||||
|
- AWS: `@aws-sdk/client-s3` e `@aws-sdk/s3-request-presigner` 3.1045.0 → 3.1050.0.
|
||||||
|
- UI e dados: `@tanstack/react-query` 5.100.9 → 5.100.11, `date-fns` 4.1.0 → 4.2.1, `jspdf-autotable` 5.0.7 → 5.0.8, `pg` 8.20.0 → 8.21.0 e `react-day-picker` 10.0.0 → 10.0.1.
|
||||||
|
- Dev tooling: `@types/node` 25.6.2 → 25.9.1, `@types/react` 19.2.14 → 19.2.15, `knip` 6.12.2 → 6.14.1, `tsx` 4.21.0 → 4.22.3 e novo `babel-plugin-react-compiler` 1.0.0.
|
||||||
|
|
||||||
|
## [2.5.7] - 2026-05-14
|
||||||
|
|
||||||
|
Esta versão faz um polimento visual no relatório de análise de parcelas, deixando o estabelecimento como referência principal do card e mantendo o cartão visível de forma mais discreta no contexto da compra.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
- Relatórios: em `/reports/installment-analysis`, os cards de parcelas passam a usar o logo do estabelecimento como avatar principal; o logo do cartão agora aparece menor ao lado do nome do cartão, tanto no card quanto no modal de detalhes.
|
||||||
|
- Relatórios: a página de análise de parcelas pré-carrega os mapeamentos de logos de estabelecimentos para evitar troca visual após o primeiro render.
|
||||||
|
- Lançamentos: o campo de anexos no modal agora aceita arquivos colados com `Ctrl+V`, mantendo o botão para buscar arquivos normalmente.
|
||||||
|
- Lançamentos: o modal agora usa uma única área interna de rolagem, com cabeçalho e rodapé estáveis, reduzindo travadas ao rolar e ao abrir "Condições, anotações e anexos".
|
||||||
|
- Anotações: tarefas agora podem ser editadas inline no modal "Atualizar anotação"; clicar no texto abre o input e o botão de remover vira botão de salvar naquela linha.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- Relatórios: o join com cartões na análise de parcelas agora também valida `cards.userId`, mantendo o filtro de ownership explícito na consulta.
|
||||||
|
|
||||||
## [2.5.6] - 2026-05-07
|
## [2.5.6] - 2026-05-07
|
||||||
|
|
||||||
Esta versão entrega um conjunto de melhorias em torno do fluxo de lançamentos: filtros mais úteis, divisão por porcentagem, indicador de orçamento dentro do modal e correção de um bug em totais por pessoa que considerava contas excluídas do saldo. Também inclui ajustes de robustez no display da calculadora (sem mais overflow do modal com valores longos) e o fix do cache de RSC nos filtros multi-seleção.
|
Esta versão entrega um conjunto de melhorias em torno do fluxo de lançamentos: filtros mais úteis, divisão por porcentagem, indicador de orçamento dentro do modal e correção de um bug em totais por pessoa que considerava contas excluídas do saldo. Também inclui ajustes de robustez no display da calculadora (sem mais overflow do modal com valores longos) e o fix do cache de RSC nos filtros multi-seleção.
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -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
|
||||||
|
|
||||||
|
|||||||
51
README.md
51
README.md
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
- [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,7 +62,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
|||||||
|
|
||||||
### Funcionalidades
|
### Funcionalidades
|
||||||
|
|
||||||
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, 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 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.
|
||||||
|
|
||||||
@@ -71,7 +72,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.
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
|||||||
- **shadcn/ui** (Radix UI) + **Tailwind CSS**
|
- **shadcn/ui** (Radix UI) + **Tailwind CSS**
|
||||||
- **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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -127,10 +128,11 @@ Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar
|
|||||||
# 1. Baixe o compose
|
# 1. Baixe o compose
|
||||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||||
|
|
||||||
# 2. Crie um .en na mesma pasta.
|
# 2. Crie um .env na mesma pasta.
|
||||||
# .env mínimo recomendado para produção
|
# .env mínimo recomendado para produção
|
||||||
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
|
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
|
||||||
BETTER_AUTH_URL=http://seu-dominio.com
|
BETTER_AUTH_URL=http://seu-dominio.com
|
||||||
|
DISABLE_SIGNUP=false # opcional: true bloqueia novos cadastros
|
||||||
|
|
||||||
# 3. Suba tudo
|
# 3. Suba tudo
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -443,6 +445,11 @@ POSTGRES_USER=openmonetis
|
|||||||
POSTGRES_PASSWORD=openmonetis_dev_password
|
POSTGRES_PASSWORD=openmonetis_dev_password
|
||||||
POSTGRES_DB=openmonetis_db
|
POSTGRES_DB=openmonetis_db
|
||||||
|
|
||||||
|
# Autenticação
|
||||||
|
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=
|
||||||
S3_REGION=
|
S3_REGION=
|
||||||
@@ -465,7 +472,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.
|
||||||
@@ -473,6 +483,25 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Arquitetura
|
## 🏗️ Arquitetura
|
||||||
@@ -575,6 +604,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).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
50
package.json
50
package.json
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.5.6",
|
"version": "2.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@11.1.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"db:seed": "tsx scripts/mock-data.ts",
|
"db:seed": "tsx scripts/mock-data.ts",
|
||||||
@@ -31,12 +31,13 @@
|
|||||||
"mockup": "tsx scripts/mock-data.ts"
|
"mockup": "tsx scripts/mock-data.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.76",
|
"@ai-sdk/anthropic": "^3.0.79",
|
||||||
"@ai-sdk/google": "^3.0.71",
|
"@ai-sdk/google": "^3.0.79",
|
||||||
"@ai-sdk/openai": "^3.0.63",
|
"@ai-sdk/openai": "^3.0.65",
|
||||||
"@aws-sdk/client-s3": "^3.1045.0",
|
"@ai-sdk/openai-compatible": "^2.0.48",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
"@aws-sdk/client-s3": "^3.1050.0",
|
||||||
"@better-auth/passkey": "^1.6.10",
|
"@aws-sdk/s3-request-presigner": "^3.1050.0",
|
||||||
|
"@better-auth/passkey": "^1.6.11",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -63,53 +64,50 @@
|
|||||||
"@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.9",
|
"@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.177",
|
"ai": "^6.0.191",
|
||||||
"better-auth": "1.6.10",
|
"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.1.0",
|
"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",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.8",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"pdfjs-dist": "^5.7.284",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"pg": "8.20.0",
|
"pg": "8.21.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-day-picker": "^10.0.0",
|
"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"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"defu": "6.1.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.15",
|
"@biomejs/biome": "2.4.15",
|
||||||
"@tailwindcss/postcss": "4.3.0",
|
"@tailwindcss/postcss": "4.3.0",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "25.6.2",
|
"@types/node": "25.9.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.15",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
"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.12.2",
|
"knip": "^6.14.2",
|
||||||
"tailwindcss": "4.3.0",
|
"tailwindcss": "4.3.0",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.22.3",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4173
pnpm-lock.yaml
generated
4173
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,35 @@
|
|||||||
onlyBuiltDependencies:
|
packages:
|
||||||
- core-js
|
- '.'
|
||||||
- esbuild
|
|
||||||
- sharp
|
allowBuilds:
|
||||||
- unrs-resolver
|
core-js: true
|
||||||
|
esbuild: true
|
||||||
|
sharp: true
|
||||||
|
unrs-resolver: true
|
||||||
|
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- '@aws-sdk/client-s3@3.1050.0'
|
||||||
|
- '@aws-sdk/s3-request-presigner@3.1050.0'
|
||||||
|
- '@types/node@25.9.1'
|
||||||
|
- '@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:
|
||||||
|
defu: 6.1.7
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inter } from "next/font/google";
|
import { Golos_Text } from "next/font/google";
|
||||||
|
|
||||||
export const inter = Inter({
|
export const inter = Golos_Text({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
|
|||||||
BIN
public/logos/bipa.png
Normal file
BIN
public/logos/bipa.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
1
public/providers/minimax.svg
Normal file
1
public/providers/minimax.svg
Normal 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 |
1
public/providers/ollama_dark.svg
Normal file
1
public/providers/ollama_dark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.4 KiB |
1
public/providers/ollama_light.svg
Normal file
1
public/providers/ollama_light.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.4 KiB |
16
setup.mjs
16
setup.mjs
@@ -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);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { LoginForm } from "@/features/auth/components/login-form";
|
import { LoginForm } from "@/features/auth/components/login-form";
|
||||||
|
import { isSignupDisabled } from "@/shared/lib/auth/signup";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return <LoginForm />;
|
return <LoginForm signupDisabled={isSignupDisabled()} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
import { SignupForm } from "@/features/auth/components/signup-form";
|
import { SignupForm } from "@/features/auth/components/signup-form";
|
||||||
|
import { isSignupDisabled } from "@/shared/lib/auth/signup";
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
|
if (isSignupDisabled()) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
return <SignupForm />;
|
return <SignupForm />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -43,6 +45,26 @@ type PageProps = {
|
|||||||
const capitalize = (value: string) =>
|
const capitalize = (value: string) =>
|
||||||
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
||||||
|
|
||||||
|
const resolveDefaultPaymentMethod = (
|
||||||
|
accountType: string | null | undefined,
|
||||||
|
) => {
|
||||||
|
if (accountType === "Dinheiro") return "Dinheiro";
|
||||||
|
if (accountType === "Pré-Pago | VR/VA") return "Pré-Pago | VR/VA";
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -100,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,
|
||||||
@@ -143,11 +166,17 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
totalExpenses={totalExpenses}
|
totalExpenses={totalExpenses}
|
||||||
logo={account.logo}
|
logo={account.logo}
|
||||||
balanceAdjustment={
|
balanceAdjustment={
|
||||||
<AdjustBalanceDialog
|
<>
|
||||||
accountId={account.id}
|
<AddYieldDialog
|
||||||
period={selectedPeriod}
|
accountId={account.id}
|
||||||
currentBalance={currentBalance}
|
defaultDate={defaultYieldDate}
|
||||||
/>
|
/>
|
||||||
|
<AdjustBalanceDialog
|
||||||
|
accountId={account.id}
|
||||||
|
period={selectedPeriod}
|
||||||
|
currentBalance={currentBalance}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<AccountDialog
|
<AccountDialog
|
||||||
@@ -197,7 +226,11 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
settledOnly: true,
|
settledOnly: true,
|
||||||
}}
|
}}
|
||||||
allowCreate={false}
|
allowCreate
|
||||||
|
defaultAccountId={account.id}
|
||||||
|
defaultPaymentMethod={resolveDefaultPaymentMethod(
|
||||||
|
account.accountType,
|
||||||
|
)}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
accountName,
|
accountName,
|
||||||
limitInUse: 0,
|
limitInUse: 0,
|
||||||
limitAvailable: limitAmount,
|
limitAvailable: limitAmount,
|
||||||
|
currentInvoiceAmount: 0,
|
||||||
|
currentInvoiceLabel: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
|
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 => ({
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
||||||
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||||
|
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
await connection();
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const data = await fetchInstallmentAnalysis(user.id);
|
const data = await fetchInstallmentAnalysis(user.id);
|
||||||
|
const logoMappings = await prefetchLogoMappings(
|
||||||
|
user.id,
|
||||||
|
data.installmentGroups.map((group) => group.name),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 pb-8">
|
<main className="flex flex-col gap-4 pb-8">
|
||||||
<InstallmentAnalysisPage data={data} />
|
<LogoPrefetchProvider mappings={logoMappings}>
|
||||||
|
<InstallmentAnalysisPage data={data} />
|
||||||
|
</LogoPrefetchProvider>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Badge } from "@/shared/components/ui/badge";
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||||
|
import { isSignupDisabled } from "@/shared/lib/auth/signup";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const [session, headersList, githubStats] = await Promise.all([
|
const [session, headersList, githubStats] = await Promise.all([
|
||||||
@@ -43,6 +44,7 @@ export default async function Page() {
|
|||||||
"",
|
"",
|
||||||
).replace(/:\d+$/, "");
|
).replace(/:\d+$/, "");
|
||||||
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
|
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
|
||||||
|
const signupDisabled = isSignupDisabled();
|
||||||
const metricsItems = getMetricsItems(githubStats.stars, githubStats.forks);
|
const metricsItems = getMetricsItems(githubStats.stars, githubStats.forks);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,20 +88,23 @@ export default async function Page() {
|
|||||||
Entrar
|
Entrar
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/signup">
|
{!signupDisabled && (
|
||||||
<Button
|
<Link href="/signup">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
size="sm"
|
||||||
>
|
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
Começar
|
>
|
||||||
</Button>
|
Começar
|
||||||
</Link>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<MobileNav
|
<MobileNav
|
||||||
isPublicDomain={isPublicDomain}
|
isPublicDomain={isPublicDomain}
|
||||||
isLoggedIn={!!session?.user}
|
isLoggedIn={!!session?.user}
|
||||||
|
signupDisabled={signupDisabled}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
</NavbarShell>
|
</NavbarShell>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
--input: var(--border);
|
--input: var(--border);
|
||||||
--ring: var(--primary);
|
--ring: var(--primary);
|
||||||
|
|
||||||
--chart-1: var(--color-emerald-500);
|
--chart-1: var(--color-orange-600);
|
||||||
--chart-2: var(--color-red-500);
|
--chart-2: var(--color-orange-400);
|
||||||
--chart-3: var(--color-amber-500);
|
--chart-3: var(--color-orange-200);
|
||||||
--chart-4: var(--color-blue-500);
|
--chart-4: var(--color-blue-500);
|
||||||
--chart-5: var(--color-pink-500);
|
--chart-5: var(--color-pink-500);
|
||||||
--chart-6: var(--color-stone-500);
|
--chart-6: var(--color-stone-500);
|
||||||
@@ -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,13 +117,13 @@
|
|||||||
--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(24.957% 0.00355 48.274);
|
--border: oklch(31.987% 0.00462 39.069);
|
||||||
--input: var(--border);
|
--input: var(--border);
|
||||||
--ring: var(--primary);
|
--ring: var(--primary);
|
||||||
|
|
||||||
--chart-1: var(--color-emerald-500);
|
--chart-1: var(--color-orange-600);
|
||||||
--chart-2: var(--color-orange-500);
|
--chart-2: var(--color-orange-400);
|
||||||
--chart-3: var(--color-indigo-500);
|
--chart-3: var(--color-orange-200);
|
||||||
--chart-4: var(--color-amber-500);
|
--chart-4: var(--color-amber-500);
|
||||||
--chart-5: var(--color-pink-500);
|
--chart-5: var(--color-pink-500);
|
||||||
--chart-6: var(--color-stone-500);
|
--chart-6: var(--color-stone-500);
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
155
src/features/accounts/components/add-yield-dialog.tsx
Normal file
155
src/features/accounts/components/add-yield-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
<DialogTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
type="button"
|
<DialogTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon-sm"
|
type="button"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
variant="ghost"
|
||||||
aria-label="Ajustar saldo"
|
size="icon-sm"
|
||||||
>
|
className="text-primary hover:text-primary"
|
||||||
<RiEqualizerLine className="size-4" />
|
aria-label="Ajustar saldo"
|
||||||
</Button>
|
>
|
||||||
</DialogTrigger>
|
<RiEqualizerLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -21,15 +22,24 @@ import { GoogleAuthButton } from "./google-auth-button";
|
|||||||
|
|
||||||
type DivProps = React.ComponentProps<"div">;
|
type DivProps = React.ComponentProps<"div">;
|
||||||
|
|
||||||
|
interface LoginFormProps extends DivProps {
|
||||||
|
signupDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const authLinkClassName =
|
const authLinkClassName =
|
||||||
"font-medium text-foreground/88 underline decoration-border underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/30 focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";
|
"font-medium text-foreground/88 underline decoration-border underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/30 focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";
|
||||||
|
|
||||||
export function LoginForm({ className, ...props }: DivProps) {
|
export function LoginForm({
|
||||||
|
className,
|
||||||
|
signupDisabled = false,
|
||||||
|
...props
|
||||||
|
}: LoginFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isGoogleAvailable = googleSignInAvailable;
|
const isGoogleAvailable = googleSignInAvailable;
|
||||||
|
|
||||||
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);
|
||||||
@@ -52,7 +62,7 @@ export function LoginForm({ className, ...props }: DivProps) {
|
|||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
callbackURL: "/dashboard",
|
callbackURL: "/dashboard",
|
||||||
rememberMe: false,
|
rememberMe,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onRequest: () => {
|
onRequest: () => {
|
||||||
@@ -178,6 +188,24 @@ export function LoginForm({ className, ...props }: DivProps) {
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@@ -233,12 +261,14 @@ export function LoginForm({ className, ...props }: DivProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<FieldDescription className="pt-1 text-center">
|
{!signupDisabled && (
|
||||||
Não tem uma conta?{" "}
|
<FieldDescription className="pt-1 text-center">
|
||||||
<a href="/signup" className={authLinkClassName}>
|
Não tem uma conta?{" "}
|
||||||
Inscreva-se
|
<a href="/signup" className={authLinkClassName}>
|
||||||
</a>
|
Inscreva-se
|
||||||
</FieldDescription>
|
</a>
|
||||||
|
</FieldDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
<FieldDescription className="text-center text-sm text-muted-foreground">
|
<FieldDescription className="text-center text-sm text-muted-foreground">
|
||||||
<a href="/" className={authLinkClassName}>
|
<a href="/" className={authLinkClassName}>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
RiCalendarCloseLine,
|
||||||
|
RiCalendarScheduleLine,
|
||||||
RiChat3Line,
|
RiChat3Line,
|
||||||
RiDeleteBin5Line,
|
RiDeleteBin5Line,
|
||||||
RiFileList2Line,
|
RiFileList2Line,
|
||||||
@@ -33,6 +35,8 @@ interface CardItemProps {
|
|||||||
limit: number;
|
limit: number;
|
||||||
limitInUse?: number;
|
limitInUse?: number;
|
||||||
limitAvailable?: number;
|
limitAvailable?: number;
|
||||||
|
currentInvoiceAmount: number;
|
||||||
|
currentInvoiceLabel: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
logo?: string | null;
|
logo?: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
@@ -52,6 +56,8 @@ export function CardItem({
|
|||||||
limit,
|
limit,
|
||||||
limitInUse,
|
limitInUse,
|
||||||
limitAvailable,
|
limitAvailable,
|
||||||
|
currentInvoiceAmount,
|
||||||
|
currentInvoiceLabel,
|
||||||
accountName: _accountName,
|
accountName: _accountName,
|
||||||
logo,
|
logo,
|
||||||
note,
|
note,
|
||||||
@@ -77,7 +83,7 @@ export function CardItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col p-6 w-full">
|
<Card className="flex flex-col p-6 w-full">
|
||||||
<CardHeader className="space-y-2 p-0">
|
<CardHeader className="space-y-1 p-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
{logoPath ? (
|
{logoPath ? (
|
||||||
@@ -146,15 +152,17 @@ export function CardItem({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-y py-3 text-sm text-muted-foreground">
|
<div className="flex items-center justify-between text-sm text-muted-foreground rounded-lg py-4 px-2 bg-primary/5">
|
||||||
<span>
|
<span className="inline-flex items-center gap-1">
|
||||||
Fecha em{" "}
|
<RiCalendarCloseLine className="size-4" aria-hidden />
|
||||||
|
Fecha{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
dia {formatDay(closingDay)}
|
dia {formatDay(closingDay)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="inline-flex items-center gap-1">
|
||||||
Vence em{" "}
|
<RiCalendarScheduleLine className="size-4" aria-hidden />
|
||||||
|
Vence{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
dia {formatDay(dueDay)}
|
dia {formatDay(dueDay)}
|
||||||
</span>
|
</span>
|
||||||
@@ -165,29 +173,40 @@ export function CardItem({
|
|||||||
<CardContent className="flex flex-1 flex-col gap-4 px-0">
|
<CardContent className="flex flex-1 flex-col gap-4 px-0">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Limite disponível
|
{currentInvoiceLabel}
|
||||||
</span>
|
</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={available}
|
amount={currentInvoiceAmount}
|
||||||
className="text-xl font-semibold text-success"
|
className="text-xl font-semibold text-info"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="flex gap-2 justify-between w-full">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<span className="text-xs text-muted-foreground">Limite total</span>
|
<span className="text-xs text-muted-foreground">Limite total</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={limit}
|
amount={limit}
|
||||||
className="text-sm font-semibold text-foreground"
|
className="text-sm font-semibold text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Limite utilizado
|
Limite utilizado
|
||||||
</span>
|
</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={used}
|
amount={used}
|
||||||
className="text-sm font-semibold text-destructive"
|
className="text-sm font-semibold text-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Limite disponível
|
||||||
|
</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={available}
|
||||||
|
className="text-sm font-semibold text-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,7 +219,7 @@ export function CardItem({
|
|||||||
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
|
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{usagePercent.toFixed(1)}% utilizado
|
{usagePercent.toFixed(0)}% utilizado
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -220,7 +239,7 @@ export function CardItem({
|
|||||||
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<RiFileList2Line className="size-4" aria-hidden />
|
<RiFileList2Line className="size-4" aria-hidden />
|
||||||
ver fatura
|
fatura
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function CardsPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-1 xl:grid-cols-3">
|
||||||
{list.map((card) => (
|
{list.map((card) => (
|
||||||
<CardItem
|
<CardItem
|
||||||
key={card.id}
|
key={card.id}
|
||||||
@@ -142,6 +142,8 @@ export function CardsPage({
|
|||||||
limit={card.limit}
|
limit={card.limit}
|
||||||
limitInUse={card.limitInUse ?? null}
|
limitInUse={card.limitInUse ?? null}
|
||||||
limitAvailable={card.limitAvailable ?? card.limit ?? null}
|
limitAvailable={card.limitAvailable ?? card.limit ?? null}
|
||||||
|
currentInvoiceAmount={card.currentInvoiceAmount}
|
||||||
|
currentInvoiceLabel={card.currentInvoiceLabel}
|
||||||
accountName={card.accountName}
|
accountName={card.accountName}
|
||||||
logo={card.logo}
|
logo={card.logo}
|
||||||
note={card.note}
|
note={card.note}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type Card = {
|
|||||||
accountName: string;
|
accountName: string;
|
||||||
limitInUse: number;
|
limitInUse: number;
|
||||||
limitAvailable: number;
|
limitAvailable: number;
|
||||||
|
currentInvoiceAmount: number;
|
||||||
|
currentInvoiceLabel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CardFormValues = {
|
export type CardFormValues = {
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
import { and, eq, ilike, isNull, ne, not, or, sql } from "drizzle-orm";
|
import {
|
||||||
import { cards, financialAccounts, transactions } from "@/db/schema";
|
and,
|
||||||
|
eq,
|
||||||
|
ilike,
|
||||||
|
isNotNull,
|
||||||
|
isNull,
|
||||||
|
ne,
|
||||||
|
not,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import { cards, financialAccounts, invoices, transactions } from "@/db/schema";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||||
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
||||||
|
import {
|
||||||
|
formatPeriodMonthShort,
|
||||||
|
getCurrentPeriod,
|
||||||
|
parsePeriod,
|
||||||
|
} from "@/shared/utils/period";
|
||||||
|
|
||||||
type CardData = {
|
type CardData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +31,8 @@ type CardData = {
|
|||||||
limit: number;
|
limit: number;
|
||||||
limitInUse: number;
|
limitInUse: number;
|
||||||
limitAvailable: number;
|
limitAvailable: number;
|
||||||
|
currentInvoiceAmount: number;
|
||||||
|
currentInvoiceLabel: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
};
|
};
|
||||||
@@ -25,6 +43,11 @@ type AccountSimple = {
|
|||||||
logo: string | null;
|
logo: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatCurrentInvoiceLabel(period: string) {
|
||||||
|
const { year } = parsePeriod(period);
|
||||||
|
return `Fatura ${formatPeriodMonthShort(period)}. ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCardsByStatus(
|
async function fetchCardsByStatus(
|
||||||
userId: string,
|
userId: string,
|
||||||
archived: boolean,
|
archived: boolean,
|
||||||
@@ -33,59 +56,94 @@ async function fetchCardsByStatus(
|
|||||||
accounts: AccountSimple[];
|
accounts: AccountSimple[];
|
||||||
logoOptions: string[];
|
logoOptions: string[];
|
||||||
}> {
|
}> {
|
||||||
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
|
const currentPeriod = getCurrentPeriod();
|
||||||
db.query.cards.findMany({
|
const currentInvoiceLabel = formatCurrentInvoiceLabel(currentPeriod);
|
||||||
orderBy: (table, { desc }) => [desc(table.name)],
|
const [cardRows, accountRows, logoOptions, usageRows, invoiceRows] =
|
||||||
where: and(
|
await Promise.all([
|
||||||
eq(cards.userId, userId),
|
db.query.cards.findMany({
|
||||||
archived
|
orderBy: (table, { desc }) => [desc(table.name)],
|
||||||
? ilike(cards.status, "inativo")
|
where: and(
|
||||||
: not(ilike(cards.status, "inativo")),
|
eq(cards.userId, userId),
|
||||||
),
|
archived
|
||||||
with: {
|
? ilike(cards.status, "inativo")
|
||||||
financialAccount: {
|
: not(ilike(cards.status, "inativo")),
|
||||||
columns: {
|
),
|
||||||
id: true,
|
with: {
|
||||||
name: true,
|
financialAccount: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
}),
|
db.query.financialAccounts.findMany({
|
||||||
db.query.financialAccounts.findMany({
|
orderBy: (table, { desc }) => [desc(table.name)],
|
||||||
orderBy: (table, { desc }) => [desc(table.name)],
|
where: eq(financialAccounts.userId, userId),
|
||||||
where: eq(financialAccounts.userId, userId),
|
columns: {
|
||||||
columns: {
|
id: true,
|
||||||
id: true,
|
name: true,
|
||||||
name: true,
|
logo: true,
|
||||||
logo: true,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
loadLogoOptions(),
|
||||||
loadLogoOptions(),
|
db
|
||||||
db
|
.select({
|
||||||
.select({
|
cardId: transactions.cardId,
|
||||||
cardId: transactions.cardId,
|
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||||
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
})
|
||||||
})
|
.from(transactions)
|
||||||
.from(transactions)
|
.leftJoin(
|
||||||
.where(
|
invoices,
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(invoices.userId, transactions.userId),
|
||||||
or(isNull(transactions.isSettled), eq(transactions.isSettled, false)),
|
eq(invoices.cardId, transactions.cardId),
|
||||||
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
|
eq(invoices.period, transactions.period),
|
||||||
or(
|
|
||||||
ne(transactions.condition, "Recorrente"),
|
|
||||||
sql`${transactions.purchaseDate} <= current_date`,
|
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
)
|
.where(
|
||||||
.groupBy(transactions.cardId),
|
and(
|
||||||
]);
|
eq(transactions.userId, userId),
|
||||||
|
isNotNull(transactions.cardId),
|
||||||
|
or(
|
||||||
|
isNull(invoices.paymentStatus),
|
||||||
|
ne(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
|
||||||
|
),
|
||||||
|
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
|
||||||
|
or(
|
||||||
|
ne(transactions.condition, "Recorrente"),
|
||||||
|
sql`${transactions.purchaseDate} <= current_date`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(transactions.cardId),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
cardId: transactions.cardId,
|
||||||
|
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||||
|
})
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
eq(transactions.period, currentPeriod),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(transactions.cardId),
|
||||||
|
]);
|
||||||
|
|
||||||
const usageMap = new Map<string, number>();
|
const usageMap = new Map<string, number>();
|
||||||
usageRows.forEach((row: { cardId: string | null; total: number | null }) => {
|
usageRows.forEach((row: { cardId: string | null; total: number | null }) => {
|
||||||
if (!row.cardId) return;
|
if (!row.cardId) return;
|
||||||
usageMap.set(row.cardId, Number(row.total ?? 0));
|
usageMap.set(row.cardId, Number(row.total ?? 0));
|
||||||
});
|
});
|
||||||
|
const invoiceMap = new Map<string, number>();
|
||||||
|
invoiceRows.forEach(
|
||||||
|
(row: { cardId: string | null; total: number | null }) => {
|
||||||
|
if (!row.cardId) return;
|
||||||
|
invoiceMap.set(row.cardId, Math.abs(Number(row.total ?? 0)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const cardList = cardRows.map((card) => ({
|
const cardList = cardRows.map((card) => ({
|
||||||
id: card.id,
|
id: card.id,
|
||||||
@@ -99,13 +157,15 @@ async function fetchCardsByStatus(
|
|||||||
limit: Number(card.limit),
|
limit: Number(card.limit),
|
||||||
limitInUse: (() => {
|
limitInUse: (() => {
|
||||||
const total = usageMap.get(card.id) ?? 0;
|
const total = usageMap.get(card.id) ?? 0;
|
||||||
return total < 0 ? Math.abs(total) : 0;
|
return Math.abs(total);
|
||||||
})(),
|
})(),
|
||||||
limitAvailable: (() => {
|
limitAvailable: (() => {
|
||||||
const total = usageMap.get(card.id) ?? 0;
|
const total = usageMap.get(card.id) ?? 0;
|
||||||
const inUse = total < 0 ? Math.abs(total) : 0;
|
const inUse = Math.abs(total);
|
||||||
return Math.max(Number(card.limit) - inUse, 0);
|
return Math.max(Number(card.limit) - inUse, 0);
|
||||||
})(),
|
})(),
|
||||||
|
currentInvoiceAmount: invoiceMap.get(card.id) ?? 0,
|
||||||
|
currentInvoiceLabel,
|
||||||
accountId: card.accountId,
|
accountId: card.accountId,
|
||||||
accountName:
|
accountName:
|
||||||
(card.financialAccount as { name?: string } | null)?.name ??
|
(card.financialAccount as { name?: string } | null)?.name ??
|
||||||
|
|||||||
@@ -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,41 +81,59 @@ 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-md border border-dashed bg-muted/20 px-3 py-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-md border border-dashed bg-muted/20 px-3 py-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-md border border-dashed bg-muted/20 px-3 py-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>
|
||||||
<PercentageChangeIndicator
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
value={percentageChange}
|
<span
|
||||||
label={variationLabel}
|
className={cn(
|
||||||
positiveTrend={category.type === "receita" ? "up" : "down"}
|
"inline-flex h-6 items-center rounded-sm border px-2 text-xs font-medium",
|
||||||
className="mt-1 gap-1 text-lg font-semibold"
|
comparisonTone === "positive" &&
|
||||||
iconClassName="size-4"
|
"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
|
||||||
|
value={percentageChange}
|
||||||
|
label={variationLabel}
|
||||||
|
positiveTrend={category.type === "receita" ? "up" : "down"}
|
||||||
|
className="gap-1 text-lg font-semibold"
|
||||||
|
iconClassName="size-4"
|
||||||
|
showFlatIcon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RiExternalLinkLine, RiWallet3Line } from "@remixicon/react";
|
import { RiExternalLinkLine } 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";
|
||||||
@@ -63,7 +63,7 @@ export function CategoryBreakdownListItem({
|
|||||||
/>
|
/>
|
||||||
</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,
|
||||||
@@ -77,10 +77,9 @@ export function CategoryBreakdownListItem({
|
|||||||
<span
|
<span
|
||||||
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
|
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
|
||||||
>
|
>
|
||||||
<RiWallet3Line className="size-3 shrink-0" />
|
|
||||||
{budgetExceeded ? (
|
{budgetExceeded ? (
|
||||||
<>
|
<>
|
||||||
excedeu{" "}
|
Excedeu{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatCurrency(exceededAmount)}
|
{formatCurrency(exceededAmount)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export function DashboardGridEditable({
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-xs rounded-lg border border-dashed border-primary flex items-center justify-center">
|
<div className="absolute inset-0 z-10 bg-background/60 backdrop-blur-[1.5px] rounded-lg border border-dashed border-primary flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<RiDragMove2Line className="size-8 text-primary" />
|
<RiDragMove2Line className="size-8 text-primary" />
|
||||||
<span className="text-xs font-medium">
|
<span className="text-xs font-medium">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-c
|
|||||||
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";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -102,21 +103,22 @@ const getTrend = (current: number, previous: number): Trend => {
|
|||||||
return "flat";
|
return "flat";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPercentChange = (current: number, previous: number): string => {
|
const getPercentChange = (current: number, previous: number): string | null => {
|
||||||
const EPSILON = 0.01;
|
const EPSILON = 0.01;
|
||||||
|
|
||||||
if (Math.abs(previous) < EPSILON) {
|
if (Math.abs(previous) < EPSILON) {
|
||||||
if (Math.abs(current) < EPSILON) return "0%";
|
if (Math.abs(current) < EPSILON) return "0%";
|
||||||
return "—";
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||||
if (!Number.isFinite(change)) return "—";
|
if (!Number.isFinite(change)) return null;
|
||||||
|
if (Math.abs(change) < TREND_THRESHOLD) return "0%";
|
||||||
if (change > 999) return "+999%";
|
if (change > 999) return "+999%";
|
||||||
if (change < -999) return "-999%";
|
if (change < -999) return "-999%";
|
||||||
return formatPercentage(change, {
|
return formatPercentage(change, {
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 0,
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 0,
|
||||||
signDisplay: "always",
|
signDisplay: "always",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -160,28 +162,45 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
|||||||
<Separator className="mt-1" />
|
<Separator className="mt-1" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
|
<div className="flex items-start justify-between mt-1">
|
||||||
<MoneyValues
|
<div className="flex flex-col gap-2 min-w-0">
|
||||||
className="text-2xl leading-none font-medium"
|
<div className="flex flex-wrap items-center">
|
||||||
amount={metric.current}
|
<MoneyValues
|
||||||
/>
|
className="text-2xl leading-none"
|
||||||
<PercentageChangeIndicator
|
amount={metric.current}
|
||||||
trend={trend}
|
/>
|
||||||
label={percentChange}
|
</div>
|
||||||
positiveTrend={invertTrend ? "down" : "up"}
|
|
||||||
showFlatIcon
|
|
||||||
className="gap-1"
|
|
||||||
iconClassName="size-3.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground gap-1 flex items-center">
|
||||||
<MoneyValues
|
<span className="text-muted-foreground/50">vs</span>
|
||||||
className="inline text-xs font-medium text-muted-foreground"
|
<MoneyValues
|
||||||
amount={metric.previous}
|
className="inline text-xs"
|
||||||
/>
|
amount={metric.previous}
|
||||||
<span className="ml-1">no mês anterior</span>
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
aria-hidden={!percentChange}
|
||||||
|
className={cn(
|
||||||
|
"w-14 justify-center px-0 text-xs",
|
||||||
|
!percentChange && "invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{percentChange ? (
|
||||||
|
<PercentageChangeIndicator
|
||||||
|
trend={trend}
|
||||||
|
label={percentChange}
|
||||||
|
positiveTrend={invertTrend ? "down" : "up"}
|
||||||
|
showFlatIcon={false}
|
||||||
|
className="shrink-0 justify-center text-center text-xs tabular-nums"
|
||||||
|
iconClassName="hidden"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="tabular-nums">0%</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function InstallmentAnalysisPage({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Card de resumo principal */}
|
{/* Card de resumo principal */}
|
||||||
<Card className="border-none bg-primary/10 dark:bg-primary/10">
|
<Card className="border-none bg-primary/10 shadow-none">
|
||||||
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
|
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Se você pagar tudo que está selecionado:
|
Se você pagar tudo que está selecionado:
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import {
|
import {
|
||||||
RiBankCard2Line,
|
RiBankCard2Line,
|
||||||
RiCheckboxCircleFill,
|
RiCheckboxCircleFill,
|
||||||
RiEyeLine,
|
RiFileList2Line,
|
||||||
RiTimeLine,
|
RiTimeLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
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";
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog";
|
} from "@/shared/components/ui/dialog";
|
||||||
import { Progress } from "@/shared/components/ui/progress";
|
import { Progress } from "@/shared/components/ui/progress";
|
||||||
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
import type { InstallmentGroup } from "./types";
|
import type { InstallmentGroup } from "./types";
|
||||||
|
|
||||||
@@ -62,8 +64,8 @@ export function InstallmentGroupCard({
|
|||||||
const hasSelection = selectedInstallments.size > 0;
|
const hasSelection = selectedInstallments.size > 0;
|
||||||
|
|
||||||
const progress =
|
const progress =
|
||||||
group.totalInstallments > 0
|
group.trackedInstallments > 0
|
||||||
? (group.paidInstallments / group.totalInstallments) * 100
|
? (group.paidInstallments / group.trackedInstallments) * 100
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const selectedAmount = group.pendingInstallments
|
const selectedAmount = group.pendingInstallments
|
||||||
@@ -79,6 +81,12 @@ export function InstallmentGroupCard({
|
|||||||
(sum, i) => sum + i.amount,
|
(sum, i) => sum + i.amount,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
const cardLogoSrc = resolveLogoSrc(group.cartaoLogo);
|
||||||
|
const cardName = group.cartaoName ?? "Compra parcelada";
|
||||||
|
const untrackedLabel =
|
||||||
|
group.untrackedInstallments === 1
|
||||||
|
? "1 parcela anterior fora do acompanhamento"
|
||||||
|
: `${group.untrackedInstallments} parcelas anteriores fora do acompanhamento`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -111,25 +119,24 @@ export function InstallmentGroupCard({
|
|||||||
{/* Info principal */}
|
{/* Info principal */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{group.cartaoLogo ? (
|
<EstablishmentLogo name={group.name} size={40} />
|
||||||
<Image
|
|
||||||
src={`/logos/${group.cartaoLogo}`}
|
|
||||||
alt={group.cartaoName ?? "Cartão"}
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
className="size-10 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="size-10 flex items-center justify-center">
|
|
||||||
<RiBankCard2Line className="size-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<CardTitle className="text-base truncate">
|
<CardTitle className="text-base truncate">
|
||||||
{group.name}
|
{group.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
|
||||||
{group.cartaoName ?? "Compra parcelada"}
|
{cardLogoSrc ? (
|
||||||
|
<Image
|
||||||
|
src={cardLogoSrc}
|
||||||
|
alt={`Logo do cartão ${cardName}`}
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
className="size-4.5 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RiBankCard2Line className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{cardName}</span>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,10 +154,10 @@ export function InstallmentGroupCard({
|
|||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Grid de valores */}
|
{/* Grid de valores */}
|
||||||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-muted/50 mb-4">
|
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-primary/5 mb-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs text-muted-foreground font-medium">
|
<p className="text-xs text-muted-foreground font-medium">
|
||||||
Valor total
|
Valor acompanhado
|
||||||
</p>
|
</p>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={totalAmount}
|
amount={totalAmount}
|
||||||
@@ -165,7 +172,7 @@ export function InstallmentGroupCard({
|
|||||||
amount={pendingAmount}
|
amount={pendingAmount}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold",
|
"text-lg font-semibold",
|
||||||
pendingAmount > 0 ? "text-amber-600" : "text-success-600",
|
pendingAmount > 0 ? "text-primary" : "text-success",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,48 +184,46 @@ export function InstallmentGroupCard({
|
|||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
<RiCheckboxCircleFill className="size-3.5 text-success" />
|
<RiCheckboxCircleFill className="size-3.5 text-success" />
|
||||||
<span>
|
<span>
|
||||||
{group.paidInstallments} de {group.totalInstallments} parcelas
|
{group.paidInstallments} de {group.trackedInstallments}{" "}
|
||||||
pagas
|
parcelas acompanhadas pagas
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{unpaidCount > 0 && (
|
{unpaidCount > 0 && (
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
<RiTimeLine className="size-3.5 text-amber-600" />
|
<RiTimeLine className="size-3.5" />
|
||||||
<span>
|
<span>
|
||||||
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Progress value={progress} className="h-2.5" />
|
<Progress
|
||||||
|
value={progress}
|
||||||
|
className="h-2.5 bg-muted"
|
||||||
|
indicatorClassName="bg-success"
|
||||||
|
/>
|
||||||
|
{group.untrackedInstallments > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{untrackedLabel}</p>
|
||||||
|
)}
|
||||||
</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="outline"
|
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)}
|
||||||
>
|
>
|
||||||
<RiEyeLine className="size-4" />
|
<span className="inline-flex items-center gap-1.5">
|
||||||
Ver detalhes ({group.pendingInstallments.length} parcelas)
|
<RiFileList2Line className="size-4" />
|
||||||
|
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>
|
||||||
@@ -228,18 +233,26 @@ export function InstallmentGroupCard({
|
|||||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{group.cartaoLogo ? (
|
<EstablishmentLogo name={group.name} size={32} />
|
||||||
<img
|
<div className="min-w-0">
|
||||||
src={`/logos/${group.cartaoLogo}`}
|
<DialogTitle className="truncate text-base">
|
||||||
alt={group.cartaoName ?? "Cartão"}
|
{group.name}
|
||||||
className="size-8 rounded-full object-cover"
|
</DialogTitle>
|
||||||
/>
|
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
) : (
|
{cardLogoSrc ? (
|
||||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
<Image
|
||||||
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
src={cardLogoSrc}
|
||||||
|
alt={`Logo do cartão ${cardName}`}
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
className="size-3.5 shrink-0 rounded-full object-cover opacity-75"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RiBankCard2Line className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{cardName}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<DialogTitle className="text-base">{group.name}</DialogTitle>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
Detalhes das parcelas do grupo {group.name}
|
Detalhes das parcelas do grupo {group.name}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function InstallmentExpenseListItem({
|
|||||||
const {
|
const {
|
||||||
compactLabel,
|
compactLabel,
|
||||||
isLast,
|
isLast,
|
||||||
|
remainingLabel,
|
||||||
remainingInstallments,
|
remainingInstallments,
|
||||||
remainingAmount,
|
remainingAmount,
|
||||||
endDate,
|
endDate,
|
||||||
@@ -65,15 +66,22 @@ export function InstallmentExpenseListItem({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
{remainingInstallments === 0 ? (
|
||||||
{endDate ? `Termina em ${endDate}` : null}
|
<p className="text-xs text-muted-foreground">
|
||||||
{" · Restante "}
|
{endDate ? `Termina em ${endDate}` : null}
|
||||||
<MoneyValues
|
{" · Quitado"}
|
||||||
amount={remainingAmount}
|
</p>
|
||||||
className="inline-block font-semibold"
|
) : (
|
||||||
/>{" "}
|
<p className="text-xs text-muted-foreground">
|
||||||
({remainingInstallments})
|
{endDate ? `Termina em ${endDate}` : null}
|
||||||
</p>
|
{` · ${remainingLabel}: `}
|
||||||
|
<MoneyValues
|
||||||
|
amount={remainingAmount}
|
||||||
|
className="inline-block font-semibold"
|
||||||
|
/>{" "}
|
||||||
|
({remainingInstallments}x)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<Progress value={progress} className="mt-1 h-2" />
|
<Progress value={progress} className="mt-1 h-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ type IncomeExpenseBalanceWidgetProps = {
|
|||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
receita: {
|
receita: {
|
||||||
label: "Receita",
|
label: "Receita",
|
||||||
color: "var(--success)",
|
color: "var(--chart-1)",
|
||||||
},
|
},
|
||||||
despesa: {
|
despesa: {
|
||||||
label: "Despesa",
|
label: "Despesa",
|
||||||
color: "var(--destructive)",
|
color: "var(--chart-2)",
|
||||||
},
|
},
|
||||||
balanco: {
|
balanco: {
|
||||||
label: "Balanço",
|
label: "Balanço",
|
||||||
color: "var(--warning)",
|
color: "var(--chart-3)",
|
||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export type InstallmentGroup = {
|
|||||||
cartaoDueDay: string | null;
|
cartaoDueDay: string | null;
|
||||||
cartaoLogo: string | null;
|
cartaoLogo: string | null;
|
||||||
totalInstallments: number;
|
totalInstallments: number;
|
||||||
|
trackedStartInstallment: number;
|
||||||
|
trackedInstallments: number;
|
||||||
|
untrackedInstallments: number;
|
||||||
paidInstallments: number;
|
paidInstallments: number;
|
||||||
pendingInstallments: InstallmentDetail[];
|
pendingInstallments: InstallmentDetail[];
|
||||||
totalPendingAmount: number;
|
totalPendingAmount: number;
|
||||||
@@ -92,7 +95,10 @@ export async function fetchInstallmentAnalysis(
|
|||||||
cartaoLogo: cards.logo,
|
cartaoLogo: cards.logo,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
.leftJoin(
|
||||||
|
cards,
|
||||||
|
and(eq(transactions.cardId, cards.id), eq(cards.userId, userId)),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
@@ -150,6 +156,12 @@ export async function fetchInstallmentAnalysis(
|
|||||||
cartaoDueDay: row.cartaoDueDay,
|
cartaoDueDay: row.cartaoDueDay,
|
||||||
cartaoLogo: row.cartaoLogo,
|
cartaoLogo: row.cartaoLogo,
|
||||||
totalInstallments: row.installmentCount ?? 0,
|
totalInstallments: row.installmentCount ?? 0,
|
||||||
|
trackedStartInstallment: installmentDetail.currentInstallment,
|
||||||
|
trackedInstallments: 1,
|
||||||
|
untrackedInstallments: Math.max(
|
||||||
|
0,
|
||||||
|
installmentDetail.currentInstallment - 1,
|
||||||
|
),
|
||||||
paidInstallments: 0,
|
paidInstallments: 0,
|
||||||
pendingInstallments: [installmentDetail],
|
pendingInstallments: [installmentDetail],
|
||||||
totalPendingAmount: amount,
|
totalPendingAmount: amount,
|
||||||
@@ -165,7 +177,13 @@ export async function fetchInstallmentAnalysis(
|
|||||||
const paidCount = group.pendingInstallments.filter(
|
const paidCount = group.pendingInstallments.filter(
|
||||||
(i) => i.isSettled,
|
(i) => i.isSettled,
|
||||||
).length;
|
).length;
|
||||||
|
const trackedStartInstallment = Math.min(
|
||||||
|
...group.pendingInstallments.map((i) => i.currentInstallment),
|
||||||
|
);
|
||||||
group.paidInstallments = paidCount;
|
group.paidInstallments = paidCount;
|
||||||
|
group.trackedStartInstallment = trackedStartInstallment;
|
||||||
|
group.trackedInstallments = group.pendingInstallments.length;
|
||||||
|
group.untrackedInstallments = Math.max(0, trackedStartInstallment - 1);
|
||||||
return group;
|
return group;
|
||||||
})
|
})
|
||||||
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
|
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
|
||||||
@@ -174,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
|
||||||
|
|||||||
@@ -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óx." | "Aberto";
|
||||||
remainingInstallments: number;
|
remainingInstallments: number;
|
||||||
remainingAmount: number;
|
remainingAmount: number;
|
||||||
endDate: string | null;
|
endDate: string | 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óx." : "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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -274,15 +274,14 @@ const buildPaymentStatusData = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target =
|
const isExpense = row.transactionType === TRANSACTION_TYPE_EXPENSE;
|
||||||
row.transactionType === TRANSACTION_TYPE_INCOME
|
const target = isExpense ? result.expenses : result.income;
|
||||||
? result.income
|
const displayAmount = isExpense ? Math.abs(amount) : amount;
|
||||||
: result.expenses;
|
|
||||||
|
|
||||||
if (row.isSettled === true) {
|
if (row.isSettled === true) {
|
||||||
target.confirmed += amount;
|
target.confirmed += displayAmount;
|
||||||
} else {
|
} else {
|
||||||
target.pending += amount;
|
target.pending += displayAmount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +405,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 =
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ export const InboxCard = memo(function InboxCard({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onViewDetails?.(item)}
|
onClick={() => onViewDetails?.(item)}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
aria-label="Ver detalhes"
|
aria-label="detalhes"
|
||||||
title="Ver detalhes"
|
title="detalhes"
|
||||||
>
|
>
|
||||||
<RiFileList2Line className="size-4" />
|
<RiFileList2Line className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
246
src/features/insights/components/analysis-summary-card.tsx
Normal file
246
src/features/insights/components/analysis-summary-card.tsx
Normal 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”, “dê 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -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,145 +174,173 @@ 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
|
{(isPending || isLoadingSavedInsights) && <LoadingState />}
|
||||||
onClick={handleAnalyze}
|
{!isPending && !isLoadingSavedInsights && error && (
|
||||||
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 &&
|
|
||||||
!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 && (
|
|
||||||
<ErrorState
|
|
||||||
title="Erro ao gerar insights"
|
|
||||||
error={error}
|
|
||||||
onRetry={handleAnalyze}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isPending &&
|
|
||||||
!isLoadingSavedInsights &&
|
|
||||||
!error &&
|
|
||||||
savedInsightsError && (
|
|
||||||
<ErrorState
|
<ErrorState
|
||||||
title="Erro ao carregar insights salvos"
|
title="Erro ao gerar insights"
|
||||||
error={savedInsightsError}
|
error={error}
|
||||||
onRetry={() => void savedInsightsQuery.refetch()}
|
onRetry={handleAnalyze}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isPending &&
|
{!isPending &&
|
||||||
!isLoadingSavedInsights &&
|
!isLoadingSavedInsights &&
|
||||||
insights &&
|
!error &&
|
||||||
!error &&
|
savedInsightsError && (
|
||||||
!savedInsightsError && <InsightsGrid insights={insights} />}
|
<ErrorState
|
||||||
</div>
|
title="Erro ao carregar insights salvos"
|
||||||
|
error={savedInsightsError}
|
||||||
|
onRetry={() => void savedInsightsQuery.refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isPending &&
|
||||||
|
!isLoadingSavedInsights &&
|
||||||
|
insights &&
|
||||||
|
!error &&
|
||||||
|
!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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
</CardHeader>
|
<div className="space-y-2">
|
||||||
<CardContent>
|
<div className="space-y-1">
|
||||||
{Array.from({ length: 4 }).map((_, j) => (
|
<p className="font-semibold text-lg tracking-tight">
|
||||||
<div
|
Preparando sua análise
|
||||||
key={j}
|
</p>
|
||||||
className="flex flex-1 border-b border-dashed py-2.5 gap-2 items-start last:border-0"
|
<p className="max-w-2xl text-muted-foreground text-sm leading-relaxed">
|
||||||
>
|
Estamos consolidando os dados do período e organizando os
|
||||||
<Skeleton className="size-4 shrink-0 rounded" />
|
achados em comportamentos, gatilhos, recomendações e
|
||||||
<div className="flex-1 space-y-2">
|
melhorias.
|
||||||
<Skeleton className="h-4 w-full" />
|
</p>
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
))}
|
</CardContent>
|
||||||
|
</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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
154
src/features/insights/components/model-selection-card.tsx
Normal file
154
src/features/insights/components/model-selection-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }
|
function isCustomModelProvider(
|
||||||
> = {
|
provider: AIProvider,
|
||||||
openai: {
|
): provider is (typeof CUSTOM_MODEL_PROVIDERS)[number] {
|
||||||
light: "/providers/chatgpt.svg",
|
return CUSTOM_MODEL_PROVIDERS.includes(
|
||||||
dark: "/providers/chatgpt_dark_mode.svg",
|
provider as (typeof CUSTOM_MODEL_PROVIDERS)[number],
|
||||||
},
|
);
|
||||||
anthropic: {
|
}
|
||||||
light: "/providers/claude.svg",
|
|
||||||
},
|
function getProviderFromValue(value: string): AIProvider | null {
|
||||||
google: {
|
if (value.startsWith("openrouter:")) {
|
||||||
light: "/providers/gemini.svg",
|
return "openrouter";
|
||||||
},
|
}
|
||||||
openrouter: {
|
|
||||||
light: "/providers/openrouter_light.svg",
|
if (value.startsWith("ollama:")) {
|
||||||
dark: "/providers/openrouter_dark.svg",
|
return "ollama";
|
||||||
},
|
}
|
||||||
};
|
|
||||||
|
if (value.includes("/")) {
|
||||||
|
return "openrouter";
|
||||||
|
}
|
||||||
|
|
||||||
|
return AVAILABLE_MODELS.find((model) => model.id === value)?.provider ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
export function ModelSelector({
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
|
period,
|
||||||
|
onAnalyze,
|
||||||
|
userInstructions,
|
||||||
|
onUserInstructionsChange,
|
||||||
|
onCancel,
|
||||||
disabled,
|
disabled,
|
||||||
|
isLoadingSavedInsights,
|
||||||
}: ModelSelectorProps) {
|
}: ModelSelectorProps) {
|
||||||
// Estado para armazenar o provider selecionado manualmente
|
|
||||||
const [selectedProvider, setSelectedProvider] = useState<AIProvider | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [customModel, setCustomModel] = useState(value);
|
const [customModel, setCustomModel] = useState(value);
|
||||||
|
|
||||||
// Sincronizar customModel quando value mudar (importante para pré-carregamento)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Se o value tem "/" é um modelo OpenRouter customizado
|
const detectedProvider = getProviderFromValue(value);
|
||||||
if (value.includes("/")) {
|
if (detectedProvider && isCustomModelProvider(detectedProvider)) {
|
||||||
setCustomModel(value);
|
setCustomModel(stripCustomProviderPrefix(value, detectedProvider));
|
||||||
setSelectedProvider("openrouter");
|
return;
|
||||||
} else {
|
|
||||||
setCustomModel(value);
|
|
||||||
// Limpar selectedProvider para deixar o useMemo detectar automaticamente
|
|
||||||
setSelectedProvider(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCustomModel(value);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
// Determinar provider atual baseado no modelo selecionado ou provider manual
|
const currentProvider = getProviderFromValue(value) ?? DEFAULT_PROVIDER;
|
||||||
const currentProvider = useMemo(() => {
|
|
||||||
// Se há um provider selecionado manualmente, use-o
|
|
||||||
if (selectedProvider) {
|
|
||||||
return selectedProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se o modelo tem "/" é OpenRouter
|
|
||||||
if (value.includes("/")) {
|
|
||||||
return "openrouter";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Caso contrário, tente detectar baseado no modelo
|
|
||||||
const model = AVAILABLE_MODELS.find((m) => m.id === value);
|
|
||||||
return model?.provider ?? DEFAULT_PROVIDER;
|
|
||||||
}, [value, selectedProvider]);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
gerar insights sobre seus dados financeiros. <br />
|
|
||||||
Diferentes modelos podem oferecer perspectivas variadas na análise.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Seletor */}
|
|
||||||
<div className="flex flex-col gap-4 min-w-xs">
|
|
||||||
<RadioGroup
|
|
||||||
value={currentProvider}
|
|
||||||
onValueChange={(v) => handleProviderChange(v as AIProvider)}
|
|
||||||
disabled={disabled}
|
|
||||||
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>
|
|
||||||
<Label
|
|
||||||
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}
|
disabled={disabled}
|
||||||
>
|
onProviderChange={handleProviderChange}
|
||||||
<SelectTrigger
|
/>
|
||||||
disabled={disabled}
|
|
||||||
className="border-none bg-neutral-200 dark:bg-neutral-800"
|
<ModelSelectionCard
|
||||||
>
|
currentProvider={currentProvider}
|
||||||
<SelectValue placeholder="Selecione um modelo" />
|
providerModels={providerModels}
|
||||||
</SelectTrigger>
|
selectValue={selectValue}
|
||||||
<SelectContent>
|
customModel={customModel}
|
||||||
{modelsByProvider[currentProvider].map((model) => (
|
isCustomModelActive={isCustomModelActive}
|
||||||
<SelectItem key={model.id} value={model.id}>
|
canUseCustomModel={isCustomModelProvider(currentProvider)}
|
||||||
{model.name}
|
canAnalyze={canAnalyze}
|
||||||
</SelectItem>
|
disabled={disabled}
|
||||||
))}
|
onModelSelect={handleModelSelect}
|
||||||
</SelectContent>
|
onCustomModelChange={handleCustomModelChange}
|
||||||
</Select>
|
onCancel={onCancel}
|
||||||
)}
|
onAnalyze={onAnalyze}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnalysisSummaryCard
|
||||||
|
period={period}
|
||||||
|
currentProvider={currentProvider}
|
||||||
|
selectedModelLabel={selectedModelLabel}
|
||||||
|
userInstructions={userInstructions}
|
||||||
|
onUserInstructionsChange={onUserInstructionsChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/features/insights/components/provider-icon.tsx
Normal file
59
src/features/insights/components/provider-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/features/insights/components/provider-selection-card.tsx
Normal file
109
src/features/insights/components/provider-selection-card.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { RiCheckLine } from "@remixicon/react";
|
||||||
|
import { type AIProvider, PROVIDERS } from "@/features/insights/constants";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
import { ProviderIcon } from "./provider-icon";
|
||||||
|
|
||||||
|
const PROVIDER_DETAILS: Record<AIProvider, { description: string }> = {
|
||||||
|
openai: {
|
||||||
|
description: "Qualidade e equilíbrio entre análise e custo.",
|
||||||
|
},
|
||||||
|
anthropic: {
|
||||||
|
description: "Forte em raciocínio e análises profundas.",
|
||||||
|
},
|
||||||
|
google: {
|
||||||
|
description: "Ideal para integração e velocidade.",
|
||||||
|
},
|
||||||
|
minimax: {
|
||||||
|
description: "Eficiente para grandes volumes de dados.",
|
||||||
|
},
|
||||||
|
openrouter: {
|
||||||
|
description: "Acesso a múltiplos modelos via API.",
|
||||||
|
},
|
||||||
|
ollama: {
|
||||||
|
description: "Execução local com privacidade total.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProviderSelectionCardProps {
|
||||||
|
currentProvider: AIProvider;
|
||||||
|
disabled?: boolean;
|
||||||
|
onProviderChange: (provider: AIProvider) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderSelectionCard({
|
||||||
|
currentProvider,
|
||||||
|
disabled,
|
||||||
|
onProviderChange,
|
||||||
|
}: ProviderSelectionCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="font-semibold text-2xl tracking-tight">
|
||||||
|
Definir modelo de análise
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-muted-foreground text-sm leading-relaxed">
|
||||||
|
Escolha o provedor de IA e o modelo específico que serão usados para
|
||||||
|
gerar insights sobre seus dados financeiros. Diferentes modelos
|
||||||
|
podem oferecer perspectivas variadas na análise.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold text-sm">1. Provedor de IA</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Selecione o provedor que melhor atende às suas necessidades.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{(Object.keys(PROVIDERS) as AIProvider[]).map((providerId) => {
|
||||||
|
const provider = PROVIDERS[providerId];
|
||||||
|
const details = PROVIDER_DETAILS[providerId];
|
||||||
|
const isSelected = currentProvider === providerId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={providerId}
|
||||||
|
onClick={() => onProviderChange(providerId)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"group relative rounded-2xl border p-4 text-left transition-all hover:border-primary/60 hover:bg-primary/5 disabled:cursor-not-allowed disabled:opacity-70",
|
||||||
|
isSelected &&
|
||||||
|
"border-primary bg-primary/10 shadow-sm ring-1 ring-primary/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-1 flex size-4 shrink-0 items-center justify-center rounded-full border text-transparent transition-colors",
|
||||||
|
isSelected &&
|
||||||
|
"border-primary bg-primary text-primary-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RiCheckLine className="size-3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProviderIcon provider={providerId} />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<span className="font-semibold text-sm leading-none">
|
||||||
|
{provider.name}
|
||||||
|
</span>
|
||||||
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
|
{details.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Tipos de providers disponíveis
|
* Tipos de providers disponíveis
|
||||||
*/
|
*/
|
||||||
export type AIProvider = "openai" | "anthropic" | "google" | "openrouter";
|
export type AIProvider =
|
||||||
|
| "openai"
|
||||||
|
| "anthropic"
|
||||||
|
| "google"
|
||||||
|
| "minimax"
|
||||||
|
| "openrouter"
|
||||||
|
| "ollama";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadados dos providers
|
* Metadados dos providers
|
||||||
@@ -10,22 +16,26 @@ export const PROVIDERS = {
|
|||||||
openai: {
|
openai: {
|
||||||
id: "openai" as const,
|
id: "openai" as const,
|
||||||
name: "ChatGPT",
|
name: "ChatGPT",
|
||||||
icon: "RiOpenaiLine",
|
|
||||||
},
|
},
|
||||||
anthropic: {
|
anthropic: {
|
||||||
id: "anthropic" as const,
|
id: "anthropic" as const,
|
||||||
name: "Claude AI",
|
name: "Claude AI",
|
||||||
icon: "RiRobot2Line",
|
|
||||||
},
|
},
|
||||||
google: {
|
google: {
|
||||||
id: "google" as const,
|
id: "google" as const,
|
||||||
name: "Gemini",
|
name: "Gemini",
|
||||||
icon: "RiGoogleLine",
|
},
|
||||||
|
minimax: {
|
||||||
|
id: "minimax" as const,
|
||||||
|
name: "MiniMax",
|
||||||
},
|
},
|
||||||
openrouter: {
|
openrouter: {
|
||||||
id: "openrouter" as const,
|
id: "openrouter" as const,
|
||||||
name: "OpenRouter",
|
name: "OpenRouter",
|
||||||
icon: "RiRouterLine",
|
},
|
||||||
|
ollama: {
|
||||||
|
id: "ollama" as const,
|
||||||
|
name: "Ollama",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -42,8 +52,8 @@ export const AVAILABLE_MODELS = [
|
|||||||
|
|
||||||
// Anthropic
|
// Anthropic
|
||||||
{
|
{
|
||||||
id: "claude-opus-4-7",
|
id: "claude-opus-4-8",
|
||||||
name: "Claude Opus 4.7",
|
name: "Claude Opus 4.8",
|
||||||
provider: "anthropic" as const,
|
provider: "anthropic" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -73,6 +83,49 @@ export const AVAILABLE_MODELS = [
|
|||||||
name: "Gemini 3.1 Flash Lite",
|
name: "Gemini 3.1 Flash Lite",
|
||||||
provider: "google" as const,
|
provider: "google" as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// MiniMax
|
||||||
|
{
|
||||||
|
id: "MiniMax-M2.7",
|
||||||
|
name: "MiniMax M2.7",
|
||||||
|
provider: "minimax" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "MiniMax-M2.7-highspeed",
|
||||||
|
name: "MiniMax M2.7 Highspeed",
|
||||||
|
provider: "minimax" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "MiniMax-M2.5",
|
||||||
|
name: "MiniMax M2.5",
|
||||||
|
provider: "minimax" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "MiniMax-M2.5-highspeed",
|
||||||
|
name: "MiniMax M2.5 Highspeed",
|
||||||
|
provider: "minimax" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "MiniMax-M2.1",
|
||||||
|
name: "MiniMax M2.1",
|
||||||
|
provider: "minimax" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "MiniMax-M2.1-highspeed",
|
||||||
|
name: "MiniMax M2.1 Highspeed",
|
||||||
|
provider: "minimax" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "MiniMax-M2",
|
||||||
|
name: "MiniMax M2",
|
||||||
|
provider: "minimax" as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ollama
|
||||||
|
{ id: "ollama:llama3.2", name: "Llama 3.2", provider: "ollama" as const },
|
||||||
|
{ id: "ollama:llama3.1", name: "Llama 3.1", provider: "ollama" as const },
|
||||||
|
{ id: "ollama:qwen2.5", name: "Qwen 2.5", provider: "ollama" as const },
|
||||||
|
{ id: "ollama:mistral", name: "Mistral", provider: "ollama" as const },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const DEFAULT_MODEL = "gpt-5.5";
|
export const DEFAULT_MODEL = "gpt-5.5";
|
||||||
|
|||||||
109
src/features/insights/lib/model-provider.ts
Normal file
109
src/features/insights/lib/model-provider.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { anthropic } from "@ai-sdk/anthropic";
|
||||||
|
import { google } from "@ai-sdk/google";
|
||||||
|
import { openai } from "@ai-sdk/openai";
|
||||||
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||||
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||||
|
import type { LanguageModel } from "ai";
|
||||||
|
import { minimax } from "vercel-minimax-ai-provider";
|
||||||
|
import { AVAILABLE_MODELS } from "../constants";
|
||||||
|
|
||||||
|
const OPENROUTER_MODEL_REGEX = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._:-]+$/;
|
||||||
|
|
||||||
|
type ResolveInsightsModelResult =
|
||||||
|
| { success: true; model: LanguageModel }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
function stripProviderPrefix(
|
||||||
|
modelId: string,
|
||||||
|
provider: "openrouter" | "ollama",
|
||||||
|
) {
|
||||||
|
return modelId.startsWith(`${provider}:`)
|
||||||
|
? modelId.slice(`${provider}:`.length).trim()
|
||||||
|
: modelId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInsightsModel(
|
||||||
|
modelId: string,
|
||||||
|
): ResolveInsightsModelResult {
|
||||||
|
const normalizedModelId = modelId.trim();
|
||||||
|
const selectedModel = AVAILABLE_MODELS.find(
|
||||||
|
(m) => m.id === normalizedModelId,
|
||||||
|
);
|
||||||
|
const isOpenRouterModel =
|
||||||
|
normalizedModelId.startsWith("openrouter:") ||
|
||||||
|
(!selectedModel && OPENROUTER_MODEL_REGEX.test(normalizedModelId));
|
||||||
|
const isOllamaModel = normalizedModelId.startsWith("ollama:");
|
||||||
|
|
||||||
|
if (!selectedModel && !isOpenRouterModel && !isOllamaModel) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Modelo inválido.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpenRouterModel) {
|
||||||
|
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 openrouterModelId = stripProviderPrefix(
|
||||||
|
normalizedModelId,
|
||||||
|
"openrouter",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!openrouterModelId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Informe um modelo válido do OpenRouter.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const openrouter = createOpenRouter({ apiKey });
|
||||||
|
return { success: true, model: openrouter.chat(openrouterModelId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOllamaModel || selectedModel?.provider === "ollama") {
|
||||||
|
const ollamaModelId = stripProviderPrefix(normalizedModelId, "ollama");
|
||||||
|
if (!ollamaModelId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Informe um modelo válido do Ollama.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ollama = createOpenAICompatible({
|
||||||
|
name: "ollama",
|
||||||
|
baseURL: process.env.OLLAMA_BASE_URL ?? "http://localhost:11434/v1",
|
||||||
|
apiKey: process.env.OLLAMA_API_KEY || "ollama",
|
||||||
|
supportsStructuredOutputs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, model: ollama.chatModel(ollamaModelId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedModel?.provider === "openai") {
|
||||||
|
return { success: true, model: openai(normalizedModelId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedModel?.provider === "anthropic") {
|
||||||
|
return { success: true, model: anthropic(normalizedModelId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedModel?.provider === "google") {
|
||||||
|
return { success: true, model: google(normalizedModelId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedModel?.provider === "minimax") {
|
||||||
|
return { success: true, model: minimax(normalizedModelId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Provider de modelo não suportado.",
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/features/insights/lib/user-instructions.ts
Normal file
1
src/features/insights/lib/user-instructions.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const USER_INSTRUCTIONS_MAX_LENGTH = 1000;
|
||||||
@@ -23,9 +23,14 @@ const navLinks = [
|
|||||||
interface MobileNavProps {
|
interface MobileNavProps {
|
||||||
isPublicDomain: boolean;
|
isPublicDomain: boolean;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
signupDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
|
export function MobileNav({
|
||||||
|
isPublicDomain,
|
||||||
|
isLoggedIn,
|
||||||
|
signupDisabled,
|
||||||
|
}: MobileNavProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,12 +80,14 @@ export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
|
|||||||
Entrar
|
Entrar
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/signup" onClick={() => setOpen(false)}>
|
{!signupDisabled && (
|
||||||
<Button className="w-full gap-2">
|
<Link href="/signup" onClick={() => setOpen(false)}>
|
||||||
Começar
|
<Button className="w-full gap-2">
|
||||||
<RiArrowRightSLine size={16} />
|
Começar
|
||||||
</Button>
|
<RiArrowRightSLine size={16} />
|
||||||
</Link>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAddCircleFill, RiDeleteBinLine } from "@remixicon/react";
|
import {
|
||||||
|
RiAddCircleFill,
|
||||||
|
RiCheckLine,
|
||||||
|
RiDeleteBinLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
import {
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -69,10 +73,13 @@ export function NoteDialog({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [newTaskText, setNewTaskText] = useState("");
|
const [newTaskText, setNewTaskText] = useState("");
|
||||||
|
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||||||
|
const [editingTaskText, setEditingTaskText] = useState("");
|
||||||
|
|
||||||
const titleRef = useRef<HTMLInputElement>(null);
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
const descRef = useRef<HTMLTextAreaElement>(null);
|
const descRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const newTaskRef = useRef<HTMLInputElement>(null);
|
const newTaskRef = useRef<HTMLInputElement>(null);
|
||||||
|
const editingTaskRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||||
open,
|
open,
|
||||||
@@ -90,6 +97,8 @@ export function NoteDialog({
|
|||||||
resetForm(buildInitialValues(note));
|
resetForm(buildInitialValues(note));
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setNewTaskText("");
|
setNewTaskText("");
|
||||||
|
setEditingTaskId(null);
|
||||||
|
setEditingTaskText("");
|
||||||
requestAnimationFrame(() => titleRef.current?.focus());
|
requestAnimationFrame(() => titleRef.current?.focus());
|
||||||
}
|
}
|
||||||
}, [dialogOpen, note, resetForm]);
|
}, [dialogOpen, note, resetForm]);
|
||||||
@@ -126,7 +135,12 @@ export function NoteDialog({
|
|||||||
formState.description.trim() === (note?.description ?? "").trim() &&
|
formState.description.trim() === (note?.description ?? "").trim() &&
|
||||||
JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
|
JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
|
||||||
|
|
||||||
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
|
const disableSubmit =
|
||||||
|
isPending ||
|
||||||
|
onlySpaces ||
|
||||||
|
unchanged ||
|
||||||
|
invalidLen ||
|
||||||
|
Boolean(editingTaskId);
|
||||||
|
|
||||||
const handleOpenChange = (v: boolean) => {
|
const handleOpenChange = (v: boolean) => {
|
||||||
setDialogOpen(v);
|
setDialogOpen(v);
|
||||||
@@ -159,6 +173,10 @@ export function NoteDialog({
|
|||||||
"tasks",
|
"tasks",
|
||||||
(formState.tasks || []).filter((t) => t.id !== taskId),
|
(formState.tasks || []).filter((t) => t.id !== taskId),
|
||||||
);
|
);
|
||||||
|
if (editingTaskId === taskId) {
|
||||||
|
setEditingTaskId(null);
|
||||||
|
setEditingTaskText("");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleTask = (taskId: string) => {
|
const handleToggleTask = (taskId: string) => {
|
||||||
@@ -170,6 +188,40 @@ export function NoteDialog({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStartEditTask = (task: Task) => {
|
||||||
|
if (isPending) return;
|
||||||
|
|
||||||
|
setEditingTaskId(task.id);
|
||||||
|
setEditingTaskText(task.text);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editingTaskRef.current?.focus();
|
||||||
|
editingTaskRef.current?.select();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTask = (taskId: string) => {
|
||||||
|
const text = normalize(editingTaskText);
|
||||||
|
if (!text) {
|
||||||
|
toast.error("O texto da tarefa não pode estar vazio.");
|
||||||
|
editingTaskRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateField(
|
||||||
|
"tasks",
|
||||||
|
(formState.tasks || []).map((t) =>
|
||||||
|
t.id === taskId ? { ...t, text } : t,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setEditingTaskId(null);
|
||||||
|
setEditingTaskText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEditTask = () => {
|
||||||
|
setEditingTaskId(null);
|
||||||
|
setEditingTaskText("");
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
@@ -373,33 +425,78 @@ export function NoteDialog({
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
className="flex items-center gap-3 rounded-md px-3 py-1.5 hover:bg-muted/50"
|
className="flex items-center gap-3 rounded-md px-3 py-1.5 hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<Checkbox
|
{editingTaskId === task.id ? (
|
||||||
className="data-[state=checked]:bg-success! data-[state=checked]:border-success! data-[state=checked]:text-success-foreground!"
|
<Input
|
||||||
checked={task.completed}
|
ref={editingTaskRef}
|
||||||
onCheckedChange={() => handleToggleTask(task.id)}
|
value={editingTaskText}
|
||||||
disabled={isPending}
|
onChange={(e) => setEditingTaskText(e.target.value)}
|
||||||
aria-label={`Marcar "${task.text}" como ${
|
onKeyDown={(e) => {
|
||||||
task.completed ? "não concluída" : "concluída"
|
if (e.key === "Enter") {
|
||||||
}`}
|
e.preventDefault();
|
||||||
/>
|
e.stopPropagation();
|
||||||
<span
|
handleSaveTask(task.id);
|
||||||
className={cn(
|
}
|
||||||
"flex-1 text-sm wrap-break-word",
|
if (e.key === "Escape") {
|
||||||
task.completed
|
e.preventDefault();
|
||||||
? "text-muted-foreground line-through"
|
e.stopPropagation();
|
||||||
: "text-foreground",
|
handleCancelEditTask();
|
||||||
)}
|
}
|
||||||
>
|
}}
|
||||||
{task.text}
|
disabled={isPending}
|
||||||
</span>
|
className="h-8 min-w-0 flex-1"
|
||||||
|
aria-label={`Editar "${task.text}"`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Checkbox
|
||||||
|
className="data-[state=checked]:bg-success! data-[state=checked]:border-success! data-[state=checked]:text-success-foreground!"
|
||||||
|
checked={task.completed}
|
||||||
|
onCheckedChange={() => handleToggleTask(task.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label={`Marcar "${task.text}" como ${
|
||||||
|
task.completed ? "não concluída" : "concluída"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStartEditTask(task)}
|
||||||
|
disabled={isPending}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 cursor-text text-left text-sm wrap-break-word transition-colors hover:text-primary disabled:cursor-not-allowed",
|
||||||
|
task.completed
|
||||||
|
? "text-muted-foreground line-through"
|
||||||
|
: "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.text}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveTask(task.id)}
|
onClick={() =>
|
||||||
|
editingTaskId === task.id
|
||||||
|
? handleSaveTask(task.id)
|
||||||
|
: handleRemoveTask(task.id)
|
||||||
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="shrink-0 text-muted-foreground/50 hover:text-destructive transition-colors"
|
className={cn(
|
||||||
aria-label={`Remover "${task.text}"`}
|
"shrink-0 transition-colors",
|
||||||
|
editingTaskId === task.id
|
||||||
|
? "text-success hover:text-success/80"
|
||||||
|
: "text-muted-foreground/50 hover:text-destructive",
|
||||||
|
)}
|
||||||
|
aria-label={
|
||||||
|
editingTaskId === task.id
|
||||||
|
? `Salvar "${task.text}"`
|
||||||
|
: `Remover "${task.text}"`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
{editingTaskId === task.id ? (
|
||||||
|
<RiCheckLine className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ export function buildReadOnlyOptionSets(
|
|||||||
categoriaOptionsMap.set(item.categoryId, {
|
categoriaOptionsMap.set(item.categoryId, {
|
||||||
value: item.categoryId,
|
value: item.categoryId,
|
||||||
label: normalizeOptionLabel(item.categoriaName, "Category"),
|
label: normalizeOptionLabel(item.categoriaName, "Category"),
|
||||||
|
group: item.categoriaType,
|
||||||
slug: item.categoryId,
|
slug: item.categoryId,
|
||||||
|
icon: item.categoriaIcon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -67,6 +69,8 @@ export function buildReadOnlyOptionSets(
|
|||||||
(option) => ({
|
(option) => ({
|
||||||
slug: option.value,
|
slug: option.value,
|
||||||
label: option.label,
|
label: option.label,
|
||||||
|
type: option.group,
|
||||||
|
icon: option.icon,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ import {
|
|||||||
getCurrentPeriod,
|
getCurrentPeriod,
|
||||||
periodToDate,
|
periodToDate,
|
||||||
} from "@/shared/utils/period";
|
} from "@/shared/utils/period";
|
||||||
|
import { slugify } from "@/shared/utils/string";
|
||||||
import type { CategoryReportFiltersProps } from "./types";
|
import type { CategoryReportFiltersProps } from "./types";
|
||||||
|
|
||||||
|
const getCategorySearchValue = (name: string, id: string) =>
|
||||||
|
`${name} ${slugify(name)} ${id}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Category Report Filters Component
|
* Category Report Filters Component
|
||||||
* Provides filters for categories selection and date range
|
* Provides filters for categories selection and date range
|
||||||
@@ -53,7 +57,14 @@ export function CategoryReportFilters({
|
|||||||
const filteredCategories = useMemo(() => {
|
const filteredCategories = useMemo(() => {
|
||||||
if (!searchValue) return categories;
|
if (!searchValue) return categories;
|
||||||
const search = searchValue.toLowerCase();
|
const search = searchValue.toLowerCase();
|
||||||
return categories.filter((cat) => cat.name.toLowerCase().includes(search));
|
const normalizedSearch = slugify(searchValue);
|
||||||
|
return categories.filter((cat) => {
|
||||||
|
const categorySearchValue = getCategorySearchValue(cat.name, cat.id);
|
||||||
|
return (
|
||||||
|
categorySearchValue.toLowerCase().includes(search) ||
|
||||||
|
categorySearchValue.includes(normalizedSearch)
|
||||||
|
);
|
||||||
|
});
|
||||||
}, [categories, searchValue]);
|
}, [categories, searchValue]);
|
||||||
|
|
||||||
// Get selected categories for display
|
// Get selected categories for display
|
||||||
@@ -76,15 +87,6 @@ export function CategoryReportFilters({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle select all
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
onFiltersChange({
|
|
||||||
...filters,
|
|
||||||
selectedCategories: categories.map((cat) => cat.id),
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle clear all
|
// Handle clear all
|
||||||
const handleClearAll = () => {
|
const handleClearAll = () => {
|
||||||
onFiltersChange({
|
onFiltersChange({
|
||||||
@@ -130,11 +132,9 @@ export function CategoryReportFilters({
|
|||||||
const selectedText =
|
const selectedText =
|
||||||
selectedCategories.length === 0
|
selectedCategories.length === 0
|
||||||
? "Categoria"
|
? "Categoria"
|
||||||
: selectedCategories.length === categories.length
|
: selectedCategories.length === 1
|
||||||
? "Todas"
|
? selectedCategories[0].name
|
||||||
: selectedCategories.length === 1
|
: `${selectedCategories.length} selecionadas`;
|
||||||
? selectedCategories[0].name
|
|
||||||
: `${selectedCategories.length} selecionadas`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -168,25 +168,18 @@ export function CategoryReportFilters({
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
|
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{/* Select All / Clear All */}
|
{filters.selectedCategories.length > 0 ? (
|
||||||
<div className="flex gap-1 p-2 border-b">
|
<div className="p-2 border-b">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs flex-1"
|
className="h-7 w-full text-xs"
|
||||||
onClick={handleSelectAll}
|
onClick={handleClearAll}
|
||||||
>
|
>
|
||||||
Todas
|
Limpar seleção
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</div>
|
||||||
variant="ghost"
|
) : null}
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs flex-1"
|
|
||||||
onClick={handleClearAll}
|
|
||||||
>
|
|
||||||
Limpar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category List */}
|
{/* Category List */}
|
||||||
{filteredCategories.map((category) => {
|
{filteredCategories.map((category) => {
|
||||||
@@ -200,7 +193,10 @@ export function CategoryReportFilters({
|
|||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={category.id}
|
key={category.id}
|
||||||
value={category.id}
|
value={getCategorySearchValue(
|
||||||
|
category.name,
|
||||||
|
category.id,
|
||||||
|
)}
|
||||||
onSelect={() => handleCategoryToggle(category.id)}
|
onSelect={() => handleCategoryToggle(category.id)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Link from "next/link";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { formatPeriodLabel } from "@/features/reports/lib/utils";
|
import { formatPeriodLabel } from "@/features/reports/lib/utils";
|
||||||
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||||
import StatusDot from "@/shared/components/feedback/status-dot";
|
|
||||||
import { Card } from "@/shared/components/ui/card";
|
import { Card } from "@/shared/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -37,6 +36,13 @@ export function CategoryTable({
|
|||||||
categories,
|
categories,
|
||||||
periods,
|
periods,
|
||||||
}: CategoryTableProps) {
|
}: CategoryTableProps) {
|
||||||
|
const categoryColumnLabel =
|
||||||
|
title === "Despesas"
|
||||||
|
? "Categoria Despesa"
|
||||||
|
: title === "Receitas"
|
||||||
|
? "Categoria Receita"
|
||||||
|
: "Categoria";
|
||||||
|
|
||||||
// Calculate section totals
|
// Calculate section totals
|
||||||
const sectionTotals = useMemo(() => {
|
const sectionTotals = useMemo(() => {
|
||||||
const totalsMap = new Map<string, number>();
|
const totalsMap = new Map<string, number>();
|
||||||
@@ -73,7 +79,7 @@ export function CategoryTable({
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[240px] min-w-[240px] font-medium">
|
<TableHead className="w-[240px] min-w-[240px] font-medium">
|
||||||
Categoria
|
{categoryColumnLabel}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
{periods.map((period) => (
|
{periods.map((period) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
@@ -114,14 +120,6 @@ export function CategoryTable({
|
|||||||
<TableRow key={category.categoryId}>
|
<TableRow key={category.categoryId}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusDot
|
|
||||||
color={
|
|
||||||
category.type === "receita"
|
|
||||||
? "bg-success"
|
|
||||||
: "bg-destructive"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CategoryIconBadge
|
<CategoryIconBadge
|
||||||
icon={category.icon}
|
icon={category.icon}
|
||||||
name={category.name}
|
name={category.name}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function DeleteAccountForm() {
|
|||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{isResetAction ? "Zerar sua conta?" : "Você tem certeza?"}
|
{isResetAction ? "ZERAR sua conta?" : "Você tem certeza?"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isResetAction
|
{isResetAction
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
import { uuidSchema } from "@/shared/lib/schemas/common";
|
import { uuidSchema } from "@/shared/lib/schemas/common";
|
||||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||||
|
import { comparePeriods } from "@/shared/utils/period";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema de validação para criar antecipação
|
* Schema de validação para criar antecipação
|
||||||
@@ -63,14 +64,18 @@ const cancelAnticipationSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export async function getEligibleInstallmentsAction(
|
export async function getEligibleInstallmentsAction(
|
||||||
seriesId: string,
|
seriesId: string,
|
||||||
|
anticipationPeriod: string,
|
||||||
): Promise<ActionResult<EligibleInstallment[]>> {
|
): Promise<ActionResult<EligibleInstallment[]>> {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
|
|
||||||
// Validar seriesId
|
// Validar seriesId
|
||||||
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
|
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
|
||||||
|
const validatedAnticipationPeriod =
|
||||||
|
createAnticipationSchema.shape.anticipationPeriod.parse(
|
||||||
|
anticipationPeriod,
|
||||||
|
);
|
||||||
|
|
||||||
// Buscar todas as parcelas da série que estão elegíveis
|
|
||||||
const rows = await db.query.transactions.findMany({
|
const rows = await db.query.transactions.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.seriesId, validatedSeriesId),
|
eq(transactions.seriesId, validatedSeriesId),
|
||||||
@@ -96,19 +101,23 @@ export async function getEligibleInstallmentsAction(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({
|
const eligibleInstallments: EligibleInstallment[] = rows
|
||||||
id: row.id,
|
.filter(
|
||||||
name: row.name,
|
(row) => comparePeriods(row.period, validatedAnticipationPeriod) > 0,
|
||||||
amount: row.amount,
|
)
|
||||||
period: row.period,
|
.map((row) => ({
|
||||||
purchaseDate: row.purchaseDate,
|
id: row.id,
|
||||||
dueDate: row.dueDate,
|
name: row.name,
|
||||||
currentInstallment: row.currentInstallment,
|
amount: row.amount,
|
||||||
installmentCount: row.installmentCount,
|
period: row.period,
|
||||||
paymentMethod: row.paymentMethod,
|
purchaseDate: row.purchaseDate,
|
||||||
categoryId: row.categoryId,
|
dueDate: row.dueDate,
|
||||||
payerId: row.payerId,
|
currentInstallment: row.currentInstallment,
|
||||||
}));
|
installmentCount: row.installmentCount,
|
||||||
|
paymentMethod: row.paymentMethod,
|
||||||
|
categoryId: row.categoryId,
|
||||||
|
payerId: row.payerId,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -195,6 +204,18 @@ export async function createInstallmentAnticipationAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedIncludesCurrentOrPastPeriod = installments.some(
|
||||||
|
(installment) =>
|
||||||
|
comparePeriods(installment.period, data.anticipationPeriod) <= 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedIncludesCurrentOrPastPeriod) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Selecione apenas parcelas de períodos futuros para antecipar.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Calcular valor total
|
// 2. Calcular valor total
|
||||||
const totalAmountCents = installments.reduce(
|
const totalAmountCents = installments.reduce(
|
||||||
(sum, inst) => sum + Number(inst.amount) * 100,
|
(sum, inst) => sum + Number(inst.amount) * 100,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
TRANSACTION_TYPES,
|
TRANSACTION_TYPES,
|
||||||
} from "@/features/transactions/lib/constants";
|
} from "@/features/transactions/lib/constants";
|
||||||
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||||
import { handleActionError } from "@/shared/lib/actions/helpers";
|
import { handleActionError } from "@/shared/lib/actions/helpers";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
fetchOwnedPayerIds,
|
fetchOwnedPayerIds,
|
||||||
formatPaidInvoicePeriods,
|
formatPaidInvoicePeriods,
|
||||||
getPaidInvoicePeriods,
|
getPaidInvoicePeriods,
|
||||||
|
isInitialBalanceTransaction,
|
||||||
type MassAddInput,
|
type MassAddInput,
|
||||||
massAddSchema,
|
massAddSchema,
|
||||||
resolvePeriod,
|
resolvePeriod,
|
||||||
@@ -47,6 +49,19 @@ const getPeriodOffset = (basePeriod: string, targetPeriod: string) => {
|
|||||||
return (target.year - base.year) * 12 + (target.month - base.month);
|
return (target.year - base.year) * 12 + (target.month - base.month);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ProtectedTransactionCandidate = {
|
||||||
|
note: string | null;
|
||||||
|
transactionType: string | null;
|
||||||
|
condition: string | null;
|
||||||
|
paymentMethod: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isProtectedTransaction = (
|
||||||
|
record: ProtectedTransactionCandidate,
|
||||||
|
): boolean =>
|
||||||
|
Boolean(record.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
||||||
|
isInitialBalanceTransaction(record);
|
||||||
|
|
||||||
export async function deleteTransactionBulkAction(
|
export async function deleteTransactionBulkAction(
|
||||||
input: DeleteBulkInput,
|
input: DeleteBulkInput,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
@@ -61,6 +76,9 @@ export async function deleteTransactionBulkAction(
|
|||||||
seriesId: true,
|
seriesId: true,
|
||||||
period: true,
|
period: true,
|
||||||
condition: true,
|
condition: true,
|
||||||
|
transactionType: true,
|
||||||
|
paymentMethod: true,
|
||||||
|
note: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
@@ -79,6 +97,13 @@ export async function deleteTransactionBulkAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isProtectedTransaction(existing)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Lançamentos protegidos não podem ser removidos em massa.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let scopeFilter: ReturnType<typeof and>;
|
let scopeFilter: ReturnType<typeof and>;
|
||||||
let successMessage: string;
|
let successMessage: string;
|
||||||
|
|
||||||
@@ -171,6 +196,7 @@ export async function updateTransactionBulkAction(
|
|||||||
purchaseDate: true,
|
purchaseDate: true,
|
||||||
payerId: true,
|
payerId: true,
|
||||||
cardId: true,
|
cardId: true,
|
||||||
|
note: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
@@ -189,6 +215,13 @@ export async function updateTransactionBulkAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isProtectedTransaction(existing)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Lançamentos protegidos não podem ser atualizados em massa.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const baseUpdatePayload: Record<string, unknown> = {
|
const baseUpdatePayload: Record<string, unknown> = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
categoryId: data.categoryId ?? null,
|
categoryId: data.categoryId ?? null,
|
||||||
@@ -753,6 +786,13 @@ export async function deleteMultipleTransactionsAction(
|
|||||||
return { success: false, error: "Nenhum lançamento encontrado." };
|
return { success: false, error: "Nenhum lançamento encontrado." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existing.some(isProtectedTransaction)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Lançamentos protegidos não podem ser removidos em massa.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const linkedAttachments = await db
|
const linkedAttachments = await db
|
||||||
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
||||||
.from(transactionAttachments)
|
.from(transactionAttachments)
|
||||||
|
|||||||
@@ -155,14 +155,20 @@ export async function validateAllOwnership(
|
|||||||
fields: {
|
fields: {
|
||||||
payerId?: string | null;
|
payerId?: string | null;
|
||||||
secondaryPayerId?: string | null;
|
secondaryPayerId?: string | null;
|
||||||
|
splitPayerIds?: Array<string | null | undefined>;
|
||||||
categoryId?: string | null;
|
categoryId?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
cardId?: string | null;
|
cardId?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
|
const payerIds = [
|
||||||
|
fields.payerId,
|
||||||
|
fields.secondaryPayerId,
|
||||||
|
...(fields.splitPayerIds ?? []),
|
||||||
|
];
|
||||||
const [ownedPayerIds, ownedCategoryIds, ownedAccountIds, ownedCardIds] =
|
const [ownedPayerIds, ownedCategoryIds, ownedAccountIds, ownedCardIds] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchOwnedPayerIds(userId, [fields.payerId, fields.secondaryPayerId]),
|
fetchOwnedPayerIds(userId, payerIds),
|
||||||
fetchOwnedCategoryIds(userId, [fields.categoryId]),
|
fetchOwnedCategoryIds(userId, [fields.categoryId]),
|
||||||
fetchOwnedAccountIds(userId, [fields.accountId]),
|
fetchOwnedAccountIds(userId, [fields.accountId]),
|
||||||
fetchOwnedCardIds(userId, [fields.cardId]),
|
fetchOwnedCardIds(userId, [fields.cardId]),
|
||||||
@@ -171,6 +177,7 @@ export async function validateAllOwnership(
|
|||||||
const checks = [
|
const checks = [
|
||||||
!fields.payerId || ownedPayerIds.has(fields.payerId),
|
!fields.payerId || ownedPayerIds.has(fields.payerId),
|
||||||
!fields.secondaryPayerId || ownedPayerIds.has(fields.secondaryPayerId),
|
!fields.secondaryPayerId || ownedPayerIds.has(fields.secondaryPayerId),
|
||||||
|
(fields.splitPayerIds ?? []).every((id) => !id || ownedPayerIds.has(id)),
|
||||||
!fields.categoryId || ownedCategoryIds.has(fields.categoryId),
|
!fields.categoryId || ownedCategoryIds.has(fields.categoryId),
|
||||||
!fields.accountId || ownedAccountIds.has(fields.accountId),
|
!fields.accountId || ownedAccountIds.has(fields.accountId),
|
||||||
!fields.cardId || ownedCardIds.has(fields.cardId),
|
!fields.cardId || ownedCardIds.has(fields.cardId),
|
||||||
@@ -178,7 +185,8 @@ export async function validateAllOwnership(
|
|||||||
|
|
||||||
const errors = [
|
const errors = [
|
||||||
"Pessoa não encontrada ou sem permissão.",
|
"Pessoa não encontrada ou sem permissão.",
|
||||||
"Pessoa secundário não encontrado ou sem permissão.",
|
"Pessoa secundária não encontrada ou sem permissão.",
|
||||||
|
"Uma das pessoas selecionadas não foi encontrada ou está sem permissão.",
|
||||||
"Categoria não encontrada.",
|
"Categoria não encontrada.",
|
||||||
"Conta não encontrada.",
|
"Conta não encontrada.",
|
||||||
"Cartão não encontrado.",
|
"Cartão não encontrado.",
|
||||||
@@ -322,6 +330,14 @@ const baseFields = z.object({
|
|||||||
}),
|
}),
|
||||||
payerId: uuidSchema("Payer").nullable().optional(),
|
payerId: uuidSchema("Payer").nullable().optional(),
|
||||||
secondaryPayerId: uuidSchema("Payer secundário").optional(),
|
secondaryPayerId: uuidSchema("Payer secundário").optional(),
|
||||||
|
splitShares: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
payerId: uuidSchema("Pessoa"),
|
||||||
|
amount: z.coerce.number().min(0.01, "Informe um valor maior que zero."),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
isSplit: z.boolean().optional().default(false),
|
isSplit: z.boolean().optional().default(false),
|
||||||
primarySplitAmount: z.coerce.number().min(0).optional(),
|
primarySplitAmount: z.coerce.number().min(0).optional(),
|
||||||
secondarySplitAmount: z.coerce.number().min(0).optional(),
|
secondarySplitAmount: z.coerce.number().min(0).optional(),
|
||||||
@@ -335,6 +351,12 @@ const baseFields = z.object({
|
|||||||
.min(1, "Selecione uma quantidade válida.")
|
.min(1, "Selecione uma quantidade válida.")
|
||||||
.max(60, "Selecione uma quantidade válida.")
|
.max(60, "Selecione uma quantidade válida.")
|
||||||
.optional(),
|
.optional(),
|
||||||
|
startInstallment: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, "Selecione uma parcela válida.")
|
||||||
|
.max(60, "Selecione uma parcela válida.")
|
||||||
|
.optional(),
|
||||||
recurrenceCount: z.coerce
|
recurrenceCount: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
@@ -415,10 +437,21 @@ const refineLancamento = (
|
|||||||
path: ["installmentCount"],
|
path: ["installmentCount"],
|
||||||
message: "Selecione pelo menos duas parcelas.",
|
message: "Selecione pelo menos duas parcelas.",
|
||||||
});
|
});
|
||||||
|
} else if (
|
||||||
|
data.startInstallment &&
|
||||||
|
data.startInstallment > data.installmentCount
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["startInstallment"],
|
||||||
|
message: "A parcela inicial não pode ser maior que o total.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.isSplit) {
|
if (data.isSplit) {
|
||||||
|
const shares = resolveSplitShares(data);
|
||||||
|
|
||||||
if (!data.payerId) {
|
if (!data.payerId) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
@@ -427,30 +460,38 @@ const refineLancamento = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.secondaryPayerId) {
|
if (shares.length < 2) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["secondaryPayerId"],
|
path: ["splitShares"],
|
||||||
message: "Selecione a pessoa secundário para dividir o lançamento.",
|
message: "Selecione pelo menos uma pessoa para dividir o lançamento.",
|
||||||
});
|
|
||||||
} else if (data.payerId && data.secondaryPayerId === data.payerId) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["secondaryPayerId"],
|
|
||||||
message: "Escolha uma pessoa diferente para dividir o lançamento.",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const uniquePayerIds = new Set(shares.map((share) => share.payerId));
|
||||||
data.primarySplitAmount !== undefined &&
|
if (uniquePayerIds.size !== shares.length) {
|
||||||
data.secondarySplitAmount !== undefined
|
ctx.addIssue({
|
||||||
) {
|
code: z.ZodIssueCode.custom,
|
||||||
const sum = data.primarySplitAmount + data.secondarySplitAmount;
|
path: ["splitShares"],
|
||||||
|
message: "Escolha pessoas diferentes para dividir o lançamento.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shares.some((share) => share.amount <= 0)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["splitShares"],
|
||||||
|
message: "Informe um valor maior que zero para cada pessoa.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shares.length > 0) {
|
||||||
|
const sum = shares.reduce((total, share) => total + share.amount, 0);
|
||||||
const total = Math.abs(data.amount);
|
const total = Math.abs(data.amount);
|
||||||
if (Math.abs(sum - total) > 0.01) {
|
if (Math.abs(sum - total) > 0.01) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["primarySplitAmount"],
|
path: ["splitShares"],
|
||||||
message: "A soma das divisões deve ser igual ao valor total.",
|
message: "A soma das divisões deve ser igual ao valor total.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -546,11 +587,41 @@ type Share = {
|
|||||||
amountCents: number;
|
amountCents: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SplitShareInput = {
|
||||||
|
payerId: string;
|
||||||
|
amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveSplitShares = (data: {
|
||||||
|
payerId?: string | null;
|
||||||
|
secondaryPayerId?: string | null;
|
||||||
|
splitShares?: SplitShareInput[];
|
||||||
|
primarySplitAmount?: number;
|
||||||
|
secondarySplitAmount?: number;
|
||||||
|
}): SplitShareInput[] => {
|
||||||
|
if (data.splitShares && data.splitShares.length > 0) {
|
||||||
|
return data.splitShares;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.payerId || !data.secondaryPayerId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ payerId: data.payerId, amount: data.primarySplitAmount ?? 0 },
|
||||||
|
{
|
||||||
|
payerId: data.secondaryPayerId,
|
||||||
|
amount: data.secondarySplitAmount ?? 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
export const buildShares = ({
|
export const buildShares = ({
|
||||||
totalCents,
|
totalCents,
|
||||||
payerId,
|
payerId,
|
||||||
isSplit,
|
isSplit,
|
||||||
secondaryPayerId,
|
secondaryPayerId,
|
||||||
|
splitShares,
|
||||||
primarySplitAmountCents,
|
primarySplitAmountCents,
|
||||||
secondarySplitAmountCents,
|
secondarySplitAmountCents,
|
||||||
}: {
|
}: {
|
||||||
@@ -558,10 +629,18 @@ export const buildShares = ({
|
|||||||
payerId: string | null;
|
payerId: string | null;
|
||||||
isSplit: boolean;
|
isSplit: boolean;
|
||||||
secondaryPayerId?: string;
|
secondaryPayerId?: string;
|
||||||
|
splitShares?: SplitShareInput[];
|
||||||
primarySplitAmountCents?: number;
|
primarySplitAmountCents?: number;
|
||||||
secondarySplitAmountCents?: number;
|
secondarySplitAmountCents?: number;
|
||||||
}): Share[] => {
|
}): Share[] => {
|
||||||
if (isSplit) {
|
if (isSplit) {
|
||||||
|
if (splitShares && splitShares.length > 0) {
|
||||||
|
return splitShares.map((share) => ({
|
||||||
|
payerId: share.payerId,
|
||||||
|
amountCents: Math.round(share.amount * 100),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (!payerId || !secondaryPayerId) {
|
if (!payerId || !secondaryPayerId) {
|
||||||
throw new Error("Configuração de divisão inválida para o lançamento.");
|
throw new Error("Configuração de divisão inválida para o lançamento.");
|
||||||
}
|
}
|
||||||
@@ -651,24 +730,27 @@ export const buildTransactionRecords = ({
|
|||||||
|
|
||||||
if (data.condition === "Parcelado") {
|
if (data.condition === "Parcelado") {
|
||||||
const installmentTotal = data.installmentCount ?? 0;
|
const installmentTotal = data.installmentCount ?? 0;
|
||||||
|
const startInstallment = data.startInstallment ?? 1;
|
||||||
const amountsByShare = shares.map((share) =>
|
const amountsByShare = shares.map((share) =>
|
||||||
splitAmount(share.amountCents, installmentTotal),
|
splitAmount(share.amountCents, installmentTotal),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let installment = 0;
|
let index = 0;
|
||||||
installment < installmentTotal;
|
index <= installmentTotal - startInstallment;
|
||||||
installment += 1
|
index += 1
|
||||||
) {
|
) {
|
||||||
const installmentPeriod = addMonthsToPeriod(period, installment);
|
const currentInstallment = startInstallment + index;
|
||||||
|
const installmentPeriod = addMonthsToPeriod(period, index);
|
||||||
const installmentDueDate = dueDate
|
const installmentDueDate = dueDate
|
||||||
? addMonthsToDate(dueDate, installment)
|
? addMonthsToDate(dueDate, index)
|
||||||
: null;
|
: null;
|
||||||
const splitGroupId = cycleSplitGroupId();
|
const splitGroupId = cycleSplitGroupId();
|
||||||
|
|
||||||
shares.forEach((share, shareIndex) => {
|
shares.forEach((share, shareIndex) => {
|
||||||
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
|
const amountCents =
|
||||||
const settled = resolveSettledValue(installment);
|
amountsByShare[shareIndex]?.[currentInstallment - 1] ?? 0;
|
||||||
|
const settled = resolveSettledValue(index);
|
||||||
records.push({
|
records.push({
|
||||||
...basePayload,
|
...basePayload,
|
||||||
amount: centsToDecimalString(amountCents * amountSign),
|
amount: centsToDecimalString(amountCents * amountSign),
|
||||||
@@ -677,7 +759,7 @@ export const buildTransactionRecords = ({
|
|||||||
period: installmentPeriod,
|
period: installmentPeriod,
|
||||||
isSettled: settled,
|
isSettled: settled,
|
||||||
installmentCount: installmentTotal,
|
installmentCount: installmentTotal,
|
||||||
currentInstallment: installment + 1,
|
currentInstallment,
|
||||||
recurrenceCount: null,
|
recurrenceCount: null,
|
||||||
dueDate: installmentDueDate,
|
dueDate: installmentDueDate,
|
||||||
splitGroupId,
|
splitGroupId,
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
|||||||
dividedFilter: z.string().nullable(),
|
dividedFilter: z.string().nullable(),
|
||||||
amountMinFilter: z.number().nullable(),
|
amountMinFilter: z.number().nullable(),
|
||||||
amountMaxFilter: z.number().nullable(),
|
amountMaxFilter: z.number().nullable(),
|
||||||
|
dateStartFilter: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.nullable(),
|
||||||
|
dateEndFilter: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.nullable(),
|
||||||
}),
|
}),
|
||||||
accountId: z.string().min(1).nullable().optional(),
|
accountId: z.string().min(1).nullable().optional(),
|
||||||
cardId: z.string().min(1).nullable().optional(),
|
cardId: z.string().min(1).nullable().optional(),
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { and, eq, inArray } from "drizzle-orm";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { transactions } from "@/db/schema";
|
import { transactions } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
|
fetchOwnedCategoryIds,
|
||||||
|
fetchOwnedPayerIds,
|
||||||
validateCartaoOwnership,
|
validateCartaoOwnership,
|
||||||
validateContaOwnership,
|
validateContaOwnership,
|
||||||
validatePayerOwnership,
|
|
||||||
} from "@/features/transactions/actions/core";
|
} from "@/features/transactions/actions/core";
|
||||||
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
@@ -21,6 +22,7 @@ const importRowSchema = z.object({
|
|||||||
description: z.string().min(1, "Descrição obrigatória."),
|
description: z.string().min(1, "Descrição obrigatória."),
|
||||||
transactionType: z.enum(["income", "expense"]),
|
transactionType: z.enum(["income", "expense"]),
|
||||||
categoryId: uuidSchema("Category").nullable().optional(),
|
categoryId: uuidSchema("Category").nullable().optional(),
|
||||||
|
payerId: uuidSchema("Payer").nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const importSchema = z.object({
|
const importSchema = z.object({
|
||||||
@@ -76,14 +78,34 @@ export async function importTransactionsAction(
|
|||||||
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
|
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
|
||||||
parsed.data;
|
parsed.data;
|
||||||
|
|
||||||
// Valida ownership
|
const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
|
||||||
const [payerOk, accountOk, cardOk] = await Promise.all([
|
|
||||||
validatePayerOwnership(userId, payerId),
|
if (payerIdsByRow.some((id) => !id)) {
|
||||||
validateContaOwnership(userId, accountId),
|
return { success: false, error: "Pessoa obrigatória." };
|
||||||
validateCartaoOwnership(userId, cardId),
|
}
|
||||||
]);
|
|
||||||
|
// Valida ownership
|
||||||
|
const [ownedPayerIds, ownedCategoryIds, accountOk, cardOk] =
|
||||||
|
await Promise.all([
|
||||||
|
fetchOwnedPayerIds(userId, payerIdsByRow),
|
||||||
|
fetchOwnedCategoryIds(
|
||||||
|
userId,
|
||||||
|
rows.map((row) => row.categoryId),
|
||||||
|
),
|
||||||
|
validateContaOwnership(userId, accountId),
|
||||||
|
validateCartaoOwnership(userId, cardId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (payerIdsByRow.some((id) => id && !ownedPayerIds.has(id))) {
|
||||||
|
return { success: false, error: "Pessoa não encontrada." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
rows.some((row) => row.categoryId && !ownedCategoryIds.has(row.categoryId))
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Categoria não encontrada." };
|
||||||
|
}
|
||||||
|
|
||||||
if (!payerOk) return { success: false, error: "Pessoa não encontrada." };
|
|
||||||
if (!accountOk) return { success: false, error: "Conta não encontrada." };
|
if (!accountOk) return { success: false, error: "Conta não encontrada." };
|
||||||
if (!cardOk) return { success: false, error: "Cartão não encontrado." };
|
if (!cardOk) return { success: false, error: "Cartão não encontrado." };
|
||||||
|
|
||||||
@@ -96,7 +118,7 @@ export async function importTransactionsAction(
|
|||||||
// Cartão de crédito: fatura pode ainda não ter sido paga
|
// Cartão de crédito: fatura pode ainda não ter sido paga
|
||||||
const isSettled = paymentMethod !== "Cartão de crédito";
|
const isSettled = paymentMethod !== "Cartão de crédito";
|
||||||
|
|
||||||
const records = rows.map((row) => {
|
const records = rows.map((row, index) => {
|
||||||
const purchaseDate = parseLocalDateString(row.date);
|
const purchaseDate = parseLocalDateString(row.date);
|
||||||
const period =
|
const period =
|
||||||
invoicePeriod ??
|
invoicePeriod ??
|
||||||
@@ -115,7 +137,7 @@ export async function importTransactionsAction(
|
|||||||
period,
|
period,
|
||||||
isSettled,
|
isSettled,
|
||||||
userId,
|
userId,
|
||||||
payerId: payerId ?? null,
|
payerId: payerIdsByRow[index],
|
||||||
accountId: accountId ?? null,
|
accountId: accountId ?? null,
|
||||||
cardId: cardId ?? null,
|
cardId: cardId ?? null,
|
||||||
categoryId: row.categoryId ?? null,
|
categoryId: row.categoryId ?? null,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
transactionAttachments,
|
transactionAttachments,
|
||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||||
import { handleActionError } from "@/shared/lib/actions/helpers";
|
import { handleActionError } from "@/shared/lib/actions/helpers";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
@@ -55,6 +56,7 @@ export async function createTransactionAction(
|
|||||||
const ownershipError = await validateAllOwnership(user.id, {
|
const ownershipError = await validateAllOwnership(user.id, {
|
||||||
payerId: data.payerId,
|
payerId: data.payerId,
|
||||||
secondaryPayerId: data.secondaryPayerId,
|
secondaryPayerId: data.secondaryPayerId,
|
||||||
|
splitPayerIds: data.splitShares?.map((share) => share.payerId),
|
||||||
categoryId: data.categoryId,
|
categoryId: data.categoryId,
|
||||||
accountId: data.accountId,
|
accountId: data.accountId,
|
||||||
cardId: data.cardId,
|
cardId: data.cardId,
|
||||||
@@ -83,6 +85,7 @@ export async function createTransactionAction(
|
|||||||
payerId: data.payerId ?? null,
|
payerId: data.payerId ?? null,
|
||||||
isSplit: data.isSplit ?? false,
|
isSplit: data.isSplit ?? false,
|
||||||
secondaryPayerId: data.secondaryPayerId,
|
secondaryPayerId: data.secondaryPayerId,
|
||||||
|
splitShares: data.splitShares,
|
||||||
primarySplitAmountCents: data.primarySplitAmount
|
primarySplitAmountCents: data.primarySplitAmount
|
||||||
? Math.round(data.primarySplitAmount * 100)
|
? Math.round(data.primarySplitAmount * 100)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -206,6 +209,7 @@ export async function updateTransactionAction(
|
|||||||
const ownershipError = await validateAllOwnership(user.id, {
|
const ownershipError = await validateAllOwnership(user.id, {
|
||||||
payerId: data.payerId,
|
payerId: data.payerId,
|
||||||
secondaryPayerId: data.secondaryPayerId,
|
secondaryPayerId: data.secondaryPayerId,
|
||||||
|
splitPayerIds: data.splitShares?.map((share) => share.payerId),
|
||||||
categoryId: data.categoryId,
|
categoryId: data.categoryId,
|
||||||
accountId: data.accountId,
|
accountId: data.accountId,
|
||||||
cardId: data.cardId,
|
cardId: data.cardId,
|
||||||
@@ -230,13 +234,6 @@ export async function updateTransactionAction(
|
|||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
eq(transactions.userId, user.id),
|
eq(transactions.userId, user.id),
|
||||||
),
|
),
|
||||||
with: {
|
|
||||||
category: {
|
|
||||||
columns: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})) as
|
})) as
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -248,7 +245,6 @@ export async function updateTransactionAction(
|
|||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
cardId: string | null;
|
cardId: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
category: { name: string } | null;
|
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -256,14 +252,17 @@ export async function updateTransactionAction(
|
|||||||
return { success: false, error: "Lançamento não encontrado." };
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"];
|
if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
||||||
if (
|
|
||||||
existing.category?.name &&
|
|
||||||
categoriasProtegidasEdicao.includes(existing.category.name)
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser editados.`,
|
error: "Pagamentos automáticos de fatura não podem ser editados.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInitialBalanceTransaction(existing)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Lançamentos de saldo inicial não podem ser editados.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,13 +390,6 @@ export async function deleteTransactionAction(
|
|||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
eq(transactions.userId, user.id),
|
eq(transactions.userId, user.id),
|
||||||
),
|
),
|
||||||
with: {
|
|
||||||
category: {
|
|
||||||
columns: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})) as
|
})) as
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -411,7 +403,6 @@ export async function deleteTransactionAction(
|
|||||||
period: string;
|
period: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
category: { name: string } | null;
|
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -419,14 +410,17 @@ export async function deleteTransactionAction(
|
|||||||
return { success: false, error: "Lançamento não encontrado." };
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"];
|
if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
||||||
if (
|
|
||||||
existing.category?.name &&
|
|
||||||
categoriasProtegidasRemocao.includes(existing.category.name)
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser removidos.`,
|
error: "Pagamentos automáticos de fatura não podem ser removidos.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInitialBalanceTransaction(existing)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Lançamentos de saldo inicial não podem ser removidos.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +480,7 @@ export async function updateTransactionSplitPairAction(
|
|||||||
|
|
||||||
const ownershipError = await validateAllOwnership(user.id, {
|
const ownershipError = await validateAllOwnership(user.id, {
|
||||||
payerId: data.payerId,
|
payerId: data.payerId,
|
||||||
|
splitPayerIds: data.splitShares?.map((share) => share.payerId),
|
||||||
categoryId: data.categoryId,
|
categoryId: data.categoryId,
|
||||||
accountId: data.accountId,
|
accountId: data.accountId,
|
||||||
cardId: data.cardId,
|
cardId: data.cardId,
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAttachment2, RiCloseLine } from "@remixicon/react";
|
import { RiAttachment2, RiCloseLine } from "@remixicon/react";
|
||||||
import { useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
ALLOWED_MIME_TYPES,
|
ALLOWED_MIME_TYPES,
|
||||||
DEFAULT_MAX_FILE_SIZE_MB,
|
DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
} from "@/features/transactions/lib/attachments-config";
|
} from "@/features/transactions/lib/attachments-config";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
getFilesFromClipboard,
|
||||||
|
isTextEditingTarget,
|
||||||
|
validateAttachmentFile,
|
||||||
|
} from "./attachment-file-utils";
|
||||||
|
|
||||||
interface AttachmentFilePickerProps {
|
interface AttachmentFilePickerProps {
|
||||||
files: File[];
|
files: File[];
|
||||||
@@ -22,34 +27,54 @@ export function AttachmentFilePicker({
|
|||||||
onRemove,
|
onRemove,
|
||||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
}: AttachmentFilePickerProps) {
|
}: AttachmentFilePickerProps) {
|
||||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function addFile(file: File) {
|
||||||
|
const validation = validateAttachmentFile(file, maxSizeMb);
|
||||||
|
if (!validation.ok) {
|
||||||
|
toast.error(validation.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onAdd(file);
|
||||||
|
}
|
||||||
|
|
||||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const selected = e.target.files?.[0];
|
const selected = e.target.files?.[0];
|
||||||
if (inputRef.current) inputRef.current.value = "";
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
|
||||||
if (
|
addFile(selected);
|
||||||
!ALLOWED_MIME_TYPES.includes(
|
|
||||||
selected.type as (typeof ALLOWED_MIME_TYPES)[number],
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
toast.error(
|
|
||||||
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected.size > maxFileSizeBytes) {
|
|
||||||
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onAdd(selected);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
|
||||||
|
const pastedFiles = getFilesFromClipboard(event);
|
||||||
|
if (pastedFiles.length === 0) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
for (const file of pastedFiles) {
|
||||||
|
addFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleDocumentPaste(event: ClipboardEvent) {
|
||||||
|
if (isTextEditingTarget(event.target)) return;
|
||||||
|
|
||||||
|
const pastedFiles = getFilesFromClipboard(event);
|
||||||
|
if (pastedFiles.length === 0) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
for (const file of pastedFiles) {
|
||||||
|
addFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("paste", handleDocumentPaste);
|
||||||
|
return () => document.removeEventListener("paste", handleDocumentPaste);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-medium">Anexos</p>
|
<p className="text-xs font-medium">Anexos</p>
|
||||||
@@ -90,13 +115,15 @@ export function AttachmentFilePicker({
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onPaste={handlePaste}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<RiAttachment2 className="size-4" />
|
<RiAttachment2 className="size-4" />
|
||||||
Adicionar anexo
|
Adicionar anexo
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
|
||||||
|
MB
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
ALLOWED_MIME_TYPES,
|
||||||
|
DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
|
} from "@/features/transactions/lib/attachments-config";
|
||||||
|
|
||||||
|
type AttachmentValidationResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
export function validateAttachmentFile(
|
||||||
|
file: File,
|
||||||
|
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
|
): AttachmentValidationResult {
|
||||||
|
if (
|
||||||
|
!ALLOWED_MIME_TYPES.includes(
|
||||||
|
file.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||||
|
if (file.size > maxFileSizeBytes) {
|
||||||
|
return { ok: false, error: `O arquivo deve ter no máximo ${maxSizeMb}MB.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClipboardLikeEvent = ClipboardEvent | React.ClipboardEvent;
|
||||||
|
|
||||||
|
export function getFilesFromClipboard(event: ClipboardLikeEvent): File[] {
|
||||||
|
const files = Array.from(event.clipboardData?.files ?? []);
|
||||||
|
if (files.length > 0) return files;
|
||||||
|
|
||||||
|
return Array.from(event.clipboardData?.items ?? [])
|
||||||
|
.filter((item) => item.kind === "file")
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter((file): file is File => Boolean(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTextEditingTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
|
||||||
|
const tagName = target.tagName.toLowerCase();
|
||||||
|
return (
|
||||||
|
tagName === "input" ||
|
||||||
|
tagName === "textarea" ||
|
||||||
|
target.isContentEditable ||
|
||||||
|
target.closest('[contenteditable="true"]') !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAttachment2 } from "@remixicon/react";
|
import { RiAttachment2 } from "@remixicon/react";
|
||||||
import { useRef, useTransition } from "react";
|
import { useEffect, useRef, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
confirmAttachmentUploadAction,
|
confirmAttachmentUploadAction,
|
||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
ALLOWED_MIME_TYPES,
|
ALLOWED_MIME_TYPES,
|
||||||
DEFAULT_MAX_FILE_SIZE_MB,
|
DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
} from "@/features/transactions/lib/attachments-config";
|
} from "@/features/transactions/lib/attachments-config";
|
||||||
|
import {
|
||||||
|
getFilesFromClipboard,
|
||||||
|
isTextEditingTarget,
|
||||||
|
validateAttachmentFile,
|
||||||
|
} from "./attachment-file-utils";
|
||||||
|
|
||||||
interface AttachmentUploadProps {
|
interface AttachmentUploadProps {
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
@@ -25,7 +30,6 @@ export function AttachmentUpload({
|
|||||||
onPendingUpload,
|
onPendingUpload,
|
||||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
}: AttachmentUploadProps) {
|
}: AttachmentUploadProps) {
|
||||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -36,19 +40,13 @@ export function AttachmentUpload({
|
|||||||
|
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (
|
handleFile(file);
|
||||||
!ALLOWED_MIME_TYPES.includes(
|
}
|
||||||
file.type as (typeof ALLOWED_MIME_TYPES)[number],
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
toast.error(
|
|
||||||
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > maxFileSizeBytes) {
|
function handleFile(file: File) {
|
||||||
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
|
const validation = validateAttachmentFile(file, maxSizeMb);
|
||||||
|
if (!validation.ok) {
|
||||||
|
toast.error(validation.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +92,29 @@ export function AttachmentUpload({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
|
||||||
|
const [file] = getFilesFromClipboard(event);
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
handleFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleDocumentPaste(event: ClipboardEvent) {
|
||||||
|
if (isPending || isTextEditingTarget(event.target)) return;
|
||||||
|
|
||||||
|
const [file] = getFilesFromClipboard(event);
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
handleFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("paste", handleDocumentPaste);
|
||||||
|
return () => document.removeEventListener("paste", handleDocumentPaste);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -107,6 +128,7 @@ export function AttachmentUpload({
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onPaste={handlePaste}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -115,7 +137,8 @@ export function AttachmentUpload({
|
|||||||
</span>
|
</span>
|
||||||
{!isPending && (
|
{!isPending && (
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
|
||||||
|
MB
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiLoader4Line } from "@remixicon/react";
|
import { RiLoader4Line } from "@remixicon/react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CategoryIcon } from "@/features/categories/components/category-icon";
|
import { CategoryIcon } from "@/features/categories/components/category-icon";
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
createInstallmentAnticipationAction,
|
createInstallmentAnticipationAction,
|
||||||
getEligibleInstallmentsAction,
|
getEligibleInstallmentsAction,
|
||||||
} from "@/features/transactions/actions/anticipation";
|
} from "@/features/transactions/actions/anticipation";
|
||||||
|
import { installmentAnticipationsQueryKey } from "@/features/transactions/hooks/use-installment-anticipations";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { PeriodPicker } from "@/shared/components/period-picker";
|
import { PeriodPicker } from "@/shared/components/period-picker";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
@@ -70,6 +72,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: AnticipateInstallmentsDialogProps) {
|
}: AnticipateInstallmentsDialogProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
|
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
|
||||||
@@ -86,7 +89,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Use form state hook for form management
|
// Use form state hook for form management
|
||||||
const { formState, replaceForm, updateField } =
|
const { formState, replaceForm, updateField, updateFields } =
|
||||||
useFormState<AnticipationFormValues>({
|
useFormState<AnticipationFormValues>({
|
||||||
anticipationPeriod: defaultPeriod,
|
anticipationPeriod: defaultPeriod,
|
||||||
discount: "0",
|
discount: "0",
|
||||||
@@ -95,15 +98,34 @@ export function AnticipateInstallmentsDialog({
|
|||||||
note: "",
|
note: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Buscar parcelas elegíveis ao abrir o dialog
|
// Resetar formulário ao abrir o dialog
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
|
setSelectedIds([]);
|
||||||
|
setErrorMessage(null);
|
||||||
|
replaceForm({
|
||||||
|
anticipationPeriod: defaultPeriod,
|
||||||
|
discount: "0",
|
||||||
|
payerId: "",
|
||||||
|
categoryId: "",
|
||||||
|
note: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [defaultPeriod, dialogOpen, replaceForm]);
|
||||||
|
|
||||||
|
// Buscar parcelas elegíveis ao abrir o dialog e ao trocar o período
|
||||||
|
useEffect(() => {
|
||||||
|
if (dialogOpen) {
|
||||||
|
let shouldUpdate = true;
|
||||||
|
|
||||||
setIsLoadingInstallments(true);
|
setIsLoadingInstallments(true);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
getEligibleInstallmentsAction(seriesId)
|
getEligibleInstallmentsAction(seriesId, formState.anticipationPeriod)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
if (!shouldUpdate) return;
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
toast.error(result.error || "Erro ao carregar parcelas");
|
toast.error(result.error || "Erro ao carregar parcelas");
|
||||||
setEligibleInstallments([]);
|
setEligibleInstallments([]);
|
||||||
@@ -116,25 +138,30 @@ export function AnticipateInstallmentsDialog({
|
|||||||
// Pré-preencher pagador e categoria da primeira parcela
|
// Pré-preencher pagador e categoria da primeira parcela
|
||||||
if (installments.length > 0) {
|
if (installments.length > 0) {
|
||||||
const first = installments[0];
|
const first = installments[0];
|
||||||
replaceForm({
|
updateFields({
|
||||||
anticipationPeriod: defaultPeriod,
|
|
||||||
discount: "0",
|
|
||||||
payerId: first.payerId ?? "",
|
payerId: first.payerId ?? "",
|
||||||
categoryId: first.categoryId ?? "",
|
categoryId: first.categoryId ?? "",
|
||||||
note: "",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (!shouldUpdate) return;
|
||||||
|
|
||||||
console.error("Erro ao buscar parcelas:", error);
|
console.error("Erro ao buscar parcelas:", error);
|
||||||
toast.error("Erro ao carregar parcelas elegíveis");
|
toast.error("Erro ao carregar parcelas elegíveis");
|
||||||
setEligibleInstallments([]);
|
setEligibleInstallments([]);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (!shouldUpdate) return;
|
||||||
|
|
||||||
setIsLoadingInstallments(false);
|
setIsLoadingInstallments(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
shouldUpdate = false;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [defaultPeriod, dialogOpen, replaceForm, seriesId]);
|
}, [dialogOpen, formState.anticipationPeriod, seriesId, updateFields]);
|
||||||
|
|
||||||
const totalAmount = useMemo(() => {
|
const totalAmount = useMemo(() => {
|
||||||
return eligibleInstallments
|
return eligibleInstallments
|
||||||
@@ -189,6 +216,9 @@ export function AnticipateInstallmentsDialog({
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: installmentAnticipationsQueryKey(seriesId),
|
||||||
|
});
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = result.error || "Erro ao criar antecipação";
|
const errorMsg = result.error || "Erro ao criar antecipação";
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
|
||||||
import {
|
import {
|
||||||
installmentAnticipationsQueryKey,
|
installmentAnticipationsQueryKey,
|
||||||
useInstallmentAnticipations,
|
useInstallmentAnticipations,
|
||||||
} from "@/features/transactions/hooks/use-installment-anticipations";
|
} from "@/features/transactions/hooks/use-installment-anticipations";
|
||||||
|
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
@@ -31,7 +36,6 @@ interface AnticipationHistoryDialogProps {
|
|||||||
lancamentoName: string;
|
lancamentoName: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
onViewLancamento?: (transactionId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnticipationHistoryDialog({
|
export function AnticipationHistoryDialog({
|
||||||
@@ -40,7 +44,6 @@ export function AnticipationHistoryDialog({
|
|||||||
lancamentoName,
|
lancamentoName,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onViewLancamento,
|
|
||||||
}: AnticipationHistoryDialogProps) {
|
}: AnticipationHistoryDialogProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||||
@@ -51,87 +54,152 @@ export function AnticipationHistoryDialog({
|
|||||||
const {
|
const {
|
||||||
data: anticipations = [],
|
data: anticipations = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isFetching,
|
||||||
isError,
|
isError,
|
||||||
refetch,
|
refetch,
|
||||||
} = useInstallmentAnticipations(seriesId, dialogOpen);
|
} = useInstallmentAnticipations(seriesId, dialogOpen);
|
||||||
|
|
||||||
const handleCanceled = () => {
|
useEffect(() => {
|
||||||
|
if (dialogOpen) {
|
||||||
|
void refetch();
|
||||||
|
}
|
||||||
|
}, [dialogOpen, refetch]);
|
||||||
|
|
||||||
|
const cancelableAnticipation = anticipations.find(
|
||||||
|
(anticipation) => anticipation.transaction?.isSettled !== true,
|
||||||
|
);
|
||||||
|
const anticipationCountLabel =
|
||||||
|
anticipations.length === 1
|
||||||
|
? "1 registro de antecipação encontrada"
|
||||||
|
: `${anticipations.length} registros de antecipações encontradas`;
|
||||||
|
|
||||||
|
const refreshHistory = () => {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: installmentAnticipationsQueryKey(seriesId),
|
queryKey: installmentAnticipationsQueryKey(seriesId),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancelAnticipation = async () => {
|
||||||
|
if (!cancelableAnticipation) return;
|
||||||
|
|
||||||
|
const result = await cancelInstallmentAnticipationAction({
|
||||||
|
anticipationId: cancelableAnticipation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message);
|
||||||
|
refreshHistory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(result.error || "Erro ao cancelar antecipação");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||||
<DialogContent className="max-w-3xl px-6 py-5 sm:px-8 sm:py-6">
|
<DialogContent className="min-w-0 overflow-x-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader className="text-left">
|
||||||
<DialogTitle>Histórico de Antecipações</DialogTitle>
|
<DialogTitle>Histórico de Antecipações</DialogTitle>
|
||||||
<DialogDescription>{lancamentoName}</DialogDescription>
|
<DialogDescription>{lancamentoName}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-2">
|
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
||||||
{isLoading ? (
|
{isLoading || isFetching ? (
|
||||||
<div className="flex items-center justify-center rounded-lg border border-dashed p-12">
|
<LoadingState />
|
||||||
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
|
|
||||||
<span className="ml-2 text-sm text-muted-foreground">
|
|
||||||
Carregando histórico...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<Empty>
|
<ErrorState onRetry={() => void refetch()} />
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyMedia variant="icon">
|
|
||||||
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyTitle>Não foi possível carregar</EmptyTitle>
|
|
||||||
<EmptyDescription>
|
|
||||||
O histórico de antecipações não pôde ser carregado agora.
|
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="mx-auto"
|
|
||||||
onClick={() => void refetch()}
|
|
||||||
>
|
|
||||||
Tentar novamente
|
|
||||||
</Button>
|
|
||||||
</Empty>
|
|
||||||
) : anticipations.length === 0 ? (
|
) : anticipations.length === 0 ? (
|
||||||
<Empty>
|
<EmptyState />
|
||||||
<EmptyHeader>
|
|
||||||
<EmptyMedia variant="icon">
|
|
||||||
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
|
|
||||||
</EmptyMedia>
|
|
||||||
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
|
|
||||||
<EmptyDescription>
|
|
||||||
As antecipações realizadas para esta compra parcelada
|
|
||||||
aparecerão aqui.
|
|
||||||
</EmptyDescription>
|
|
||||||
</EmptyHeader>
|
|
||||||
</Empty>
|
|
||||||
) : (
|
) : (
|
||||||
anticipations.map((anticipation) => (
|
<div className="min-w-0 space-y-3">
|
||||||
<AnticipationCard
|
<p className="text-left text-muted-foreground text-primary">
|
||||||
key={anticipation.id}
|
{anticipationCountLabel}
|
||||||
anticipation={anticipation}
|
</p>
|
||||||
onViewLancamento={onViewLancamento}
|
{anticipations.map((anticipation) => (
|
||||||
onCanceled={handleCanceled}
|
<AnticipationCard
|
||||||
/>
|
key={anticipation.id}
|
||||||
))
|
anticipation={anticipation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoading && anticipations.length > 0 && (
|
<DialogFooter>
|
||||||
<div className="border-t pt-4 text-center text-sm text-muted-foreground">
|
<DialogClose asChild>
|
||||||
{anticipations.length}{" "}
|
<Button type="button" variant="outline">
|
||||||
{anticipations.length === 1
|
Fechar
|
||||||
? "antecipação encontrada"
|
</Button>
|
||||||
: "antecipações encontradas"}
|
</DialogClose>
|
||||||
</div>
|
{cancelableAnticipation ? (
|
||||||
)}
|
<ConfirmActionDialog
|
||||||
|
trigger={
|
||||||
|
<Button type="button" variant="destructive">
|
||||||
|
Desfazer Antecipação
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
title="Cancelar antecipação?"
|
||||||
|
description="Esta ação irá reverter a antecipação e restaurar as parcelas originais. O lançamento de antecipação será removido."
|
||||||
|
confirmLabel="Cancelar Antecipação"
|
||||||
|
confirmVariant="destructive"
|
||||||
|
pendingLabel="Cancelando..."
|
||||||
|
onConfirm={handleCancelAnticipation}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed">
|
||||||
|
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
|
Carregando histórico...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ onRetry }: { onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>Não foi possível carregar</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
O histórico de antecipações não pôde ser carregado agora.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="mx-auto"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
Tentar novamente
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
As antecipações realizadas para esta compra parcelada aparecerão aqui.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export function InstallmentSelectionTable({
|
|||||||
Nenhuma parcela elegível para antecipação encontrada.
|
Nenhuma parcela elegível para antecipação encontrada.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Todas as parcelas desta compra já foram pagas ou antecipadas.
|
Apenas parcelas futuras, ainda não pagas ou antecipadas, aparecem
|
||||||
|
aqui.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
type TRANSACTION_TYPES,
|
type TRANSACTION_TYPES,
|
||||||
} from "@/features/transactions/lib/constants";
|
} from "@/features/transactions/lib/constants";
|
||||||
|
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||||
import { DatePicker } from "@/shared/components/ui/date-picker";
|
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||||
@@ -123,10 +124,11 @@ interface TransactionRow {
|
|||||||
|
|
||||||
function createEmptyTransactionRow(
|
function createEmptyTransactionRow(
|
||||||
defaultPayerId?: string | null,
|
defaultPayerId?: string | null,
|
||||||
|
lastPurchaseDate?: string,
|
||||||
): TransactionRow {
|
): TransactionRow {
|
||||||
return {
|
return {
|
||||||
id: createClientSafeId(),
|
id: createClientSafeId(),
|
||||||
purchaseDate: getTodayDateString(),
|
purchaseDate: lastPurchaseDate ?? getTodayDateString(),
|
||||||
name: "",
|
name: "",
|
||||||
amount: "",
|
amount: "",
|
||||||
categoryId: undefined,
|
categoryId: undefined,
|
||||||
@@ -148,6 +150,9 @@ export function MassAddDialog({
|
|||||||
defaultCardId,
|
defaultCardId,
|
||||||
}: MassAddDialogProps) {
|
}: MassAddDialogProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
|
||||||
|
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
|
||||||
|
|
||||||
// Fixed fields state (sempre ativos, sem checkboxes)
|
// Fixed fields state (sempre ativos, sem checkboxes)
|
||||||
const [transactionType, setTransactionType] =
|
const [transactionType, setTransactionType] =
|
||||||
@@ -179,11 +184,23 @@ export function MassAddDialog({
|
|||||||
return groupAndSortCategories(filtered);
|
return groupAndSortCategories(filtered);
|
||||||
}, [categoryOptions, transactionType]);
|
}, [categoryOptions, transactionType]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setTransactionType("Despesa");
|
||||||
|
setPaymentMethod(PAYMENT_METHODS[0]);
|
||||||
|
setPeriod(selectedPeriod);
|
||||||
|
setContaId(undefined);
|
||||||
|
setCartaoId(defaultCardId ?? undefined);
|
||||||
|
setTransactions([createEmptyTransactionRow(defaultPayerId)]);
|
||||||
|
setIsDirty(false);
|
||||||
|
};
|
||||||
|
|
||||||
const addTransaction = () => {
|
const addTransaction = () => {
|
||||||
|
const lastTransaction = transactions[transactions.length - 1];
|
||||||
setTransactions([
|
setTransactions([
|
||||||
...transactions,
|
...transactions,
|
||||||
createEmptyTransactionRow(defaultPayerId),
|
createEmptyTransactionRow(defaultPayerId, lastTransaction?.purchaseDate),
|
||||||
]);
|
]);
|
||||||
|
setIsDirty(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTransaction = (id: string) => {
|
const removeTransaction = (id: string) => {
|
||||||
@@ -192,6 +209,7 @@ export function MassAddDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTransactions(transactions.filter((t) => t.id !== id));
|
setTransactions(transactions.filter((t) => t.id !== id));
|
||||||
|
setIsDirty(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTransaction = (
|
const updateTransaction = (
|
||||||
@@ -202,6 +220,7 @@ export function MassAddDialog({
|
|||||||
setTransactions(
|
setTransactions(
|
||||||
transactions.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
|
transactions.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
|
||||||
);
|
);
|
||||||
|
setIsDirty(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -250,13 +269,7 @@ export function MassAddDialog({
|
|||||||
try {
|
try {
|
||||||
await onSubmit(formData);
|
await onSubmit(formData);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
// Reset form
|
resetForm();
|
||||||
setTransactionType("Despesa");
|
|
||||||
setPaymentMethod(PAYMENT_METHODS[0]);
|
|
||||||
setPeriod(selectedPeriod);
|
|
||||||
setContaId(undefined);
|
|
||||||
setCartaoId(defaultCardId ?? undefined);
|
|
||||||
setTransactions([createEmptyTransactionRow(defaultPayerId)]);
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Error is handled by the onSubmit function
|
// Error is handled by the onSubmit function
|
||||||
} finally {
|
} finally {
|
||||||
@@ -265,7 +278,19 @@ export function MassAddDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(newOpen) => {
|
||||||
|
if (!newOpen && isDirty) {
|
||||||
|
setConfirmCloseOpen(true);
|
||||||
|
} else {
|
||||||
|
onOpenChange(newOpen);
|
||||||
|
if (newOpen === false) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
|
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
|
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
|
||||||
@@ -286,9 +311,10 @@ export function MassAddDialog({
|
|||||||
<Label htmlFor="transaction-type">Tipo de Transação</Label>
|
<Label htmlFor="transaction-type">Tipo de Transação</Label>
|
||||||
<Select
|
<Select
|
||||||
value={transactionType}
|
value={transactionType}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => {
|
||||||
setTransactionType(value as MassAddTransactionType)
|
setTransactionType(value as MassAddTransactionType);
|
||||||
}
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="transaction-type" className="w-full">
|
<SelectTrigger id="transaction-type" className="w-full">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
@@ -315,6 +341,7 @@ export function MassAddDialog({
|
|||||||
value={paymentMethod}
|
value={paymentMethod}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setPaymentMethod(value as MassAddPaymentMethod);
|
setPaymentMethod(value as MassAddPaymentMethod);
|
||||||
|
setIsDirty(true);
|
||||||
// Reset conta/cartao when changing payment method
|
// Reset conta/cartao when changing payment method
|
||||||
if (value === "Cartão de crédito") {
|
if (value === "Cartão de crédito") {
|
||||||
setContaId(undefined);
|
setContaId(undefined);
|
||||||
@@ -346,7 +373,10 @@ export function MassAddDialog({
|
|||||||
<Label htmlFor="cartao">Cartão</Label>
|
<Label htmlFor="cartao">Cartão</Label>
|
||||||
<Select
|
<Select
|
||||||
value={cardId}
|
value={cardId}
|
||||||
onValueChange={setCartaoId}
|
onValueChange={(value) => {
|
||||||
|
setCartaoId(value);
|
||||||
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
disabled={isLockedToCartao}
|
disabled={isLockedToCartao}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="cartao" className="w-full">
|
<SelectTrigger id="cartao" className="w-full">
|
||||||
@@ -395,7 +425,10 @@ export function MassAddDialog({
|
|||||||
{cardId ? (
|
{cardId ? (
|
||||||
<InlinePeriodPicker
|
<InlinePeriodPicker
|
||||||
period={period}
|
period={period}
|
||||||
onPeriodChange={setPeriod}
|
onPeriodChange={(value) => {
|
||||||
|
setPeriod(value);
|
||||||
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -405,7 +438,13 @@ export function MassAddDialog({
|
|||||||
{!isCartaoSelected ? (
|
{!isCartaoSelected ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="conta">Conta</Label>
|
<Label htmlFor="conta">Conta</Label>
|
||||||
<Select value={accountId} onValueChange={setContaId}>
|
<Select
|
||||||
|
value={accountId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setContaId(value);
|
||||||
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger id="conta" className="w-full">
|
<SelectTrigger id="conta" className="w-full">
|
||||||
<SelectValue placeholder="Selecione">
|
<SelectValue placeholder="Selecione">
|
||||||
{accountId &&
|
{accountId &&
|
||||||
@@ -635,7 +674,13 @@ export function MassAddDialog({
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => {
|
||||||
|
if (isDirty) {
|
||||||
|
setCancelConfirmOpen(true);
|
||||||
|
} else {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
@@ -646,6 +691,36 @@ export function MassAddDialog({
|
|||||||
{transactions.length === 1 ? "lançamento" : "lançamentos"}
|
{transactions.length === 1 ? "lançamento" : "lançamentos"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={confirmCloseOpen}
|
||||||
|
onOpenChange={setConfirmCloseOpen}
|
||||||
|
title="Descartar alterações?"
|
||||||
|
description="Há lançamentos não salvos. Se fechar agora, todos os dados serão perdidos."
|
||||||
|
confirmLabel="Descartar"
|
||||||
|
cancelLabel="Continuar editando"
|
||||||
|
confirmVariant="destructive"
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmCloseOpen(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={cancelConfirmOpen}
|
||||||
|
onOpenChange={setCancelConfirmOpen}
|
||||||
|
title="Cancelar adição de lançamentos?"
|
||||||
|
description="Há lançamentos não salvos. Se cancelar, todos os dados serão perdidos."
|
||||||
|
confirmLabel="Cancelar"
|
||||||
|
cancelLabel="Continuar editando"
|
||||||
|
confirmVariant="destructive"
|
||||||
|
onConfirm={() => {
|
||||||
|
setCancelConfirmOpen(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export function SplitPairDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Editar lançamento dividido</DialogTitle>
|
<DialogTitle>Editar lançamento dividido</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Este lançamento está dividido com outra pessoa. Escolha o que deseja
|
Este lançamento está dividido com outras pessoas. Escolha o que
|
||||||
editar:
|
deseja editar:
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ export function SplitPairDialog({
|
|||||||
Apenas este lançamento
|
Apenas este lançamento
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Aplica a alteração somente neste lado da divisão
|
Aplica a alteração somente nesta parte da divisão
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,11 +75,11 @@ export function SplitPairDialog({
|
|||||||
htmlFor="split-both"
|
htmlFor="split-both"
|
||||||
className="text-sm cursor-pointer font-medium"
|
className="text-sm cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
Atualizar os dois lançamentos
|
Atualizar toda a divisão
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Aplica nome, data, categoria e outros campos compartilhados
|
Aplica nome, data, categoria e outros campos compartilhados em
|
||||||
nos dois lados da divisão
|
todo o grupo da divisão
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
currencyFormatter,
|
currencyFormatter,
|
||||||
formatCondition,
|
formatCondition,
|
||||||
formatDate,
|
|
||||||
formatPeriod,
|
formatPeriod,
|
||||||
} from "@/features/transactions/lib/formatting-helpers";
|
} from "@/features/transactions/lib/formatting-helpers";
|
||||||
|
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -34,7 +34,7 @@ import { Separator } from "@/shared/components/ui/separator";
|
|||||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||||
import { getCategoryColorFromName } from "@/shared/utils/category-colors";
|
import { getCategoryColorFromName } from "@/shared/utils/category-colors";
|
||||||
import { parseLocalDateString } from "@/shared/utils/date";
|
import { formatDate, parseLocalDateString } from "@/shared/utils/date";
|
||||||
import { getIconComponent, getPaymentMethodIcon } from "@/shared/utils/icons";
|
import { getIconComponent, getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||||
import { AttachmentSection } from "../attachments/attachment-section";
|
import { AttachmentSection } from "../attachments/attachment-section";
|
||||||
import { InstallmentTimeline } from "../shared/installment-timeline";
|
import { InstallmentTimeline } from "../shared/installment-timeline";
|
||||||
@@ -55,10 +55,9 @@ export function TransactionDetailsDialog({
|
|||||||
}: TransactionDetailsDialogProps) {
|
}: TransactionDetailsDialogProps) {
|
||||||
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
|
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: transaction?.id é trigger intencional para reset do contador
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAttachmentCount(null);
|
setAttachmentCount(null);
|
||||||
}, [transaction?.id]);
|
}, []);
|
||||||
|
|
||||||
if (!transaction) return null;
|
if (!transaction) return null;
|
||||||
|
|
||||||
@@ -87,11 +86,16 @@ export function TransactionDetailsDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
|
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader className="text-left">
|
||||||
<DialogTitle>{transaction.name}</DialogTitle>
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
<DialogDescription>
|
<EstablishmentLogo size={40} name={transaction.name} />
|
||||||
{formatDate(transaction.purchaseDate)}
|
<div className="min-w-0">
|
||||||
</DialogDescription>
|
<DialogTitle className="truncate">{transaction.name}</DialogTitle>
|
||||||
|
<DialogDescription className="mt-1">
|
||||||
|
{formatDate(transaction.purchaseDate)}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { TRANSACTION_CONDITIONS } from "@/features/transactions/lib/constants";
|
import { TRANSACTION_CONDITIONS } from "@/features/transactions/lib/constants";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/shared/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -14,6 +20,61 @@ import { cn } from "@/shared/utils/ui";
|
|||||||
import { ConditionSelectContent } from "../../select-items";
|
import { ConditionSelectContent } from "../../select-items";
|
||||||
import type { ConditionSectionProps } from "./transaction-dialog-types";
|
import type { ConditionSectionProps } from "./transaction-dialog-types";
|
||||||
|
|
||||||
|
function InlineStartInstallmentPicker({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
options: number[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = Number(value || "1");
|
||||||
|
const selectedLabel =
|
||||||
|
!Number.isNaN(selected) && selected > 0
|
||||||
|
? `${selected}ª parcela`
|
||||||
|
: "1ª parcela";
|
||||||
|
const disabled = options.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ml-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Começar em </span>
|
||||||
|
<Popover modal open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer text-xs text-primary underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:hover:no-underline"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{selectedLabel}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-40 p-1" align="start">
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
option === selected && "font-medium text-primary",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(String(option));
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option}ª parcela
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ConditionSection({
|
export function ConditionSection({
|
||||||
formState,
|
formState,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
@@ -37,11 +98,17 @@ export function ConditionSection({
|
|||||||
const installmentSummary =
|
const installmentSummary =
|
||||||
showInstallments &&
|
showInstallments &&
|
||||||
formState.installmentCount &&
|
formState.installmentCount &&
|
||||||
amount &&
|
|
||||||
!Number.isNaN(installmentCount) &&
|
!Number.isNaN(installmentCount) &&
|
||||||
installmentCount > 0
|
installmentCount > 0
|
||||||
? getInstallmentLabel(installmentCount)
|
? getInstallmentLabel(installmentCount)
|
||||||
: null;
|
: null;
|
||||||
|
const startInstallmentOptions =
|
||||||
|
showInstallments &&
|
||||||
|
formState.installmentCount &&
|
||||||
|
!Number.isNaN(installmentCount) &&
|
||||||
|
installmentCount > 0
|
||||||
|
? Array.from({ length: installmentCount }, (_, index) => index + 1)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||||
@@ -96,6 +163,11 @@ export function ConditionSection({
|
|||||||
})}
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<InlineStartInstallmentPicker
|
||||||
|
value={formState.startInstallment}
|
||||||
|
options={startInstallmentOptions}
|
||||||
|
onChange={(value) => onFieldChange("startInstallment", value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import { RiSliceFill } from "@remixicon/react";
|
import { RiSliceFill } from "@remixicon/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
import {
|
||||||
import { Input } from "@/shared/components/ui/input";
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/shared/components/ui/avatar";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -13,120 +17,48 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select";
|
} from "@/shared/components/ui/select";
|
||||||
import {
|
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||||
ToggleGroup,
|
|
||||||
ToggleGroupItem,
|
|
||||||
} from "@/shared/components/ui/toggle-group";
|
|
||||||
import {
|
|
||||||
formatCurrency,
|
|
||||||
formatDecimalForDbRequired,
|
|
||||||
normalizeDecimalInput,
|
|
||||||
} from "@/shared/utils/currency";
|
|
||||||
import { safeToNumber } from "@/shared/utils/number";
|
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import { PayerSelectContent } from "../../select-items";
|
import { PayerSelectContent } from "../../select-items";
|
||||||
|
import { getSplitSummaryData, SplitConfigDialog } from "./split-config-dialog";
|
||||||
import type { PayerSectionProps } from "./transaction-dialog-types";
|
import type { PayerSectionProps } from "./transaction-dialog-types";
|
||||||
|
|
||||||
type SplitInputMode = "currency" | "percentage";
|
type SplitSummary = ReturnType<typeof getSplitSummaryData>;
|
||||||
|
|
||||||
const SPLIT_MODE_OPTIONS = [
|
function SplitSummaryContent({ summary }: { summary: SplitSummary }) {
|
||||||
{ value: "currency", label: "R$" },
|
if (summary.type === "text") {
|
||||||
{ value: "percentage", label: "%" },
|
return <p className="text-xs text-muted-foreground">{summary.label}</p>;
|
||||||
] as const satisfies ReadonlyArray<{ value: SplitInputMode; label: string }>;
|
|
||||||
|
|
||||||
const amountToPercent = (amount: string, total: number): string => {
|
|
||||||
if (total <= 0) return "";
|
|
||||||
const numeric = safeToNumber(normalizeDecimalInput(amount), Number.NaN);
|
|
||||||
if (!Number.isFinite(numeric)) return "";
|
|
||||||
const pct = (numeric / total) * 100;
|
|
||||||
return (Math.round(pct * 10) / 10).toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const percentToAmount = (percent: string, total: number): string => {
|
|
||||||
const pct = safeToNumber(normalizeDecimalInput(percent), Number.NaN);
|
|
||||||
if (!Number.isFinite(pct) || total <= 0) return "0.00";
|
|
||||||
const clamped = Math.min(100, Math.max(0, pct));
|
|
||||||
return formatDecimalForDbRequired((total * clamped) / 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
function SplitModeToggle({
|
|
||||||
mode,
|
|
||||||
onModeChange,
|
|
||||||
}: {
|
|
||||||
mode: SplitInputMode;
|
|
||||||
onModeChange: (mode: SplitInputMode) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ToggleGroup
|
|
||||||
type="single"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
value={mode}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value) onModeChange(value as SplitInputMode);
|
|
||||||
}}
|
|
||||||
aria-label="Modo de entrada do split"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
>
|
|
||||||
{SPLIT_MODE_OPTIONS.map((option) => (
|
|
||||||
<ToggleGroupItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className="px-2 py-0 h-7 text-xs"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
))}
|
|
||||||
</ToggleGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SplitAmountField({
|
|
||||||
mode,
|
|
||||||
value,
|
|
||||||
totalAmount,
|
|
||||||
onAmountChange,
|
|
||||||
ariaLabel,
|
|
||||||
}: {
|
|
||||||
mode: SplitInputMode;
|
|
||||||
value: string;
|
|
||||||
totalAmount: number;
|
|
||||||
onAmountChange: (amount: string) => void;
|
|
||||||
ariaLabel: string;
|
|
||||||
}) {
|
|
||||||
if (mode === "currency") {
|
|
||||||
return (
|
|
||||||
<CurrencyInput
|
|
||||||
value={value}
|
|
||||||
onValueChange={onAmountChange}
|
|
||||||
placeholder="R$ 0,00"
|
|
||||||
className="h-9 w-[45%] text-sm"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[45%] space-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||||
<div className="relative">
|
<span>{summary.count} pessoas:</span>
|
||||||
<Input
|
{summary.participants.map((participant, index) => {
|
||||||
type="text"
|
const initial = participant.label.charAt(0).toUpperCase() || "?";
|
||||||
inputMode="decimal"
|
|
||||||
value={amountToPercent(value, totalAmount)}
|
return (
|
||||||
onChange={(event) => {
|
<span
|
||||||
const sanitized = event.target.value.replace(/[^\d.,]/g, "");
|
key={`${participant.label}-${index}`}
|
||||||
onAmountChange(percentToAmount(sanitized, totalAmount));
|
className="inline-flex min-w-0 items-center gap-0.5"
|
||||||
}}
|
>
|
||||||
placeholder="0"
|
<Avatar className="size-4 border border-border/60 bg-background">
|
||||||
aria-label={ariaLabel}
|
<AvatarImage
|
||||||
className="h-9 w-full pr-7 text-sm"
|
src={getAvatarSrc(participant.avatarUrl)}
|
||||||
/>
|
alt={`Avatar de ${participant.label}`}
|
||||||
<span className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-xs text-muted-foreground">
|
/>
|
||||||
%
|
<AvatarFallback className="text-[0.55rem] font-medium uppercase">
|
||||||
</span>
|
{initial}
|
||||||
</div>
|
</AvatarFallback>
|
||||||
<p className="ml-1 text-xs text-muted-foreground">
|
</Avatar>
|
||||||
{formatCurrency(safeToNumber(value))}
|
<span>{participant.firstName}</span>
|
||||||
</p>
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{summary.remainingCount > 0 ? (
|
||||||
|
<span>+{summary.remainingCount}</span>
|
||||||
|
) : null}
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>{summary.totalLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,53 +67,93 @@ export function PayerSection({
|
|||||||
formState,
|
formState,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
payerOptions,
|
payerOptions,
|
||||||
secondaryPayerOptions,
|
splitPayerOptions,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
}: PayerSectionProps) {
|
}: PayerSectionProps) {
|
||||||
const [splitMode, setSplitMode] = useState<SplitInputMode>("currency");
|
const [splitConfigOpen, setSplitConfigOpen] = useState(false);
|
||||||
|
const splitSummary = getSplitSummaryData(
|
||||||
|
formState,
|
||||||
|
payerOptions,
|
||||||
|
totalAmount,
|
||||||
|
);
|
||||||
|
|
||||||
const handlePrimaryAmountChange = (value: string) => {
|
const handleSplitToggle = (checked: boolean) => {
|
||||||
onFieldChange("primarySplitAmount", value);
|
onFieldChange("isSplit", checked);
|
||||||
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
|
||||||
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
|
if (checked) {
|
||||||
|
setSplitConfigOpen(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSecondaryAmountChange = (value: string) => {
|
const handleSplitCardClick = () => {
|
||||||
onFieldChange("secondarySplitAmount", value);
|
if (formState.isSplit) {
|
||||||
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
setSplitConfigOpen(true);
|
||||||
onFieldChange("primarySplitAmount", remaining.toFixed(2));
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSplitToggle(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="payer">Pessoa</Label>
|
||||||
|
<Select
|
||||||
|
value={formState.payerId ?? ""}
|
||||||
|
onValueChange={(value) => onFieldChange("payerId", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="payer" className="w-full">
|
||||||
|
<SelectValue placeholder="Selecione">
|
||||||
|
{formState.payerId &&
|
||||||
|
(() => {
|
||||||
|
const selectedOption = payerOptions.find(
|
||||||
|
(opt) => opt.value === formState.payerId,
|
||||||
|
);
|
||||||
|
return selectedOption ? (
|
||||||
|
<PayerSelectContent
|
||||||
|
label={selectedOption.label}
|
||||||
|
avatarUrl={selectedOption.avatarUrl}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{payerOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<PayerSelectContent
|
||||||
|
label={option.label}
|
||||||
|
avatarUrl={option.avatarUrl}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
|
"rounded-lg border px-3 py-2.5 transition-colors",
|
||||||
formState.isSplit
|
formState.isSplit
|
||||||
? "border-primary/20 bg-primary/5"
|
? "border-primary/20 bg-primary/5"
|
||||||
: "border-border bg-transparent",
|
: "border-border bg-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 space-y-0.5 text-left"
|
||||||
|
onClick={handleSplitCardClick}
|
||||||
|
>
|
||||||
<p className="text-sm text-foreground">Dividir lançamento</p>
|
<p className="text-sm text-foreground">Dividir lançamento</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<SplitSummaryContent summary={splitSummary} />
|
||||||
Atribuir parte do valor a outra pessoa.
|
</button>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{formState.isSplit ? (
|
|
||||||
<SplitModeToggle mode={splitMode} onModeChange={setSplitMode} />
|
|
||||||
) : null}
|
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
checked={formState.isSplit}
|
checked={formState.isSplit}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => handleSplitToggle(Boolean(checked))}
|
||||||
onFieldChange("isSplit", Boolean(checked))
|
|
||||||
}
|
|
||||||
aria-label="Dividir lançamento"
|
aria-label="Dividir lançamento"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"peer mt-0.5 size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
formState.isSplit
|
formState.isSplit
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
: "border-input dark:bg-input/30",
|
: "border-input dark:bg-input/30",
|
||||||
@@ -192,110 +164,29 @@ export function PayerSection({
|
|||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
|
||||||
<div className="w-full space-y-1">
|
|
||||||
<Label htmlFor="payer">Pessoa</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select
|
|
||||||
value={formState.payerId ?? ""}
|
|
||||||
onValueChange={(value) => onFieldChange("payerId", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="payer"
|
|
||||||
className={formState.isSplit ? "min-w-0 flex-1" : "w-full"}
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Selecione">
|
|
||||||
{formState.payerId &&
|
|
||||||
(() => {
|
|
||||||
const selectedOption = payerOptions.find(
|
|
||||||
(opt) => opt.value === formState.payerId,
|
|
||||||
);
|
|
||||||
return selectedOption ? (
|
|
||||||
<PayerSelectContent
|
|
||||||
label={selectedOption.label}
|
|
||||||
avatarUrl={selectedOption.avatarUrl}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{payerOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
<PayerSelectContent
|
|
||||||
label={option.label}
|
|
||||||
avatarUrl={option.avatarUrl}
|
|
||||||
/>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{formState.isSplit ? (
|
|
||||||
<SplitAmountField
|
|
||||||
mode={splitMode}
|
|
||||||
value={formState.primarySplitAmount}
|
|
||||||
totalAmount={totalAmount}
|
|
||||||
onAmountChange={handlePrimaryAmountChange}
|
|
||||||
ariaLabel="Porcentagem da pessoa"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formState.isSplit ? (
|
{formState.isSplit ? (
|
||||||
<div className="w-full space-y-1 mb-1">
|
<Button
|
||||||
<Label htmlFor="secondaryPayer">Dividir com</Label>
|
type="button"
|
||||||
<div className="flex gap-2">
|
variant="outline"
|
||||||
<Select
|
size="sm"
|
||||||
value={formState.secondaryPayerId ?? ""}
|
className="mt-3 w-full"
|
||||||
onValueChange={(value) =>
|
onClick={() => setSplitConfigOpen(true)}
|
||||||
onFieldChange("secondaryPayerId", value)
|
>
|
||||||
}
|
Editar divisão
|
||||||
>
|
</Button>
|
||||||
<SelectTrigger
|
|
||||||
id="secondaryPayer"
|
|
||||||
disabled={secondaryPayerOptions.length === 0}
|
|
||||||
className="w-[55%]"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Selecione">
|
|
||||||
{formState.secondaryPayerId &&
|
|
||||||
(() => {
|
|
||||||
const selectedOption = secondaryPayerOptions.find(
|
|
||||||
(opt) => opt.value === formState.secondaryPayerId,
|
|
||||||
);
|
|
||||||
return selectedOption ? (
|
|
||||||
<PayerSelectContent
|
|
||||||
label={selectedOption.label}
|
|
||||||
avatarUrl={selectedOption.avatarUrl}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{secondaryPayerOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
<PayerSelectContent
|
|
||||||
label={option.label}
|
|
||||||
avatarUrl={option.avatarUrl}
|
|
||||||
/>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<SplitAmountField
|
|
||||||
mode={splitMode}
|
|
||||||
value={formState.secondarySplitAmount}
|
|
||||||
totalAmount={totalAmount}
|
|
||||||
onAmountChange={handleSecondaryAmountChange}
|
|
||||||
ariaLabel="Porcentagem do segundo pagador"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SplitConfigDialog
|
||||||
|
open={splitConfigOpen}
|
||||||
|
onOpenChange={setSplitConfigOpen}
|
||||||
|
formState={formState}
|
||||||
|
onFieldChange={onFieldChange}
|
||||||
|
payerOptions={payerOptions}
|
||||||
|
splitPayerOptions={splitPayerOptions}
|
||||||
|
totalAmount={totalAmount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
|
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
|
import { safeToNumber } from "@/shared/utils/number";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
import { PayerSelectContent } from "../../select-items";
|
||||||
|
import type { FormState } from "./transaction-dialog-types";
|
||||||
|
|
||||||
|
const splitRowClassName =
|
||||||
|
"grid min-h-[2rem] items-center gap-2 rounded-lg border p-1.5 transition-colors sm:grid-cols-[minmax(0,1fr)_7rem_5.5rem]";
|
||||||
|
const splitDisabledFieldClassName =
|
||||||
|
"hidden h-9 rounded-md border border-transparent sm:block";
|
||||||
|
|
||||||
|
type SplitConfigDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
formState: FormState;
|
||||||
|
onFieldChange: <Key extends keyof FormState>(
|
||||||
|
key: Key,
|
||||||
|
value: FormState[Key],
|
||||||
|
) => void;
|
||||||
|
payerOptions: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
role?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
}>;
|
||||||
|
splitPayerOptions: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
}>;
|
||||||
|
totalAmount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPercentValue = (amount: string, totalAmount: number) => {
|
||||||
|
if (totalAmount <= 0) return "0%";
|
||||||
|
const percentage = (safeToNumber(amount) / totalAmount) * 100;
|
||||||
|
return percentage.toLocaleString("pt-BR", {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentToAmount = (percent: string, totalAmount: number) => {
|
||||||
|
const normalized = percent.replace(/[^\d.,]/g, "").replace(",", ".");
|
||||||
|
const percentage = Number(normalized);
|
||||||
|
|
||||||
|
if (!Number.isFinite(percentage) || totalAmount <= 0) return "0.00";
|
||||||
|
|
||||||
|
const clamped = Math.min(100, Math.max(0, percentage));
|
||||||
|
return ((totalAmount * clamped) / 100).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEqualAmounts = (count: number, totalAmount: number) => {
|
||||||
|
if (count <= 0 || totalAmount <= 0) return [];
|
||||||
|
|
||||||
|
const centsTotal = Math.round(totalAmount * 100);
|
||||||
|
const baseCents = Math.floor(centsTotal / count);
|
||||||
|
let remainder = centsTotal - baseCents * count;
|
||||||
|
|
||||||
|
return Array.from({ length: count }, () => {
|
||||||
|
const cents = baseCents + (remainder > 0 ? 1 : 0);
|
||||||
|
remainder -= 1;
|
||||||
|
return (cents / 100).toFixed(2);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type SplitSummaryPayerOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSplitSummaryData(
|
||||||
|
formState: FormState,
|
||||||
|
payerOptions: SplitSummaryPayerOption[],
|
||||||
|
totalAmount: number,
|
||||||
|
) {
|
||||||
|
if (!formState.isSplit) {
|
||||||
|
return {
|
||||||
|
type: "text" as const,
|
||||||
|
label: "Atribuir partes do valor a outras pessoas.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = [
|
||||||
|
formState.payerId,
|
||||||
|
...formState.splitShares.map((share) => share.payerId),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (participants.length <= 1) {
|
||||||
|
return {
|
||||||
|
type: "text" as const,
|
||||||
|
label: "Configure as pessoas e os valores da divisão.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const total =
|
||||||
|
safeToNumber(formState.primarySplitAmount) +
|
||||||
|
formState.splitShares.reduce(
|
||||||
|
(sum, share) => sum + safeToNumber(share.amount),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const displayedParticipants = participants
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((payerId) => payerOptions.find((option) => option.value === payerId))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((option) => ({
|
||||||
|
label: option?.label ?? "",
|
||||||
|
firstName: option?.label.split(/\s+/)[0] ?? "",
|
||||||
|
avatarUrl: option?.avatarUrl ?? null,
|
||||||
|
}));
|
||||||
|
const remainingCount = Math.max(0, participants.length - 3);
|
||||||
|
const totalLabel =
|
||||||
|
Math.abs(total - totalAmount) <= 0.01
|
||||||
|
? formatCurrency(totalAmount)
|
||||||
|
: `${formatCurrency(total)} de ${formatCurrency(totalAmount)}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "split" as const,
|
||||||
|
count: participants.length,
|
||||||
|
participants: displayedParticipants,
|
||||||
|
remainingCount,
|
||||||
|
totalLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSplitSummaryLabel(
|
||||||
|
formState: FormState,
|
||||||
|
payerOptions: SplitSummaryPayerOption[],
|
||||||
|
totalAmount: number,
|
||||||
|
) {
|
||||||
|
const summary = getSplitSummaryData(formState, payerOptions, totalAmount);
|
||||||
|
|
||||||
|
if (summary.type === "text") return summary.label;
|
||||||
|
|
||||||
|
const namesLabel = summary.participants
|
||||||
|
.map((participant) => participant.firstName)
|
||||||
|
.join(" ");
|
||||||
|
const remainingLabel =
|
||||||
|
summary.remainingCount > 0 ? ` +${summary.remainingCount}` : "";
|
||||||
|
|
||||||
|
return `${summary.count} pessoas: ${namesLabel}${remainingLabel} · ${summary.totalLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplitConfigDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
formState,
|
||||||
|
onFieldChange,
|
||||||
|
payerOptions,
|
||||||
|
splitPayerOptions,
|
||||||
|
totalAmount,
|
||||||
|
}: SplitConfigDialogProps) {
|
||||||
|
const selectedSplitIds = new Set(
|
||||||
|
formState.splitShares.map((share) => share.payerId),
|
||||||
|
);
|
||||||
|
const availableSplitOptions = splitPayerOptions.filter(
|
||||||
|
(option) => option.value !== formState.payerId,
|
||||||
|
);
|
||||||
|
const primaryPayerOption =
|
||||||
|
payerOptions.find((option) => option.value === formState.payerId) ??
|
||||||
|
payerOptions.find((option) => option.role === "admin") ??
|
||||||
|
null;
|
||||||
|
const splitTotal =
|
||||||
|
safeToNumber(formState.primarySplitAmount) +
|
||||||
|
formState.splitShares.reduce(
|
||||||
|
(total, share) => total + safeToNumber(share.amount),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const splitDifference = totalAmount - splitTotal;
|
||||||
|
const hasSplitDifference = Math.abs(splitDifference) > 0.01;
|
||||||
|
const splitDifferenceLabel =
|
||||||
|
splitDifference > 0
|
||||||
|
? `Faltam ${formatCurrency(splitDifference)}`
|
||||||
|
: `Sobram ${formatCurrency(Math.abs(splitDifference))}`;
|
||||||
|
|
||||||
|
const applyEqualSplit = (shares = formState.splitShares) => {
|
||||||
|
const participantCount = (formState.payerId ? 1 : 0) + shares.length;
|
||||||
|
const amounts = getEqualAmounts(participantCount, totalAmount);
|
||||||
|
|
||||||
|
if (amounts.length === 0) return;
|
||||||
|
|
||||||
|
onFieldChange("primarySplitAmount", amounts[0] ?? "0.00");
|
||||||
|
onFieldChange(
|
||||||
|
"splitShares",
|
||||||
|
shares.map((share, index) => ({
|
||||||
|
...share,
|
||||||
|
amount: amounts[index + 1] ?? "0.00",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSplitPayer = (payerId: string, checked: boolean) => {
|
||||||
|
const nextShares = checked
|
||||||
|
? [...formState.splitShares, { payerId, amount: "0.00" }]
|
||||||
|
: formState.splitShares.filter((share) => share.payerId !== payerId);
|
||||||
|
|
||||||
|
applyEqualSplit(nextShares);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSecondaryAmountChange = (payerId: string, value: string) => {
|
||||||
|
const nextShares = formState.splitShares.map((share) =>
|
||||||
|
share.payerId === payerId ? { ...share, amount: value } : share,
|
||||||
|
);
|
||||||
|
const othersTotal = nextShares.reduce(
|
||||||
|
(total, share) => total + safeToNumber(share.amount),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
onFieldChange("splitShares", nextShares);
|
||||||
|
onFieldChange(
|
||||||
|
"primarySplitAmount",
|
||||||
|
Math.max(0, totalAmount - othersTotal).toFixed(2),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSecondaryPercentChange = (payerId: string, percent: string) => {
|
||||||
|
handleSecondaryAmountChange(payerId, percentToAmount(percent, totalAmount));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisableSplit = () => {
|
||||||
|
onFieldChange("isSplit", false);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPercentInput = (
|
||||||
|
amount: string,
|
||||||
|
onPercentChange: (percent: string) => void,
|
||||||
|
ariaLabel: string,
|
||||||
|
) => (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={getPercentValue(amount, totalAmount)}
|
||||||
|
onChange={(event) => onPercentChange(event.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="h-9 pr-7 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-xs text-muted-foreground">
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[90vh] min-w-0 flex-col overflow-hidden sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Dividir lançamento</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Marque as pessoas e ajuste os valores se precisar.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 space-y-2 overflow-y-auto pr-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2.5",
|
||||||
|
hasSplitDifference
|
||||||
|
? "border-destructive/30 bg-destructive/5"
|
||||||
|
: "border-primary/20 bg-primary/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatCurrency(splitTotal)} de {formatCurrency(totalAmount)}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
hasSplitDifference
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasSplitDifference ? splitDifferenceLabel : "Tudo certo"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => applyEqualSplit()}
|
||||||
|
disabled={
|
||||||
|
totalAmount <= 0 ||
|
||||||
|
!formState.payerId ||
|
||||||
|
formState.splitShares.length === 0
|
||||||
|
}
|
||||||
|
className="border-primary/30 bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary"
|
||||||
|
>
|
||||||
|
Dividir igualmente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{primaryPayerOption ? (
|
||||||
|
<div className={cn(splitRowClassName, "bg-background")}>
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked disabled aria-hidden />
|
||||||
|
<PayerSelectContent
|
||||||
|
label={primaryPayerOption.label}
|
||||||
|
avatarUrl={primaryPayerOption.avatarUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CurrencyInput
|
||||||
|
value={formState.primarySplitAmount}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onFieldChange("primarySplitAmount", value)
|
||||||
|
}
|
||||||
|
placeholder="R$ 0,00"
|
||||||
|
aria-label={`Valor de ${primaryPayerOption.label}`}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
{renderPercentInput(
|
||||||
|
formState.primarySplitAmount,
|
||||||
|
(percent) =>
|
||||||
|
onFieldChange(
|
||||||
|
"primarySplitAmount",
|
||||||
|
percentToAmount(percent, totalAmount),
|
||||||
|
),
|
||||||
|
`Percentual de ${primaryPayerOption.label}`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{availableSplitOptions.map((option) => {
|
||||||
|
const isSelected = selectedSplitIds.has(option.value);
|
||||||
|
const share = formState.splitShares.find(
|
||||||
|
(item) => item.payerId === option.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={cn(
|
||||||
|
splitRowClassName,
|
||||||
|
isSelected
|
||||||
|
? "bg-background"
|
||||||
|
: "border-border/60 bg-muted/20 opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<label className="flex min-w-0 cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleSplitPayer(option.value, Boolean(checked))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<PayerSelectContent
|
||||||
|
label={option.label}
|
||||||
|
avatarUrl={option.avatarUrl}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{isSelected && share ? (
|
||||||
|
<>
|
||||||
|
<CurrencyInput
|
||||||
|
value={share.amount}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleSecondaryAmountChange(option.value, value)
|
||||||
|
}
|
||||||
|
placeholder="R$ 0,00"
|
||||||
|
aria-label={`Valor de ${option.label}`}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
{renderPercentInput(
|
||||||
|
share.amount,
|
||||||
|
(percent) =>
|
||||||
|
handleSecondaryPercentChange(option.value, percent),
|
||||||
|
`Percentual de ${option.label}`,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={splitDisabledFieldClassName} />
|
||||||
|
<div className={splitDisabledFieldClassName} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0">
|
||||||
|
<Button type="button" variant="outline" onClick={handleDisableSplit}>
|
||||||
|
Cancelar divisão
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
Concluir
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export interface TransactionDialogProps {
|
|||||||
estabelecimentos: string[];
|
estabelecimentos: string[];
|
||||||
transaction?: TransactionItem;
|
transaction?: TransactionItem;
|
||||||
defaultPeriod?: string;
|
defaultPeriod?: string;
|
||||||
|
defaultAccountId?: string | null;
|
||||||
defaultCardId?: string | null;
|
defaultCardId?: string | null;
|
||||||
defaultPaymentMethod?: string | null;
|
defaultPaymentMethod?: string | null;
|
||||||
defaultPurchaseDate?: string | null;
|
defaultPurchaseDate?: string | null;
|
||||||
@@ -95,7 +96,7 @@ export interface CategorySectionProps extends BaseFieldSectionProps {
|
|||||||
|
|
||||||
export interface PayerSectionProps extends BaseFieldSectionProps {
|
export interface PayerSectionProps extends BaseFieldSectionProps {
|
||||||
payerOptions: SelectOption[];
|
payerOptions: SelectOption[];
|
||||||
secondaryPayerOptions: SelectOption[];
|
splitPayerOptions: SelectOption[];
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { RiArrowDropDownLine } from "@remixicon/react";
|
import { RiArrowDropDownLine } from "@remixicon/react";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createTransactionAction,
|
createTransactionAction,
|
||||||
@@ -11,10 +11,7 @@ import {
|
|||||||
detachTransactionAttachmentAction,
|
detachTransactionAttachmentAction,
|
||||||
getPresignedUploadUrlAction,
|
getPresignedUploadUrlAction,
|
||||||
} from "@/features/transactions/actions/attachments";
|
} from "@/features/transactions/actions/attachments";
|
||||||
import {
|
import { groupAndSortCategories } from "@/features/transactions/lib/category-helpers";
|
||||||
filterSecondaryPayerOptions,
|
|
||||||
groupAndSortCategories,
|
|
||||||
} from "@/features/transactions/lib/category-helpers";
|
|
||||||
import {
|
import {
|
||||||
applyFieldDependencies,
|
applyFieldDependencies,
|
||||||
buildTransactionInitialState,
|
buildTransactionInitialState,
|
||||||
@@ -50,6 +47,7 @@ import type {
|
|||||||
FormState,
|
FormState,
|
||||||
TransactionDialogProps,
|
TransactionDialogProps,
|
||||||
} from "./transaction-dialog-types";
|
} from "./transaction-dialog-types";
|
||||||
|
import { TransactionSummaryCard } from "./transaction-summary-card";
|
||||||
|
|
||||||
export function TransactionDialog({
|
export function TransactionDialog({
|
||||||
mode,
|
mode,
|
||||||
@@ -65,6 +63,7 @@ export function TransactionDialog({
|
|||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
transaction,
|
transaction,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
|
defaultAccountId,
|
||||||
defaultCardId,
|
defaultCardId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
defaultPurchaseDate,
|
defaultPurchaseDate,
|
||||||
@@ -88,6 +87,7 @@ export function TransactionDialog({
|
|||||||
|
|
||||||
const [formState, setFormState] = useState<FormState>(() =>
|
const [formState, setFormState] = useState<FormState>(() =>
|
||||||
buildTransactionInitialState(transaction, defaultPayerId, defaultPeriod, {
|
buildTransactionInitialState(transaction, defaultPayerId, defaultPeriod, {
|
||||||
|
defaultAccountId,
|
||||||
defaultCardId,
|
defaultCardId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
defaultPurchaseDate,
|
defaultPurchaseDate,
|
||||||
@@ -102,6 +102,8 @@ export function TransactionDialog({
|
|||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||||
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
||||||
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
||||||
|
const [extrasOpen, setExtrasOpen] = useState(false);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
@@ -110,6 +112,7 @@ export function TransactionDialog({
|
|||||||
defaultPayerId,
|
defaultPayerId,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
{
|
{
|
||||||
|
defaultAccountId,
|
||||||
defaultCardId,
|
defaultCardId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
defaultPurchaseDate,
|
defaultPurchaseDate,
|
||||||
@@ -142,12 +145,14 @@ export function TransactionDialog({
|
|||||||
setPendingFiles([]);
|
setPendingFiles([]);
|
||||||
setPendingDetachIds([]);
|
setPendingDetachIds([]);
|
||||||
setPendingUploadFiles([]);
|
setPendingUploadFiles([]);
|
||||||
|
setExtrasOpen(initial.condition !== "À vista");
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
dialogOpen,
|
dialogOpen,
|
||||||
transaction,
|
transaction,
|
||||||
defaultPayerId,
|
defaultPayerId,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
|
defaultAccountId,
|
||||||
defaultCardId,
|
defaultCardId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
defaultPurchaseDate,
|
defaultPurchaseDate,
|
||||||
@@ -159,13 +164,6 @@ export function TransactionDialog({
|
|||||||
mode,
|
mode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const primaryPayerId = formState.payerId;
|
|
||||||
|
|
||||||
const secondaryPayerOptions = useMemo(
|
|
||||||
() => filterSecondaryPayerOptions(splitPayerOptions, primaryPayerId),
|
|
||||||
[splitPayerOptions, primaryPayerId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const categoryGroups = useMemo(() => {
|
const categoryGroups = useMemo(() => {
|
||||||
const filtered = categoryOptions.filter(
|
const filtered = categoryOptions.filter(
|
||||||
(option) =>
|
(option) =>
|
||||||
@@ -211,6 +209,22 @@ export function TransactionDialog({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleExtrasOpenChange(nextOpen: boolean) {
|
||||||
|
setExtrasOpen(nextOpen);
|
||||||
|
|
||||||
|
if (nextOpen) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const scrollContainer = scrollContainerRef.current;
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: scrollContainer.scrollHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
@@ -236,14 +250,6 @@ export function TransactionDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formState.isSplit && !formState.secondaryPayerId) {
|
|
||||||
const message =
|
|
||||||
"Selecione a pessoa secundário para dividir o lançamento.";
|
|
||||||
setErrorMessage(message);
|
|
||||||
toast.error(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const amountValue = Number(formState.amount);
|
const amountValue = Number(formState.amount);
|
||||||
if (Number.isNaN(amountValue)) {
|
if (Number.isNaN(amountValue)) {
|
||||||
const message = "Informe um valor válido.";
|
const message = "Informe um valor válido.";
|
||||||
@@ -253,6 +259,44 @@ export function TransactionDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedAmount = Math.abs(amountValue);
|
const sanitizedAmount = Math.abs(amountValue);
|
||||||
|
const normalizedSplitShares = formState.isSplit
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
payerId: formState.payerId ?? "",
|
||||||
|
amount: Number.parseFloat(formState.primarySplitAmount) || 0,
|
||||||
|
},
|
||||||
|
...formState.splitShares.map((share) => ({
|
||||||
|
payerId: share.payerId,
|
||||||
|
amount: Number.parseFloat(share.amount) || 0,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (formState.isSplit) {
|
||||||
|
if (formState.splitShares.length === 0) {
|
||||||
|
const message = "Selecione pelo menos uma pessoa para dividir.";
|
||||||
|
setErrorMessage(message);
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSplitShares?.some((share) => share.amount <= 0)) {
|
||||||
|
const message = "Informe um valor maior que zero para cada pessoa.";
|
||||||
|
setErrorMessage(message);
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitTotal =
|
||||||
|
normalizedSplitShares?.reduce((sum, share) => sum + share.amount, 0) ??
|
||||||
|
0;
|
||||||
|
if (Math.abs(splitTotal - sanitizedAmount) > 0.01) {
|
||||||
|
const message = "A soma das divisões deve ser igual ao valor total.";
|
||||||
|
setErrorMessage(message);
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!formState.categoryId) {
|
if (!formState.categoryId) {
|
||||||
const message = "Selecione uma categoria.";
|
const message = "Selecione uma categoria.";
|
||||||
@@ -286,9 +330,7 @@ export function TransactionDialog({
|
|||||||
paymentMethod:
|
paymentMethod:
|
||||||
formState.paymentMethod as CreateTransactionInput["paymentMethod"],
|
formState.paymentMethod as CreateTransactionInput["paymentMethod"],
|
||||||
payerId: formState.payerId ?? null,
|
payerId: formState.payerId ?? null,
|
||||||
secondaryPayerId: formState.isSplit
|
splitShares: normalizedSplitShares,
|
||||||
? formState.secondaryPayerId
|
|
||||||
: undefined,
|
|
||||||
isSplit: formState.isSplit,
|
isSplit: formState.isSplit,
|
||||||
primarySplitAmount: formState.isSplit
|
primarySplitAmount: formState.isSplit
|
||||||
? Number.parseFloat(formState.primarySplitAmount) || undefined
|
? Number.parseFloat(formState.primarySplitAmount) || undefined
|
||||||
@@ -308,6 +350,12 @@ export function TransactionDialog({
|
|||||||
formState.condition === "Parcelado" && formState.installmentCount
|
formState.condition === "Parcelado" && formState.installmentCount
|
||||||
? Number(formState.installmentCount)
|
? Number(formState.installmentCount)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
startInstallment:
|
||||||
|
mode === "create" &&
|
||||||
|
formState.condition === "Parcelado" &&
|
||||||
|
formState.startInstallment
|
||||||
|
? Number(formState.startInstallment)
|
||||||
|
: undefined,
|
||||||
recurrenceCount:
|
recurrenceCount:
|
||||||
formState.condition === "Recorrente" && formState.recurrenceCount
|
formState.condition === "Recorrente" && formState.recurrenceCount
|
||||||
? Number(formState.recurrenceCount)
|
? Number(formState.recurrenceCount)
|
||||||
@@ -527,18 +575,21 @@ export function TransactionDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||||
<DialogContent className="min-w-0 overflow-x-hidden">
|
<DialogContent className="flex max-h-[90vh] min-w-0 flex-col overflow-hidden p-4 sm:p-10">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="flex min-w-0 flex-col gap-0"
|
className="flex min-h-0 min-w-0 flex-1 flex-col gap-0"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
<div className="min-w-0 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="-mx-1 min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-1 pb-1"
|
||||||
|
>
|
||||||
{/* Detalhes */}
|
{/* Detalhes */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<BasicFieldsSection
|
<BasicFieldsSection
|
||||||
@@ -566,7 +617,7 @@ export function TransactionDialog({
|
|||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
payerOptions={payerOptions}
|
payerOptions={payerOptions}
|
||||||
secondaryPayerOptions={secondaryPayerOptions}
|
splitPayerOptions={splitPayerOptions}
|
||||||
totalAmount={totalAmount}
|
totalAmount={totalAmount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -634,11 +685,15 @@ export function TransactionDialog({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
defaultOpen={formState.condition !== "À vista"}
|
open={extrasOpen}
|
||||||
|
onOpenChange={handleExtrasOpenChange}
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
>
|
>
|
||||||
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
||||||
<RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" />
|
<RiArrowDropDownLine
|
||||||
|
className="text-primary size-4 transition-transform duration-200"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
Condições, anotações e anexos
|
Condições, anotações e anexos
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="min-w-0 overflow-hidden space-y-3 pt-3">
|
<CollapsibleContent className="min-w-0 overflow-hidden space-y-3 pt-3">
|
||||||
@@ -674,13 +729,23 @@ export function TransactionDialog({
|
|||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<TransactionSummaryCard
|
||||||
|
formState={formState}
|
||||||
|
payerOptions={payerOptions}
|
||||||
|
accountOptions={accountOptions}
|
||||||
|
cardOptions={cardOptions}
|
||||||
|
categoryOptions={categoryOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p className="mt-3 text-sm text-destructive">{errorMessage}</p>
|
<p className="mt-3 text-sm text-destructive">{errorMessage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type RemixiconComponentType,
|
||||||
|
RiBankCard2Line,
|
||||||
|
RiBankLine,
|
||||||
|
RiCalendarScheduleLine,
|
||||||
|
RiPriceTag3Line,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
|
import { safeToNumber } from "@/shared/utils/number";
|
||||||
|
import { MONTH_NAMES, parsePeriod } from "@/shared/utils/period";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
import type { SelectOption } from "../../types";
|
||||||
|
import type { FormState } from "./transaction-dialog-types";
|
||||||
|
|
||||||
|
type TransactionSummaryCardProps = {
|
||||||
|
formState: FormState;
|
||||||
|
payerOptions: SelectOption[];
|
||||||
|
accountOptions: SelectOption[];
|
||||||
|
cardOptions: SelectOption[];
|
||||||
|
categoryOptions: SelectOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShareSummary = {
|
||||||
|
payerId: string | undefined;
|
||||||
|
label: string;
|
||||||
|
amountCents: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SummaryChipProps = {
|
||||||
|
icon: RemixiconComponentType;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitCents = (totalCents: number, parts: number) => {
|
||||||
|
if (parts <= 0) return [];
|
||||||
|
|
||||||
|
const base = Math.trunc(totalCents / parts);
|
||||||
|
const remainder = totalCents % parts;
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
{ length: parts },
|
||||||
|
(_, index) => base + (index < remainder ? 1 : 0),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toCents = (value: string | number) =>
|
||||||
|
Math.round(safeToNumber(value) * 100);
|
||||||
|
|
||||||
|
const firstName = (label: string) => label.trim().split(/\s+/)[0] || label;
|
||||||
|
|
||||||
|
function getOptionLabel(options: SelectOption[], value?: string) {
|
||||||
|
if (!value) return null;
|
||||||
|
return options.find((option) => option.value === value)?.label ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryChip({ icon: Icon, children }: SummaryChipProps) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-md bg-background/70 px-1.5 py-0.5 text-[0.7rem] leading-5 text-foreground/80 ring-1 ring-primary/10">
|
||||||
|
<Icon className="size-3 shrink-0 text-primary/65" aria-hidden />
|
||||||
|
<span className="min-w-0 truncate">{children}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShareSummaries(
|
||||||
|
formState: FormState,
|
||||||
|
payerOptions: SelectOption[],
|
||||||
|
totalCents: number,
|
||||||
|
): ShareSummary[] {
|
||||||
|
if (!formState.isSplit) {
|
||||||
|
const label = getOptionLabel(payerOptions, formState.payerId) ?? "Pessoa";
|
||||||
|
return [{ payerId: formState.payerId, label, amountCents: totalCents }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const shares = [
|
||||||
|
{
|
||||||
|
payerId: formState.payerId,
|
||||||
|
amountCents: toCents(formState.primarySplitAmount),
|
||||||
|
},
|
||||||
|
...formState.splitShares.map((share) => ({
|
||||||
|
payerId: share.payerId,
|
||||||
|
amountCents: toCents(share.amount),
|
||||||
|
})),
|
||||||
|
].filter((share) => share.payerId || share.amountCents > 0);
|
||||||
|
|
||||||
|
return shares.map((share, index) => ({
|
||||||
|
payerId: share.payerId,
|
||||||
|
label:
|
||||||
|
getOptionLabel(payerOptions, share.payerId) ??
|
||||||
|
(index === 0 ? "Pessoa principal" : "Pessoa"),
|
||||||
|
amountCents: share.amountCents,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInstallmentPart(totalCents: number, installmentCount: number) {
|
||||||
|
const parts = splitCents(totalCents, installmentCount);
|
||||||
|
const uniqueValues = Array.from(new Set(parts));
|
||||||
|
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
if (uniqueValues.length === 1) {
|
||||||
|
return `${installmentCount}x de ${formatCurrency(parts[0] / 100)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${installmentCount}x de ~${formatCurrency(Math.max(...parts) / 100)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInvoicePeriod(period: string) {
|
||||||
|
try {
|
||||||
|
const { year, month } = parsePeriod(period);
|
||||||
|
return `${MONTH_NAMES[month - 1]} de ${year}`;
|
||||||
|
} catch {
|
||||||
|
return period;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransactionSummaryCard({
|
||||||
|
formState,
|
||||||
|
payerOptions,
|
||||||
|
accountOptions,
|
||||||
|
cardOptions,
|
||||||
|
categoryOptions,
|
||||||
|
}: TransactionSummaryCardProps) {
|
||||||
|
const totalCents = Math.abs(toCents(formState.amount));
|
||||||
|
const totalAmount = totalCents / 100;
|
||||||
|
const installmentCount = Math.max(
|
||||||
|
0,
|
||||||
|
Math.trunc(safeToNumber(formState.installmentCount)),
|
||||||
|
);
|
||||||
|
const startInstallment = Math.max(
|
||||||
|
1,
|
||||||
|
Math.trunc(safeToNumber(formState.startInstallment, 1)),
|
||||||
|
);
|
||||||
|
const isInstallment =
|
||||||
|
formState.condition === "Parcelado" && installmentCount > 1;
|
||||||
|
const remainingInstallments = isInstallment
|
||||||
|
? Math.max(0, installmentCount - startInstallment + 1)
|
||||||
|
: 1;
|
||||||
|
const shares = getShareSummaries(formState, payerOptions, totalCents);
|
||||||
|
const targetLabel =
|
||||||
|
formState.paymentMethod === "Cartão de crédito"
|
||||||
|
? getOptionLabel(cardOptions, formState.cardId)
|
||||||
|
: getOptionLabel(accountOptions, formState.accountId);
|
||||||
|
const categoryLabel = getOptionLabel(categoryOptions, formState.categoryId);
|
||||||
|
const shareTotalCents = shares.reduce(
|
||||||
|
(sum, share) => sum + share.amountCents,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const hasSplitDifference =
|
||||||
|
formState.isSplit && Math.abs(shareTotalCents - totalCents) > 1;
|
||||||
|
const displayedShares = shares.slice(0, 3);
|
||||||
|
const remainingShares = Math.max(0, shares.length - displayedShares.length);
|
||||||
|
const operationCount =
|
||||||
|
Math.max(1, remainingInstallments) * Math.max(1, shares.length);
|
||||||
|
const statusLabel =
|
||||||
|
formState.paymentMethod === "Cartão de crédito"
|
||||||
|
? `na fatura de ${formatInvoicePeriod(formState.period)}`
|
||||||
|
: formState.isSettled
|
||||||
|
? "como pago"
|
||||||
|
: "em aberto";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5 text-xs shadow-xs shadow-primary/5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-foreground">Resumo da operação</p>
|
||||||
|
<p className="mt-0.5 text-muted-foreground">
|
||||||
|
{formState.transactionType || "Lançamento"} de{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{formatCurrency(totalAmount)}
|
||||||
|
</span>{" "}
|
||||||
|
{statusLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full px-2 py-0.5 font-medium",
|
||||||
|
formState.transactionType === "Receita"
|
||||||
|
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "bg-orange-500/10 text-orange-700 dark:text-orange-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{operationCount} lançamento{operationCount > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1 text-muted-foreground">
|
||||||
|
<SummaryChip
|
||||||
|
icon={
|
||||||
|
formState.paymentMethod === "Cartão de crédito"
|
||||||
|
? RiBankCard2Line
|
||||||
|
: RiBankLine
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formState.paymentMethod || "Forma não informada"}
|
||||||
|
</SummaryChip>
|
||||||
|
{targetLabel ? (
|
||||||
|
<SummaryChip
|
||||||
|
icon={
|
||||||
|
formState.paymentMethod === "Cartão de crédito"
|
||||||
|
? RiBankCard2Line
|
||||||
|
: RiBankLine
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{targetLabel}
|
||||||
|
</SummaryChip>
|
||||||
|
) : null}
|
||||||
|
{categoryLabel ? (
|
||||||
|
<SummaryChip icon={RiPriceTag3Line}>{categoryLabel}</SummaryChip>
|
||||||
|
) : null}
|
||||||
|
{isInstallment ? (
|
||||||
|
<SummaryChip icon={RiCalendarScheduleLine}>
|
||||||
|
{startInstallment > 1
|
||||||
|
? `${remainingInstallments} parcelas restantes de ${installmentCount}`
|
||||||
|
: `${installmentCount} parcelas`}
|
||||||
|
</SummaryChip>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{displayedShares.map((share) => {
|
||||||
|
const installmentLabel = isInstallment
|
||||||
|
? formatInstallmentPart(share.amountCents, installmentCount)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${share.payerId ?? share.label}-${share.amountCents}`}
|
||||||
|
className="flex items-center justify-between gap-3 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">{firstName(share.label)}</span>
|
||||||
|
<span className="shrink-0 text-right text-foreground">
|
||||||
|
{formatCurrency(share.amountCents / 100)}
|
||||||
|
{installmentLabel ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
· {installmentLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{remainingShares > 0 ? (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
+{remainingShares} pessoas na divisão
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasSplitDifference ? (
|
||||||
|
<p className="mt-2 text-[0.7rem] text-destructive">
|
||||||
|
A divisão soma {formatCurrency(shareTotalCents / 100)} de{" "}
|
||||||
|
{formatCurrency(totalAmount)}.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,16 +74,16 @@ export function GlobalFields({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Aplicado a todos os lançamentos importados.
|
Aplicado aos lançamentos selecionados.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="grid w-full grid-cols-1 items-end justify-start gap-3 sm:grid-cols-[repeat(2,minmax(0,14rem))] lg:grid-cols-[16rem_14rem_18rem_14rem]">
|
||||||
<div className="flex min-w-44 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<Label>Conta / Cartão</Label>
|
<Label>Conta / Cartão</Label>
|
||||||
<Select
|
<Select
|
||||||
value={accountCardValue ?? ""}
|
value={accountCardValue ?? ""}
|
||||||
onValueChange={(v) => onAccountCardChange(v || null)}
|
onValueChange={(v) => onAccountCardChange(v || null)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Selecionar conta ou cartão…" />
|
<SelectValue placeholder="Selecionar conta ou cartão…" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -122,14 +122,14 @@ export function GlobalFields({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-44 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<Label>Pessoa</Label>
|
<Label>Pessoa</Label>
|
||||||
<Select
|
<Select
|
||||||
value={payerId ?? ""}
|
value={payerId ?? ""}
|
||||||
onValueChange={(v) => onPayerChange(v || null)}
|
onValueChange={(v) => onPayerChange(v || null)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Selecionar pessoa…" />
|
<SelectValue placeholder="Aplicar pessoa…" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{payerOptions.map((opt) => (
|
{payerOptions.map((opt) => (
|
||||||
@@ -144,10 +144,10 @@ export function GlobalFields({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-44 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<Label>Categoria</Label>
|
<Label>Categoria</Label>
|
||||||
<Select onValueChange={onBulkCategoryChange}>
|
<Select onValueChange={onBulkCategoryChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Aplicar a todas selecionadas…" />
|
<SelectValue placeholder="Aplicar a todas selecionadas…" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -185,7 +185,7 @@ export function GlobalFields({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCard && (
|
{isCard && (
|
||||||
<div className="flex min-w-44 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<Label>Fatura</Label>
|
<Label>Fatura</Label>
|
||||||
<PeriodPicker
|
<PeriodPicker
|
||||||
value={invoicePeriod ?? ""}
|
value={invoicePeriod ?? ""}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ import {
|
|||||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
import type { ImportStatement } from "@/shared/lib/import/types";
|
import type { ImportStatement } from "@/shared/lib/import/types";
|
||||||
|
|
||||||
|
const categoryGroupByTransactionType = {
|
||||||
|
expense: "despesa",
|
||||||
|
income: "receita",
|
||||||
|
} as const;
|
||||||
|
|
||||||
interface ImportPageProps {
|
interface ImportPageProps {
|
||||||
payerOptions: SelectOption[];
|
payerOptions: SelectOption[];
|
||||||
accountOptions: SelectOption[];
|
accountOptions: SelectOption[];
|
||||||
@@ -69,33 +74,63 @@ export function ImportPage({
|
|||||||
const [accountCardValue, setAccountCardValue] = useState<string | null>(null);
|
const [accountCardValue, setAccountCardValue] = useState<string | null>(null);
|
||||||
const [invoicePeriod, setInvoicePeriod] = useState<string | null>(null);
|
const [invoicePeriod, setInvoicePeriod] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleParsed = useCallback(async (stmt: ImportStatement) => {
|
const categoryGroupById = useMemo(
|
||||||
setStatement(stmt);
|
() =>
|
||||||
setIsChecking(true);
|
new Map(categoryOptions.map((option) => [option.value, option.group])),
|
||||||
|
[categoryOptions],
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
const isCategoryCompatible = useCallback(
|
||||||
const fitIds = stmt.transactions
|
(
|
||||||
.map((t) => t.externalId)
|
categoryId: string | null,
|
||||||
.filter((id): id is string => id !== null);
|
transactionType: ReviewRow["transactionType"],
|
||||||
|
) =>
|
||||||
|
!categoryId ||
|
||||||
|
categoryGroupById.get(categoryId) ===
|
||||||
|
categoryGroupByTransactionType[transactionType],
|
||||||
|
[categoryGroupById],
|
||||||
|
);
|
||||||
|
|
||||||
const [duplicates, categoryMappings] = await Promise.all([
|
const handleParsed = useCallback(
|
||||||
checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
|
async (stmt: ImportStatement) => {
|
||||||
fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
|
setStatement(stmt);
|
||||||
]);
|
setIsChecking(true);
|
||||||
|
|
||||||
setRows(
|
try {
|
||||||
stmt.transactions.map((t) => ({
|
const fitIds = stmt.transactions
|
||||||
...t,
|
.map((t) => t.externalId)
|
||||||
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
|
.filter((id): id is string => id !== null);
|
||||||
selected: t.externalId ? !duplicates.has(t.externalId) : true,
|
|
||||||
categoryId:
|
const [duplicates, categoryMappings] = await Promise.all([
|
||||||
categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
|
checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
|
||||||
})),
|
fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
|
||||||
);
|
]);
|
||||||
} finally {
|
|
||||||
setIsChecking(false);
|
setRows(
|
||||||
}
|
stmt.transactions.map((t) => {
|
||||||
}, []);
|
const mappedCategoryId =
|
||||||
|
categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
|
||||||
|
selected: t.externalId ? !duplicates.has(t.externalId) : true,
|
||||||
|
payerId,
|
||||||
|
categoryId: isCategoryCompatible(
|
||||||
|
mappedCategoryId,
|
||||||
|
t.transactionType,
|
||||||
|
)
|
||||||
|
? mappedCategoryId
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isCategoryCompatible, payerId],
|
||||||
|
);
|
||||||
|
|
||||||
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX
|
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -121,7 +156,17 @@ export function ImportPage({
|
|||||||
|
|
||||||
const handleCategoryChange = (index: number, categoryId: string | null) => {
|
const handleCategoryChange = (index: number, categoryId: string | null) => {
|
||||||
setRows((prev) =>
|
setRows((prev) =>
|
||||||
prev.map((r, i) => (i === index ? { ...r, categoryId } : r)),
|
prev.map((r, i) =>
|
||||||
|
i === index && isCategoryCompatible(categoryId, r.transactionType)
|
||||||
|
? { ...r, categoryId }
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePayerChange = (index: number, payerId: string | null) => {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r, i) => (i === index ? { ...r, payerId } : r)),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,17 +195,36 @@ export function ImportPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkCategoryChange = (categoryId: string) => {
|
const handleBulkCategoryChange = (categoryId: string) => {
|
||||||
setRows((prev) => prev.map((r) => (r.selected ? { ...r, categoryId } : r)));
|
setRows((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.selected && isCategoryCompatible(categoryId, r.transactionType)
|
||||||
|
? { ...r, categoryId }
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkPayerChange = (nextPayerId: string | null) => {
|
||||||
|
setPayerId(nextPayerId);
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => (r.selected ? { ...r, payerId: nextPayerId } : r)),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCard = accountCardValue?.startsWith("card:") ?? false;
|
const isCard = accountCardValue?.startsWith("card:") ?? false;
|
||||||
|
|
||||||
const { selectedRows, duplicateCount, uncategorizedCount } = useMemo(() => {
|
const {
|
||||||
|
selectedRows,
|
||||||
|
duplicateCount,
|
||||||
|
uncategorizedCount,
|
||||||
|
withoutPayerCount,
|
||||||
|
} = useMemo(() => {
|
||||||
const selected = rows.filter((r) => r.selected);
|
const selected = rows.filter((r) => r.selected);
|
||||||
return {
|
return {
|
||||||
selectedRows: selected,
|
selectedRows: selected,
|
||||||
duplicateCount: rows.filter((r) => r.isDuplicate).length,
|
duplicateCount: rows.filter((r) => r.isDuplicate).length,
|
||||||
uncategorizedCount: selected.filter((r) => !r.categoryId).length,
|
uncategorizedCount: selected.filter((r) => !r.categoryId).length,
|
||||||
|
withoutPayerCount: selected.filter((r) => !r.payerId).length,
|
||||||
};
|
};
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
|
|
||||||
@@ -168,6 +232,7 @@ export function ImportPage({
|
|||||||
selectedRows.length > 0 &&
|
selectedRows.length > 0 &&
|
||||||
!!accountCardValue &&
|
!!accountCardValue &&
|
||||||
uncategorizedCount === 0 &&
|
uncategorizedCount === 0 &&
|
||||||
|
withoutPayerCount === 0 &&
|
||||||
(!isCard || !!invoicePeriod) &&
|
(!isCard || !!invoicePeriod) &&
|
||||||
!isPending;
|
!isPending;
|
||||||
|
|
||||||
@@ -191,6 +256,7 @@ export function ImportPage({
|
|||||||
description: r.description,
|
description: r.description,
|
||||||
transactionType: r.transactionType,
|
transactionType: r.transactionType,
|
||||||
categoryId: r.categoryId,
|
categoryId: r.categoryId,
|
||||||
|
payerId: r.payerId,
|
||||||
})),
|
})),
|
||||||
payerId,
|
payerId,
|
||||||
accountId,
|
accountId,
|
||||||
@@ -280,6 +346,7 @@ export function ImportPage({
|
|||||||
selected={selectedRows.length}
|
selected={selectedRows.length}
|
||||||
duplicates={duplicateCount}
|
duplicates={duplicateCount}
|
||||||
uncategorized={uncategorizedCount}
|
uncategorized={uncategorizedCount}
|
||||||
|
withoutPayer={withoutPayerCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GlobalFields
|
<GlobalFields
|
||||||
@@ -291,23 +358,25 @@ export function ImportPage({
|
|||||||
payerId={payerId}
|
payerId={payerId}
|
||||||
invoicePeriod={invoicePeriod}
|
invoicePeriod={invoicePeriod}
|
||||||
onAccountCardChange={setAccountCardValue}
|
onAccountCardChange={setAccountCardValue}
|
||||||
onPayerChange={setPayerId}
|
onPayerChange={handleBulkPayerChange}
|
||||||
onInvoicePeriodChange={setInvoicePeriod}
|
onInvoicePeriodChange={setInvoicePeriod}
|
||||||
onBulkCategoryChange={handleBulkCategoryChange}
|
onBulkCategoryChange={handleBulkCategoryChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReviewTable
|
<ReviewTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
payerOptions={payerOptions}
|
||||||
categoryOptions={categoryOptions}
|
categoryOptions={categoryOptions}
|
||||||
onToggle={toggleRow}
|
onToggle={toggleRow}
|
||||||
onToggleAll={toggleAll}
|
onToggleAll={toggleAll}
|
||||||
|
onPayerChange={handlePayerChange}
|
||||||
onCategoryChange={handleCategoryChange}
|
onCategoryChange={handleCategoryChange}
|
||||||
onDescriptionChange={handleDescriptionChange}
|
onDescriptionChange={handleDescriptionChange}
|
||||||
onUndoDuplicate={handleUndoDuplicate}
|
onUndoDuplicate={handleUndoDuplicate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sticky footer */}
|
{/* Sticky footer */}
|
||||||
<div className="sticky bottom-0 -mx-6 border-t bg-background px-6 py-4">
|
<div className="sticky bottom-0 -mx-6 px-6">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ImportSummaryProps {
|
|||||||
selected: number;
|
selected: number;
|
||||||
duplicates: number;
|
duplicates: number;
|
||||||
uncategorized: number;
|
uncategorized: number;
|
||||||
|
withoutPayer: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportSummary({
|
export function ImportSummary({
|
||||||
@@ -18,9 +19,10 @@ export function ImportSummary({
|
|||||||
selected,
|
selected,
|
||||||
duplicates,
|
duplicates,
|
||||||
uncategorized,
|
uncategorized,
|
||||||
|
withoutPayer,
|
||||||
}: ImportSummaryProps) {
|
}: ImportSummaryProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col gap-1 p-5 text-sm bg-linear-to-br from-primary/5 to-transparent">
|
<Card className="flex flex-col gap-1 p-5 text-sm bg-primary/10 shadow-none ">
|
||||||
{/* Linha 1: título */}
|
{/* Linha 1: título */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{statement.source}</span>
|
<span className="font-medium">{statement.source}</span>
|
||||||
@@ -40,8 +42,7 @@ export function ImportSummary({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium text-foreground">{selected}</span>/
|
{selected}/{total} selecionadas
|
||||||
{total} selecionadas
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{duplicates > 0 && (
|
{duplicates > 0 && (
|
||||||
@@ -59,6 +60,16 @@ export function ImportSummary({
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{withoutPayer > 0 ? (
|
||||||
|
<span>{withoutPayer} sem pessoa</span>
|
||||||
|
) : (
|
||||||
|
selected > 0 && (
|
||||||
|
<span className="text-emerald-600 dark:text-emerald-400">
|
||||||
|
todas com pessoa ✓
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { CategorySelectContent } from "@/features/transactions/components/select-items";
|
import {
|
||||||
|
CategorySelectContent,
|
||||||
|
PayerSelectContent,
|
||||||
|
} from "@/features/transactions/components/select-items";
|
||||||
import type { SelectOption } from "@/features/transactions/components/types";
|
import type { SelectOption } from "@/features/transactions/components/types";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
||||||
@@ -31,17 +34,28 @@ import {
|
|||||||
import type { ImportedTransaction } from "@/shared/lib/import/types";
|
import type { ImportedTransaction } from "@/shared/lib/import/types";
|
||||||
import { formatDate } from "@/shared/utils/date";
|
import { formatDate } from "@/shared/utils/date";
|
||||||
|
|
||||||
|
const categoryGroupByTransactionType: Record<
|
||||||
|
ImportedTransaction["transactionType"],
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
expense: "despesa",
|
||||||
|
income: "receita",
|
||||||
|
};
|
||||||
|
|
||||||
export type ReviewRow = ImportedTransaction & {
|
export type ReviewRow = ImportedTransaction & {
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
isDuplicate: boolean;
|
isDuplicate: boolean;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
|
payerId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ReviewTableProps {
|
interface ReviewTableProps {
|
||||||
rows: ReviewRow[];
|
rows: ReviewRow[];
|
||||||
|
payerOptions: SelectOption[];
|
||||||
categoryOptions: SelectOption[];
|
categoryOptions: SelectOption[];
|
||||||
onToggle: (index: number) => void;
|
onToggle: (index: number) => void;
|
||||||
onToggleAll: (selected: boolean) => void;
|
onToggleAll: (selected: boolean) => void;
|
||||||
|
onPayerChange: (index: number, payerId: string | null) => void;
|
||||||
onCategoryChange: (index: number, categoryId: string | null) => void;
|
onCategoryChange: (index: number, categoryId: string | null) => void;
|
||||||
onDescriptionChange: (index: number, description: string) => void;
|
onDescriptionChange: (index: number, description: string) => void;
|
||||||
onUndoDuplicate: (index: number) => void;
|
onUndoDuplicate: (index: number) => void;
|
||||||
@@ -49,9 +63,11 @@ interface ReviewTableProps {
|
|||||||
|
|
||||||
export function ReviewTable({
|
export function ReviewTable({
|
||||||
rows,
|
rows,
|
||||||
|
payerOptions,
|
||||||
categoryOptions,
|
categoryOptions,
|
||||||
onToggle,
|
onToggle,
|
||||||
onToggleAll,
|
onToggleAll,
|
||||||
|
onPayerChange,
|
||||||
onCategoryChange,
|
onCategoryChange,
|
||||||
onDescriptionChange,
|
onDescriptionChange,
|
||||||
onUndoDuplicate,
|
onUndoDuplicate,
|
||||||
@@ -97,6 +113,7 @@ export function ReviewTable({
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-24">Data</TableHead>
|
<TableHead className="w-24">Data</TableHead>
|
||||||
<TableHead>Descrição</TableHead>
|
<TableHead>Descrição</TableHead>
|
||||||
|
<TableHead className="w-44">Pessoa</TableHead>
|
||||||
<TableHead className="w-44">Categoria</TableHead>
|
<TableHead className="w-44">Categoria</TableHead>
|
||||||
<TableHead className="w-20">Tipo</TableHead>
|
<TableHead className="w-20">Tipo</TableHead>
|
||||||
<TableHead className="w-28 text-right">Valor</TableHead>
|
<TableHead className="w-28 text-right">Valor</TableHead>
|
||||||
@@ -106,7 +123,7 @@ export function ReviewTable({
|
|||||||
{paddingTop > 0 && (
|
{paddingTop > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={6}
|
colSpan={7}
|
||||||
style={{ height: paddingTop, padding: 0 }}
|
style={{ height: paddingTop, padding: 0 }}
|
||||||
/>
|
/>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -117,6 +134,11 @@ export function ReviewTable({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const index = virtualRow.index;
|
const index = virtualRow.index;
|
||||||
|
const categoryOptionsForRow = categoryOptions.filter(
|
||||||
|
(option) =>
|
||||||
|
option.group ===
|
||||||
|
categoryGroupByTransactionType[row.transactionType],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.externalId ?? `${row.date}-${index}`}
|
key={row.externalId ?? `${row.date}-${index}`}
|
||||||
@@ -177,6 +199,26 @@ export function ReviewTable({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={row.payerId ?? ""}
|
||||||
|
onValueChange={(v) => onPayerChange(index, v || null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Pessoa…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{payerOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
<PayerSelectContent
|
||||||
|
label={opt.label}
|
||||||
|
avatarUrl={opt.avatarUrl}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Select
|
<Select
|
||||||
value={row.categoryId ?? ""}
|
value={row.categoryId ?? ""}
|
||||||
@@ -186,7 +228,7 @@ export function ReviewTable({
|
|||||||
<SelectValue placeholder="Categoria…" />
|
<SelectValue placeholder="Categoria…" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{categoryOptions.map((opt) => (
|
{categoryOptionsForRow.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
<CategorySelectContent
|
<CategorySelectContent
|
||||||
label={opt.label}
|
label={opt.label}
|
||||||
@@ -225,7 +267,7 @@ export function ReviewTable({
|
|||||||
{paddingBottom > 0 && (
|
{paddingBottom > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={6}
|
colSpan={7}
|
||||||
style={{ height: paddingBottom, padding: 0 }}
|
style={{ height: paddingBottom, padding: 0 }}
|
||||||
/>
|
/>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ interface TransactionsPageProps {
|
|||||||
categoryFilterOptions: TransactionFilterOption[];
|
categoryFilterOptions: TransactionFilterOption[];
|
||||||
accountCardFilterOptions: AccountCardFilterOption[];
|
accountCardFilterOptions: AccountCardFilterOption[];
|
||||||
selectedPeriod: string;
|
selectedPeriod: string;
|
||||||
|
defaultAccountId?: string | null;
|
||||||
estabelecimentos: string[];
|
estabelecimentos: string[];
|
||||||
allowCreate?: boolean;
|
allowCreate?: boolean;
|
||||||
noteAsColumn?: boolean;
|
noteAsColumn?: boolean;
|
||||||
@@ -96,6 +97,7 @@ export function TransactionsPage({
|
|||||||
categoryFilterOptions,
|
categoryFilterOptions,
|
||||||
accountCardFilterOptions,
|
accountCardFilterOptions,
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
|
defaultAccountId,
|
||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
allowCreate = true,
|
allowCreate = true,
|
||||||
noteAsColumn = false,
|
noteAsColumn = false,
|
||||||
@@ -562,6 +564,7 @@ export function TransactionsPage({
|
|||||||
categoryOptions={categoryOptions}
|
categoryOptions={categoryOptions}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
defaultAccountId={defaultAccountId}
|
||||||
defaultCardId={defaultCardId}
|
defaultCardId={defaultCardId}
|
||||||
defaultPaymentMethod={defaultPaymentMethod}
|
defaultPaymentMethod={defaultPaymentMethod}
|
||||||
lockCardSelection={lockCardSelection}
|
lockCardSelection={lockCardSelection}
|
||||||
@@ -585,6 +588,7 @@ export function TransactionsPage({
|
|||||||
categoryOptions={categoryOptions}
|
categoryOptions={categoryOptions}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
defaultAccountId={defaultAccountId}
|
||||||
defaultCardId={defaultCardId}
|
defaultCardId={defaultCardId}
|
||||||
defaultPaymentMethod={defaultPaymentMethod}
|
defaultPaymentMethod={defaultPaymentMethod}
|
||||||
lockCardSelection={lockCardSelection}
|
lockCardSelection={lockCardSelection}
|
||||||
@@ -648,6 +652,7 @@ export function TransactionsPage({
|
|||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
transaction={transactionToCopy ?? undefined}
|
transaction={transactionToCopy ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
defaultAccountId={defaultAccountId}
|
||||||
maxSizeMb={attachmentMaxSizeMb}
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -669,6 +674,7 @@ export function TransactionsPage({
|
|||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
transaction={transactionToImport ?? undefined}
|
transaction={transactionToImport ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
defaultAccountId={defaultAccountId}
|
||||||
isImporting={true}
|
isImporting={true}
|
||||||
maxSizeMb={attachmentMaxSizeMb}
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
@@ -697,6 +703,7 @@ export function TransactionsPage({
|
|||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
transaction={selectedTransaction ?? undefined}
|
transaction={selectedTransaction ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
defaultAccountId={defaultAccountId}
|
||||||
onBulkEditRequest={handleBulkEditRequest}
|
onBulkEditRequest={handleBulkEditRequest}
|
||||||
onSplitEditRequest={handleSplitEditRequest}
|
onSplitEditRequest={handleSplitEditRequest}
|
||||||
maxSizeMb={attachmentMaxSizeMb}
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
@@ -844,16 +851,6 @@ export function TransactionsPage({
|
|||||||
onOpenChange={setAnticipationHistoryOpen}
|
onOpenChange={setAnticipationHistoryOpen}
|
||||||
seriesId={selectedForAnticipation.seriesId as string}
|
seriesId={selectedForAnticipation.seriesId as string}
|
||||||
lancamentoName={selectedForAnticipation.name}
|
lancamentoName={selectedForAnticipation.name}
|
||||||
onViewLancamento={(transactionId) => {
|
|
||||||
const transaction = transactionList.find(
|
|
||||||
(l) => l.id === transactionId,
|
|
||||||
);
|
|
||||||
if (transaction) {
|
|
||||||
setSelectedTransaction(transaction);
|
|
||||||
setDetailsOpen(true);
|
|
||||||
setAnticipationHistoryOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function PayerSelectContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Avatar className="size-5 border border-border/60 bg-background">
|
<Avatar className="size-6 border border-border/60 bg-background">
|
||||||
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
|
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
|
||||||
<AvatarFallback className="text-xs font-medium uppercase">
|
<AvatarFallback className="text-xs font-medium uppercase">
|
||||||
{initial}
|
{initial}
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react";
|
import { RiCalendarCheckLine } from "@remixicon/react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { useTransition } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
|
|
||||||
import type { InstallmentAnticipationListItem } from "@/features/transactions/hooks/use-installment-anticipations";
|
import type { InstallmentAnticipationListItem } from "@/features/transactions/hooks/use-installment-anticipations";
|
||||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/shared/components/ui/card";
|
} from "@/shared/components/ui/card";
|
||||||
@@ -23,174 +17,129 @@ import { displayPeriod } from "@/shared/utils/period";
|
|||||||
|
|
||||||
interface AnticipationCardProps {
|
interface AnticipationCardProps {
|
||||||
anticipation: InstallmentAnticipationListItem;
|
anticipation: InstallmentAnticipationListItem;
|
||||||
onViewLancamento?: (transactionId: string) => void;
|
|
||||||
onCanceled?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnticipationCard({
|
export function AnticipationCard({ anticipation }: AnticipationCardProps) {
|
||||||
anticipation,
|
|
||||||
onViewLancamento,
|
|
||||||
onCanceled,
|
|
||||||
}: AnticipationCardProps) {
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const isSettled = anticipation.transaction?.isSettled === true;
|
const isSettled = anticipation.transaction?.isSettled === true;
|
||||||
const canCancel = !isSettled;
|
const totalAmount = Number(anticipation.totalAmount);
|
||||||
|
const discount = Number(anticipation.discount);
|
||||||
|
|
||||||
|
const finalAmount =
|
||||||
|
totalAmount < 0 ? totalAmount + discount : totalAmount - discount;
|
||||||
|
|
||||||
|
const hasDiscount = discount > 0;
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
const formatDate = (date: string) => {
|
||||||
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
|
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", {
|
||||||
};
|
locale: ptBR,
|
||||||
|
|
||||||
const handleCancel = async () => {
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await cancelInstallmentAnticipationAction({
|
|
||||||
anticipationId: anticipation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(result.message);
|
|
||||||
onCanceled?.();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || "Erro ao cancelar antecipação");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewLancamento = () => {
|
|
||||||
onViewLancamento?.(anticipation.transactionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="shadow-none py-2">
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
|
<CardHeader className="space-y-3 p-4 pb-1">
|
||||||
<div className="space-y-1">
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
<CardTitle className="text-base">
|
<div className="min-w-0 space-y-1">
|
||||||
{anticipation.installmentCount}{" "}
|
<CardTitle className="text-base leading-none">
|
||||||
{anticipation.installmentCount === 1
|
{anticipation.installmentCount}{" "}
|
||||||
? "parcela antecipada"
|
{anticipation.installmentCount === 1
|
||||||
: "parcelas antecipadas"}
|
? "parcela antecipada"
|
||||||
</CardTitle>
|
: "parcelas antecipadas"}
|
||||||
<CardDescription>
|
</CardTitle>
|
||||||
<RiCalendarCheckLine className="mr-1 inline size-3.5" />
|
|
||||||
{formatDate(anticipation.anticipationDate)}
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
</CardDescription>
|
<RiCalendarCheckLine className="size-3 shrink-0" />
|
||||||
|
<span>{formatDate(anticipation.anticipationDate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge variant="secondary" className="shrink-0 rounded-full px-3">
|
||||||
|
{displayPeriod(anticipation.anticipationPeriod)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-lg bg-primary/10 p-3">
|
||||||
|
<span className="text-xs font-medium text-foreground">
|
||||||
|
{hasDiscount ? "Valor Final" : "Valor Total"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-lg font-semibold leading-none text-primary">
|
||||||
|
<MoneyValues amount={finalAmount} />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">
|
|
||||||
{displayPeriod(anticipation.anticipationPeriod)}
|
|
||||||
</Badge>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="px-4 pb-4 pt-0">
|
||||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||||
<div>
|
<DetailItem label="Valor Original">
|
||||||
<dt className="text-muted-foreground">Valor Original</dt>
|
<MoneyValues amount={totalAmount} />
|
||||||
<dd className="mt-1 font-medium">
|
</DetailItem>
|
||||||
<MoneyValues amount={Number(anticipation.totalAmount)} />
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Number(anticipation.discount) > 0 && (
|
{hasDiscount ? (
|
||||||
<div>
|
<DetailItem label="Desconto" valueClassName="text-success">
|
||||||
<dt className="text-muted-foreground">Desconto</dt>
|
- <MoneyValues amount={discount} />
|
||||||
<dd className="mt-1 font-medium text-success">
|
</DetailItem>
|
||||||
- <MoneyValues amount={Number(anticipation.discount)} />
|
) : (
|
||||||
</dd>
|
<div />
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<DetailItem label="Status">
|
||||||
className={
|
<Badge
|
||||||
Number(anticipation.discount) > 0
|
variant={isSettled ? "success" : "outline"}
|
||||||
? "col-span-2 border-t pt-3"
|
className="h-5 rounded-full px-2 text-xs"
|
||||||
: ""
|
>
|
||||||
}
|
{isSettled ? "Pago" : "Pendente"}
|
||||||
>
|
</Badge>
|
||||||
<dt className="text-muted-foreground">
|
</DetailItem>
|
||||||
{Number(anticipation.discount) > 0
|
|
||||||
? "Valor Final"
|
|
||||||
: "Valor Total"}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-lg font-semibold text-primary">
|
|
||||||
<MoneyValues
|
|
||||||
amount={
|
|
||||||
Number(anticipation.totalAmount) < 0
|
|
||||||
? Number(anticipation.totalAmount) +
|
|
||||||
Number(anticipation.discount)
|
|
||||||
: Number(anticipation.totalAmount) -
|
|
||||||
Number(anticipation.discount)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{anticipation.payer ? (
|
||||||
<dt className="text-muted-foreground">Status do Lançamento</dt>
|
<DetailItem label="Pessoa">{anticipation.payer.name}</DetailItem>
|
||||||
<dd className="mt-1">
|
) : (
|
||||||
<Badge variant={isSettled ? "success" : "outline"}>
|
<div />
|
||||||
{isSettled ? "Pago" : "Pendente"}
|
|
||||||
</Badge>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{anticipation.payer && (
|
|
||||||
<div>
|
|
||||||
<dt className="text-muted-foreground">Pessoa</dt>
|
|
||||||
<dd className="mt-1 font-medium">{anticipation.payer.name}</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{anticipation.category && (
|
{anticipation.category ? (
|
||||||
<div>
|
<DetailItem label="Categoria">
|
||||||
<dt className="text-muted-foreground">Categoria</dt>
|
{anticipation.category.name}
|
||||||
<dd className="mt-1 font-medium">{anticipation.category.name}</dd>
|
</DetailItem>
|
||||||
</div>
|
) : null}
|
||||||
)}
|
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{anticipation.note && (
|
{anticipation.note ? (
|
||||||
<div className="rounded-lg border p-3">
|
<div className="mt-3 border-t pt-3">
|
||||||
<dt className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
Observação
|
Observação
|
||||||
</dt>
|
</p>
|
||||||
<dd className="mt-1 text-sm">{anticipation.note}</dd>
|
<p className="mt-1 text-sm leading-snug">{anticipation.note}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleViewLancamento}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<RiEyeLine className="mr-2 size-4" />
|
|
||||||
Ver Lançamento
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{canCancel && (
|
|
||||||
<ConfirmActionDialog
|
|
||||||
trigger={
|
|
||||||
<Button variant="destructive" size="sm" disabled={isPending}>
|
|
||||||
<RiCloseLine className="mr-2 size-4" />
|
|
||||||
Cancelar Antecipação
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Cancelar antecipação?"
|
|
||||||
description="Esta ação irá reverter a antecipação e restaurar as parcelas originais. O lançamento de antecipação será removido."
|
|
||||||
confirmLabel="Cancelar Antecipação"
|
|
||||||
confirmVariant="destructive"
|
|
||||||
pendingLabel="Cancelando..."
|
|
||||||
onConfirm={handleCancel}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSettled && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Não é possível cancelar uma antecipação paga
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DetailItem({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
valueClassName,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
valueClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<dt className="text-xs font-medium leading-none text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
|
||||||
|
<dd
|
||||||
|
className={`truncate text-sm font-medium leading-tight ${
|
||||||
|
valueClassName ?? ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiCheckLine,
|
||||||
|
RiDeleteBin5Line,
|
||||||
|
RiFileCopyLine,
|
||||||
|
RiFileList2Line,
|
||||||
|
RiHistoryLine,
|
||||||
|
RiMoreFill,
|
||||||
|
RiPencilLine,
|
||||||
|
RiRefundLine,
|
||||||
|
RiTimeLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu";
|
||||||
|
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||||
|
import type { TransactionItem } from "../types";
|
||||||
|
|
||||||
|
type TransactionActionsMenuProps = {
|
||||||
|
item: TransactionItem;
|
||||||
|
currentUserId: string;
|
||||||
|
onEdit?: (item: TransactionItem) => void;
|
||||||
|
onCopy?: (item: TransactionItem) => void;
|
||||||
|
onImport?: (item: TransactionItem) => void;
|
||||||
|
onConfirmDelete?: (item: TransactionItem) => void;
|
||||||
|
onViewDetails?: (item: TransactionItem) => void;
|
||||||
|
onRefund?: (item: TransactionItem) => void;
|
||||||
|
onAnticipate?: (item: TransactionItem) => void;
|
||||||
|
onViewAnticipationHistory?: (item: TransactionItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransactionActionsMenu({
|
||||||
|
item,
|
||||||
|
currentUserId,
|
||||||
|
onEdit,
|
||||||
|
onCopy,
|
||||||
|
onImport,
|
||||||
|
onConfirmDelete,
|
||||||
|
onViewDetails,
|
||||||
|
onRefund,
|
||||||
|
onAnticipate,
|
||||||
|
onViewAnticipationHistory,
|
||||||
|
}: TransactionActionsMenuProps) {
|
||||||
|
const isOwnData = item.userId === currentUserId;
|
||||||
|
const canRefund =
|
||||||
|
isOwnData &&
|
||||||
|
item.transactionType === "Despesa" &&
|
||||||
|
item.condition === "À vista" &&
|
||||||
|
!item.splitGroupId &&
|
||||||
|
!item.readonly &&
|
||||||
|
!item.note?.startsWith(REFUND_NOTE_PREFIX);
|
||||||
|
const showInstallmentActions =
|
||||||
|
isOwnData && item.condition === "Parcelado" && item.seriesId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm">
|
||||||
|
<RiMoreFill className="size-4" aria-hidden />
|
||||||
|
<span className="sr-only">Abrir ações do lançamento</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => onViewDetails?.(item)}
|
||||||
|
disabled={!onViewDetails}
|
||||||
|
>
|
||||||
|
<RiFileList2Line className="size-4" aria-hidden />
|
||||||
|
Detalhes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{isOwnData ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => onEdit?.(item)}
|
||||||
|
disabled={item.readonly || !onEdit}
|
||||||
|
>
|
||||||
|
<RiPencilLine className="size-4" aria-hidden />
|
||||||
|
Editar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!item.readonly && isOwnData ? (
|
||||||
|
<DropdownMenuItem onSelect={() => onCopy?.(item)} disabled={!onCopy}>
|
||||||
|
<RiFileCopyLine className="size-4" aria-hidden />
|
||||||
|
Copiar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!item.readonly && !isOwnData ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => onImport?.(item)}
|
||||||
|
disabled={!onImport}
|
||||||
|
>
|
||||||
|
<RiFileCopyLine className="size-4" aria-hidden />
|
||||||
|
Importar para Minha Conta
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canRefund ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => onRefund?.(item)}
|
||||||
|
disabled={!onRefund}
|
||||||
|
>
|
||||||
|
<RiRefundLine className="size-4" aria-hidden />
|
||||||
|
Reembolso
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isOwnData ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={() => onConfirmDelete?.(item)}
|
||||||
|
disabled={item.readonly || !onConfirmDelete}
|
||||||
|
>
|
||||||
|
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||||
|
Remover
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showInstallmentActions ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{!item.isAnticipated && onAnticipate ? (
|
||||||
|
<DropdownMenuItem onSelect={() => onAnticipate(item)}>
|
||||||
|
<RiTimeLine className="size-4" aria-hidden />
|
||||||
|
Antecipar Parcelas
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{onViewAnticipationHistory ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => onViewAnticipationHistory(item)}
|
||||||
|
>
|
||||||
|
<RiHistoryLine className="size-4" aria-hidden />
|
||||||
|
Histórico de Antecipações
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{item.isAnticipated ? (
|
||||||
|
<DropdownMenuItem disabled>
|
||||||
|
<RiCheckLine className="size-4 text-success" aria-hidden />
|
||||||
|
Parcela Antecipada
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiBankCard2Line,
|
||||||
|
RiCheckboxBlankCircleLine,
|
||||||
|
RiCheckboxCircleFill,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import {
|
||||||
|
CREDIT_CARD_PAYMENT_METHOD,
|
||||||
|
SETTLEABLE_PAYMENT_METHODS,
|
||||||
|
} from "@/features/transactions/lib/constants";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Spinner } from "@/shared/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
import type { TransactionItem } from "../types";
|
||||||
|
|
||||||
|
type TransactionSettlementButtonProps = {
|
||||||
|
item: TransactionItem;
|
||||||
|
isLoading: boolean;
|
||||||
|
onToggle?: (item: TransactionItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransactionSettlementButton({
|
||||||
|
item,
|
||||||
|
isLoading,
|
||||||
|
onToggle,
|
||||||
|
}: TransactionSettlementButtonProps) {
|
||||||
|
const isCreditCard = item.paymentMethod === CREDIT_CARD_PAYMENT_METHOD;
|
||||||
|
const canToggleSettlement = (
|
||||||
|
SETTLEABLE_PAYMENT_METHODS as readonly string[]
|
||||||
|
).includes(item.paymentMethod);
|
||||||
|
|
||||||
|
if (!canToggleSettlement && !isCreditCard) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreditCard) {
|
||||||
|
const invoicePaid = Boolean(item.isSettled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
invoicePaid
|
||||||
|
? "bg-success/10 text-success"
|
||||||
|
: "text-muted-foreground/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{invoicePaid ? (
|
||||||
|
<RiCheckboxCircleFill className="size-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RiBankCard2Line className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">
|
||||||
|
{invoicePaid
|
||||||
|
? "Fatura paga"
|
||||||
|
: "Lançamento de cartão de crédito"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-48 text-center">
|
||||||
|
{invoicePaid
|
||||||
|
? "Fatura paga"
|
||||||
|
: "Lançamentos de cartão de crédito são liquidados ao pagar a fatura"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settled = Boolean(item.isSettled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onToggle?.(item)}
|
||||||
|
disabled={isLoading || item.readonly}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
settled
|
||||||
|
? "bg-success/10 text-success hover:bg-success/20 hover:text-success"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : settled ? (
|
||||||
|
<RiCheckboxCircleFill className="size-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RiCheckboxBlankCircleLine className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">
|
||||||
|
{settled ? "Desfazer pagamento" : "Marcar como pago"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{settled ? "Desfazer pagamento" : "Marcar como pago"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user