mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-10 07:16:01 +00:00
Compare commits
37 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 |
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
113
CHANGELOG.md
113
CHANGELOG.md
@@ -5,6 +5,119 @@ 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
|
## [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.
|
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.
|
||||||
|
|||||||
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).
|
||||||
|
|||||||
50
package.json
50
package.json
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.5.7",
|
"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={
|
||||||
|
<>
|
||||||
|
<AddYieldDialog
|
||||||
|
accountId={account.id}
|
||||||
|
defaultDate={defaultYieldDate}
|
||||||
|
/>
|
||||||
<AdjustBalanceDialog
|
<AdjustBalanceDialog
|
||||||
accountId={account.id}
|
accountId={account.id}
|
||||||
period={selectedPeriod}
|
period={selectedPeriod}
|
||||||
currentBalance={currentBalance}
|
currentBalance={currentBalance}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<AccountDialog
|
<AccountDialog
|
||||||
@@ -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 => ({
|
||||||
|
|||||||
@@ -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,6 +88,7 @@ export default async function Page() {
|
|||||||
Entrar
|
Entrar
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{!signupDisabled && (
|
||||||
<Link href="/signup">
|
<Link href="/signup">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -95,11 +98,13 @@ export default async function Page() {
|
|||||||
Começar
|
Começar
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-primary hover:text-primary"
|
||||||
aria-label="Ajustar saldo"
|
aria-label="Ajustar saldo"
|
||||||
>
|
>
|
||||||
<RiEqualizerLine className="size-4" />
|
<RiEqualizerLine className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Ajustar saldo</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Ajustar saldo</DialogTitle>
|
<DialogTitle>Ajustar saldo</DialogTitle>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
{!signupDisabled && (
|
||||||
<FieldDescription className="pt-1 text-center">
|
<FieldDescription className="pt-1 text-center">
|
||||||
Não tem uma conta?{" "}
|
Não tem uma conta?{" "}
|
||||||
<a href="/signup" className={authLinkClassName}>
|
<a href="/signup" className={authLinkClassName}>
|
||||||
Inscreva-se
|
Inscreva-se
|
||||||
</a>
|
</a>
|
||||||
</FieldDescription>
|
</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,7 +56,10 @@ async function fetchCardsByStatus(
|
|||||||
accounts: AccountSimple[];
|
accounts: AccountSimple[];
|
||||||
logoOptions: string[];
|
logoOptions: string[];
|
||||||
}> {
|
}> {
|
||||||
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
|
const currentPeriod = getCurrentPeriod();
|
||||||
|
const currentInvoiceLabel = formatCurrentInvoiceLabel(currentPeriod);
|
||||||
|
const [cardRows, accountRows, logoOptions, usageRows, invoiceRows] =
|
||||||
|
await Promise.all([
|
||||||
db.query.cards.findMany({
|
db.query.cards.findMany({
|
||||||
orderBy: (table, { desc }) => [desc(table.name)],
|
orderBy: (table, { desc }) => [desc(table.name)],
|
||||||
where: and(
|
where: and(
|
||||||
@@ -67,10 +93,22 @@ async function fetchCardsByStatus(
|
|||||||
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
invoices,
|
||||||
|
and(
|
||||||
|
eq(invoices.userId, transactions.userId),
|
||||||
|
eq(invoices.cardId, transactions.cardId),
|
||||||
|
eq(invoices.period, transactions.period),
|
||||||
|
),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
or(isNull(transactions.isSettled), eq(transactions.isSettled, false)),
|
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
|
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
|
||||||
or(
|
or(
|
||||||
ne(transactions.condition, "Recorrente"),
|
ne(transactions.condition, "Recorrente"),
|
||||||
@@ -79,6 +117,19 @@ async function fetchCardsByStatus(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(transactions.cardId),
|
.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>();
|
||||||
@@ -86,6 +137,13 @@ async function fetchCardsByStatus(
|
|||||||
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,44 +81,62 @@ export function CategoryDetailHeader({
|
|||||||
<TransactionTypeBadge kind={category.type} />
|
<TransactionTypeBadge kind={category.type} />
|
||||||
<span>
|
<span>
|
||||||
{transactionCount}{" "}
|
{transactionCount}{" "}
|
||||||
{transactionCount === 1 ? "lançamento" : "lançamentos"} no{" "}
|
{transactionCount === 1 ? "lançamento" : "lançamentos"} em{" "}
|
||||||
período
|
{currentPeriodLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid w-full gap-4 sm:grid-cols-2 lg:w-auto lg:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<div>
|
<div className="rounded-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>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-6 items-center rounded-sm border px-2 text-xs font-medium",
|
||||||
|
comparisonTone === "positive" &&
|
||||||
|
"border-success/30 bg-success/5 text-success",
|
||||||
|
comparisonTone === "negative" &&
|
||||||
|
"border-destructive/30 bg-destructive/5 text-destructive",
|
||||||
|
comparisonTone === "neutral" &&
|
||||||
|
"border-muted-foreground/30 bg-muted/30 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
<PercentageChangeIndicator
|
<PercentageChangeIndicator
|
||||||
value={percentageChange}
|
value={percentageChange}
|
||||||
label={variationLabel}
|
label={variationLabel}
|
||||||
positiveTrend={category.type === "receita" ? "up" : "down"}
|
positiveTrend={category.type === "receita" ? "up" : "down"}
|
||||||
className="mt-1 gap-1 text-lg font-semibold"
|
className="gap-1 text-lg font-semibold"
|
||||||
iconClassName="size-4"
|
iconClassName="size-4"
|
||||||
|
showFlatIcon
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div className="flex flex-col gap-2 min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center">
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
className="text-2xl leading-none font-medium"
|
className="text-2xl leading-none"
|
||||||
amount={metric.current}
|
amount={metric.current}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground gap-1 flex items-center">
|
||||||
|
<span className="text-muted-foreground/50">vs</span>
|
||||||
|
<MoneyValues
|
||||||
|
className="inline text-xs"
|
||||||
|
amount={metric.previous}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
aria-hidden={!percentChange}
|
||||||
|
className={cn(
|
||||||
|
"w-14 justify-center px-0 text-xs",
|
||||||
|
!percentChange && "invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{percentChange ? (
|
||||||
<PercentageChangeIndicator
|
<PercentageChangeIndicator
|
||||||
trend={trend}
|
trend={trend}
|
||||||
label={percentChange}
|
label={percentChange}
|
||||||
positiveTrend={invertTrend ? "down" : "up"}
|
positiveTrend={invertTrend ? "down" : "up"}
|
||||||
showFlatIcon
|
showFlatIcon={false}
|
||||||
className="gap-1"
|
className="shrink-0 justify-center text-center text-xs tabular-nums"
|
||||||
iconClassName="size-3.5"
|
iconClassName="hidden"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="tabular-nums">0%</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
<MoneyValues
|
|
||||||
className="inline text-xs font-medium text-muted-foreground"
|
|
||||||
amount={metric.previous}
|
|
||||||
/>
|
|
||||||
<span className="ml-1">no mês anterior</span>
|
|
||||||
</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:
|
||||||
|
|||||||
@@ -64,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
|
||||||
@@ -83,6 +83,10 @@ export function InstallmentGroupCard({
|
|||||||
);
|
);
|
||||||
const cardLogoSrc = resolveLogoSrc(group.cartaoLogo);
|
const cardLogoSrc = resolveLogoSrc(group.cartaoLogo);
|
||||||
const cardName = group.cartaoName ?? "Compra parcelada";
|
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 (
|
||||||
<>
|
<>
|
||||||
@@ -153,7 +157,7 @@ export function InstallmentGroupCard({
|
|||||||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-primary/5 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}
|
||||||
@@ -180,8 +184,8 @@ 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 && (
|
||||||
@@ -198,34 +202,28 @@ export function InstallmentGroupCard({
|
|||||||
className="h-2.5 bg-muted"
|
className="h-2.5 bg-muted"
|
||||||
indicatorClassName="bg-success"
|
indicatorClassName="bg-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
{group.untrackedInstallments > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{untrackedLabel}</p>
|
||||||
{/* 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>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Botão para abrir detalhes */}
|
{/* Botão para abrir detalhes */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full gap-1.5"
|
className="relative w-full justify-center gap-1.5"
|
||||||
onClick={() => setIsDetailsOpen(true)}
|
onClick={() => setIsDetailsOpen(true)}
|
||||||
>
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<RiFileList2Line className="size-4" />
|
<RiFileList2Line className="size-4" />
|
||||||
detalhes ({group.pendingInstallments.length} parcelas)
|
detalhes
|
||||||
|
</span>
|
||||||
|
{hasSelection && (
|
||||||
|
<span className="absolute right-2 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
|
{selectedInstallments.size} sel.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
{remainingInstallments === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{endDate ? `Termina em ${endDate}` : null}
|
{endDate ? `Termina em ${endDate}` : null}
|
||||||
{" · Restante "}
|
{" · Quitado"}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{endDate ? `Termina em ${endDate}` : null}
|
||||||
|
{` · ${remainingLabel}: `}
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={remainingAmount}
|
amount={remainingAmount}
|
||||||
className="inline-block font-semibold"
|
className="inline-block font-semibold"
|
||||||
/>{" "}
|
/>{" "}
|
||||||
({remainingInstallments})
|
({remainingInstallments}x)
|
||||||
</p>
|
</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;
|
||||||
@@ -153,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,
|
||||||
@@ -168,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)
|
||||||
@@ -177,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,83 +174,24 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Privacy Warning */}
|
|
||||||
<Alert className="border-none bg-primary/15">
|
|
||||||
<RiAlertLine className="size-4" color="red" />
|
|
||||||
<AlertDescription className="text-sm text-card-foreground">
|
|
||||||
<strong>Aviso de privacidade:</strong> Ao gerar insights, seus dados
|
|
||||||
financeiros serão enviados para o provedor de IA selecionado
|
|
||||||
(Anthropic, OpenAI, Google ou OpenRouter) para processamento.
|
|
||||||
Certifique-se de que você confia no provedor escolhido antes de
|
|
||||||
prosseguir.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* Model Selector */}
|
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
onValueChange={setSelectedModelOverride}
|
onValueChange={setSelectedModelOverride}
|
||||||
|
period={period}
|
||||||
|
onAnalyze={handleAnalyze}
|
||||||
|
userInstructions={userInstructions}
|
||||||
|
onUserInstructionsChange={setUserInstructions}
|
||||||
|
onCancel={() => {
|
||||||
|
setSelectedModelOverride(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
|
isLoadingSavedInsights={isLoadingSavedInsights}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Analyze Button */}
|
{shouldShowAnalysisArea && (
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="min-h-[320px] scroll-mt-6" ref={analysisAreaRef}>
|
||||||
<Button
|
|
||||||
onClick={handleAnalyze}
|
|
||||||
disabled={isPending || isLoadingSavedInsights}
|
|
||||||
className="bg-linear-to-r from-primary via-violet-400 to-cyan-400 dark:from-primary-dark dark:to-cyan-600"
|
|
||||||
>
|
|
||||||
<RiSparklingLine className="mr-2 size-5" aria-hidden="true" />
|
|
||||||
{isPending ? "Analisando..." : "Gerar análise inteligente"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{insights && !error && (
|
|
||||||
<Button
|
|
||||||
onClick={isSaved ? handleDelete : handleSave}
|
|
||||||
disabled={isSaving || isPending || isLoadingSavedInsights}
|
|
||||||
variant={isSaved ? "destructive" : "outline"}
|
|
||||||
>
|
|
||||||
{isSaved ? (
|
|
||||||
<>
|
|
||||||
<RiDeleteBinLine className="mr-2 size-4" />
|
|
||||||
{isSaving ? "Removendo..." : "Remover análise"}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiSaveLine className="mr-2 size-4" />
|
|
||||||
{isSaving ? "Salvando..." : "Salvar análise"}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSaved && savedDate && (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Salva em{" "}
|
|
||||||
{format(new Date(savedDate), "dd/MM/yyyy 'às' HH:mm", {
|
|
||||||
locale: ptBR,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<div className="min-h-[400px]">
|
|
||||||
{(isPending || isLoadingSavedInsights) && <LoadingState />}
|
{(isPending || isLoadingSavedInsights) && <LoadingState />}
|
||||||
{!isPending &&
|
|
||||||
!isLoadingSavedInsights &&
|
|
||||||
!insights &&
|
|
||||||
!error &&
|
|
||||||
!savedInsightsError && (
|
|
||||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
|
||||||
<EmptyState
|
|
||||||
media={<RiSparklingLine className="size-6 text-primary" />}
|
|
||||||
title="Nenhuma análise realizada"
|
|
||||||
description="Clique no botão acima para gerar insights inteligentes sobre seus
|
|
||||||
dados financeiros do mês selecionado."
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{!isPending && !isLoadingSavedInsights && error && (
|
{!isPending && !isLoadingSavedInsights && error && (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
title="Erro ao gerar insights"
|
title="Erro ao gerar insights"
|
||||||
@@ -243,48 +213,135 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
!isLoadingSavedInsights &&
|
!isLoadingSavedInsights &&
|
||||||
insights &&
|
insights &&
|
||||||
!error &&
|
!error &&
|
||||||
!savedInsightsError && <InsightsGrid insights={insights} />}
|
!savedInsightsError && (
|
||||||
|
<InsightsGrid
|
||||||
|
insights={insights}
|
||||||
|
action={
|
||||||
|
<div className="flex flex-col items-start sm:items-end">
|
||||||
|
<Button
|
||||||
|
onClick={isSaved ? handleDelete : handleSave}
|
||||||
|
disabled={isSaving || isPending || isLoadingSavedInsights}
|
||||||
|
variant={isSaved ? "destructive" : "secondary"}
|
||||||
|
>
|
||||||
|
{isSaved ? (
|
||||||
|
<>
|
||||||
|
<RiDeleteBinLine className="mr-2 size-4" />
|
||||||
|
{isSaving ? "Removendo..." : "Remover análise"}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiSaveLine className="mr-2 size-4" />
|
||||||
|
{isSaving ? "Salvando..." : "Salvar análise"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{isSaved && savedDate && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Salva em{" "}
|
||||||
|
{format(new Date(savedDate), "dd/MM/yyyy 'às' HH:mm", {
|
||||||
|
locale: ptBR,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingState() {
|
function LoadingState() {
|
||||||
return (
|
const categories = [
|
||||||
<div className="space-y-6">
|
{
|
||||||
{/* Intro text skeleton */}
|
label: "Comportamentos",
|
||||||
<div className="space-y-2 px-1">
|
icon: RiEyeLine,
|
||||||
<Skeleton className="h-5 w-full max-w-2xl" />
|
color: "text-orange-600 dark:text-orange-400",
|
||||||
<Skeleton className="h-5 w-full max-w-md" />
|
},
|
||||||
</div>
|
{
|
||||||
|
label: "Gatilhos",
|
||||||
|
icon: RiFlashlightLine,
|
||||||
|
color: "text-amber-600 dark:text-amber-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Recomendações",
|
||||||
|
icon: RiLightbulbLine,
|
||||||
|
color: "text-sky-600 dark:text-sky-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Melhorias",
|
||||||
|
icon: RiRocketLine,
|
||||||
|
color: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
{/* Grid de Cards */}
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
<Card className="overflow-hidden border-primary/10 bg-linear-to-br from-primary/10 via-card to-card">
|
||||||
<Card key={i} className="relative overflow-hidden">
|
<CardContent className="px-4 py-1">
|
||||||
<CardHeader>
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex gap-3">
|
||||||
<Skeleton className="size-5 rounded" />
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
<Skeleton className="h-5 w-32" />
|
<RiLoader4Line className="size-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold text-lg tracking-tight">
|
||||||
|
Preparando sua análise
|
||||||
|
</p>
|
||||||
|
<p className="max-w-2xl text-muted-foreground text-sm leading-relaxed">
|
||||||
|
Estamos consolidando os dados do período e organizando os
|
||||||
|
achados em comportamentos, gatilhos, recomendações e
|
||||||
|
melhorias.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{Array.from({ length: 4 }).map((_, j) => (
|
|
||||||
<div
|
|
||||||
key={j}
|
|
||||||
className="flex flex-1 border-b border-dashed py-2.5 gap-2 items-start last:border-0"
|
|
||||||
>
|
|
||||||
<Skeleton className="size-4 shrink-0 rounded" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{categories.map((category) => {
|
||||||
|
const Icon = category.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={category.label}
|
||||||
|
className="relative min-h-[390px] overflow-hidden"
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={`size-5 ${category.color}`} />
|
||||||
|
<span className={`font-semibold ${category.color}`}>
|
||||||
|
{category.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div className="space-y-2" key={index}>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Skeleton className="mt-0.5 size-4 shrink-0 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-3.5 w-full" />
|
||||||
|
<Skeleton className="h-3.5 w-[82%]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(index === 1 || index === 3) && (
|
||||||
|
<Skeleton className="ml-6 h-10 w-[72%] rounded-xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 }
|
|
||||||
> = {
|
|
||||||
openai: {
|
|
||||||
light: "/providers/chatgpt.svg",
|
|
||||||
dark: "/providers/chatgpt_dark_mode.svg",
|
|
||||||
},
|
|
||||||
anthropic: {
|
|
||||||
light: "/providers/claude.svg",
|
|
||||||
},
|
|
||||||
google: {
|
|
||||||
light: "/providers/gemini.svg",
|
|
||||||
},
|
|
||||||
openrouter: {
|
|
||||||
light: "/providers/openrouter_light.svg",
|
|
||||||
dark: "/providers/openrouter_dark.svg",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ModelSelector({
|
function isCustomModelProvider(
|
||||||
value,
|
provider: AIProvider,
|
||||||
onValueChange,
|
): provider is (typeof CUSTOM_MODEL_PROVIDERS)[number] {
|
||||||
disabled,
|
return CUSTOM_MODEL_PROVIDERS.includes(
|
||||||
}: ModelSelectorProps) {
|
provider as (typeof CUSTOM_MODEL_PROVIDERS)[number],
|
||||||
// Estado para armazenar o provider selecionado manualmente
|
|
||||||
const [selectedProvider, setSelectedProvider] = useState<AIProvider | null>(
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
const [customModel, setCustomModel] = useState(value);
|
|
||||||
|
|
||||||
// Sincronizar customModel quando value mudar (importante para pré-carregamento)
|
|
||||||
useEffect(() => {
|
|
||||||
// Se o value tem "/" é um modelo OpenRouter customizado
|
|
||||||
if (value.includes("/")) {
|
|
||||||
setCustomModel(value);
|
|
||||||
setSelectedProvider("openrouter");
|
|
||||||
} else {
|
|
||||||
setCustomModel(value);
|
|
||||||
// Limpar selectedProvider para deixar o useMemo detectar automaticamente
|
|
||||||
setSelectedProvider(null);
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
// Determinar provider atual baseado no modelo selecionado ou provider manual
|
|
||||||
const currentProvider = useMemo(() => {
|
|
||||||
// Se há um provider selecionado manualmente, use-o
|
|
||||||
if (selectedProvider) {
|
|
||||||
return selectedProvider;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se o modelo tem "/" é OpenRouter
|
function getProviderFromValue(value: string): AIProvider | null {
|
||||||
|
if (value.startsWith("openrouter:")) {
|
||||||
|
return "openrouter";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith("ollama:")) {
|
||||||
|
return "ollama";
|
||||||
|
}
|
||||||
|
|
||||||
if (value.includes("/")) {
|
if (value.includes("/")) {
|
||||||
return "openrouter";
|
return "openrouter";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caso contrário, tente detectar baseado no modelo
|
return AVAILABLE_MODELS.find((model) => model.id === value)?.provider ?? null;
|
||||||
const model = AVAILABLE_MODELS.find((m) => m.id === value);
|
}
|
||||||
return model?.provider ?? DEFAULT_PROVIDER;
|
|
||||||
}, [value, selectedProvider]);
|
function stripCustomProviderPrefix(value: string, provider: AIProvider) {
|
||||||
|
if (!isCustomModelProvider(provider)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.startsWith(`${provider}:`)
|
||||||
|
? value.slice(`${provider}:`.length)
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelLabel(modelId: string) {
|
||||||
|
const model = AVAILABLE_MODELS.find((item) => item.id === modelId);
|
||||||
|
if (model) return model.name;
|
||||||
|
|
||||||
|
const provider = getProviderFromValue(modelId);
|
||||||
|
return provider ? stripCustomProviderPrefix(modelId, provider) : modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
period,
|
||||||
|
onAnalyze,
|
||||||
|
userInstructions,
|
||||||
|
onUserInstructionsChange,
|
||||||
|
onCancel,
|
||||||
|
disabled,
|
||||||
|
isLoadingSavedInsights,
|
||||||
|
}: ModelSelectorProps) {
|
||||||
|
const [customModel, setCustomModel] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const detectedProvider = getProviderFromValue(value);
|
||||||
|
if (detectedProvider && isCustomModelProvider(detectedProvider)) {
|
||||||
|
setCustomModel(stripCustomProviderPrefix(value, detectedProvider));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCustomModel(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const currentProvider = getProviderFromValue(value) ?? DEFAULT_PROVIDER;
|
||||||
|
|
||||||
// Agrupar modelos por provider
|
|
||||||
const modelsByProvider = useMemo(() => {
|
const modelsByProvider = useMemo(() => {
|
||||||
const grouped: Record<
|
const grouped: Record<
|
||||||
AIProvider,
|
AIProvider,
|
||||||
@@ -97,7 +99,9 @@ export function ModelSelector({
|
|||||||
openai: [],
|
openai: [],
|
||||||
anthropic: [],
|
anthropic: [],
|
||||||
google: [],
|
google: [],
|
||||||
|
minimax: [],
|
||||||
openrouter: [],
|
openrouter: [],
|
||||||
|
ollama: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
AVAILABLE_MODELS.forEach((model) => {
|
AVAILABLE_MODELS.forEach((model) => {
|
||||||
@@ -107,130 +111,88 @@ export function ModelSelector({
|
|||||||
return grouped;
|
return grouped;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Atualizar provider (seleciona primeiro modelo daquele provider)
|
const providerModels = modelsByProvider[currentProvider];
|
||||||
const handleProviderChange = (newProvider: AIProvider) => {
|
const selectedModelIsKnown = providerModels.some(
|
||||||
setSelectedProvider(newProvider);
|
(model) => model.id === value,
|
||||||
|
);
|
||||||
|
const selectValue = selectedModelIsKnown ? value : CUSTOM_MODEL_VALUE;
|
||||||
|
const isCustomModelActive =
|
||||||
|
isCustomModelProvider(currentProvider) && !selectedModelIsKnown;
|
||||||
|
const selectedModelLabel = getModelLabel(value);
|
||||||
|
const canAnalyze =
|
||||||
|
!disabled &&
|
||||||
|
!isLoadingSavedInsights &&
|
||||||
|
selectedModelLabel.trim().length > 0;
|
||||||
|
|
||||||
|
const handleProviderChange = (newProvider: AIProvider) => {
|
||||||
if (newProvider === "openrouter") {
|
if (newProvider === "openrouter") {
|
||||||
// Para OpenRouter, usa o modelo customizado ou limpa o valor
|
setCustomModel("");
|
||||||
onValueChange(customModel || "");
|
onValueChange("openrouter:");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstModel = modelsByProvider[newProvider][0];
|
const firstModel = modelsByProvider[newProvider][0];
|
||||||
if (firstModel) {
|
if (firstModel) {
|
||||||
onValueChange(firstModel.id);
|
onValueChange(firstModel.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCustomModelProvider(newProvider)) {
|
||||||
|
onValueChange(
|
||||||
|
customModel ? `${newProvider}:${customModel}` : `${newProvider}:`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Atualizar modelo customizado do OpenRouter
|
const handleModelSelect = (modelId: string) => {
|
||||||
|
if (modelId === CUSTOM_MODEL_VALUE) {
|
||||||
|
setCustomModel("");
|
||||||
|
onValueChange(`${currentProvider}:`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onValueChange(modelId);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCustomModelChange = (modelName: string) => {
|
const handleCustomModelChange = (modelName: string) => {
|
||||||
setCustomModel(modelName);
|
setCustomModel(modelName);
|
||||||
onValueChange(modelName);
|
onValueChange(`${currentProvider}:${modelName}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start p-6">
|
<section className="space-y-4">
|
||||||
{/* Descrição */}
|
<div className="grid items-stretch gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Definir modelo de análise</h3>
|
<ProviderSelectionCard
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
currentProvider={currentProvider}
|
||||||
Escolha o provedor de IA e o modelo específico que será utilizado para
|
disabled={disabled}
|
||||||
gerar insights sobre seus dados financeiros. <br />
|
onProviderChange={handleProviderChange}
|
||||||
Diferentes modelos podem oferecer perspectivas variadas na análise.
|
/>
|
||||||
</p>
|
|
||||||
|
<ModelSelectionCard
|
||||||
|
currentProvider={currentProvider}
|
||||||
|
providerModels={providerModels}
|
||||||
|
selectValue={selectValue}
|
||||||
|
customModel={customModel}
|
||||||
|
isCustomModelActive={isCustomModelActive}
|
||||||
|
canUseCustomModel={isCustomModelProvider(currentProvider)}
|
||||||
|
canAnalyze={canAnalyze}
|
||||||
|
disabled={disabled}
|
||||||
|
onModelSelect={handleModelSelect}
|
||||||
|
onCustomModelChange={handleCustomModelChange}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onAnalyze={onAnalyze}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Seletor */}
|
<AnalysisSummaryCard
|
||||||
<div className="flex flex-col gap-4 min-w-xs">
|
period={period}
|
||||||
<RadioGroup
|
currentProvider={currentProvider}
|
||||||
value={currentProvider}
|
selectedModelLabel={selectedModelLabel}
|
||||||
onValueChange={(v) => handleProviderChange(v as AIProvider)}
|
userInstructions={userInstructions}
|
||||||
disabled={disabled}
|
onUserInstructionsChange={onUserInstructionsChange}
|
||||||
className="gap-3"
|
|
||||||
>
|
|
||||||
{(Object.keys(PROVIDERS) as AIProvider[]).map((providerId) => {
|
|
||||||
const provider = PROVIDERS[providerId];
|
|
||||||
const iconPaths = PROVIDER_ICON_PATHS[providerId];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={providerId} className="flex items-center gap-3">
|
|
||||||
<RadioGroupItem
|
|
||||||
value={providerId}
|
|
||||||
id={`provider-${providerId}`}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
/>
|
||||||
<div className="size-6 relative">
|
|
||||||
<Image
|
|
||||||
src={iconPaths.light}
|
|
||||||
alt={provider.name}
|
|
||||||
width={22}
|
|
||||||
height={22}
|
|
||||||
className={iconPaths.dark ? "dark:hidden" : ""}
|
|
||||||
/>
|
|
||||||
{iconPaths.dark && (
|
|
||||||
<Image
|
|
||||||
src={iconPaths.dark}
|
|
||||||
alt={provider.name}
|
|
||||||
width={22}
|
|
||||||
height={22}
|
|
||||||
className="hidden dark:block"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Label
|
</section>
|
||||||
htmlFor={`provider-${providerId}`}
|
|
||||||
className="text-sm font-medium cursor-pointer flex-1"
|
|
||||||
>
|
|
||||||
{provider.name}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</RadioGroup>
|
|
||||||
|
|
||||||
{/* Seletor de Modelo */}
|
|
||||||
{currentProvider === "openrouter" ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
value={customModel}
|
|
||||||
onChange={(e) => handleCustomModelChange(e.target.value)}
|
|
||||||
placeholder="Ex: anthropic/claude-3.5-sonnet"
|
|
||||||
disabled={disabled}
|
|
||||||
className="border-none bg-neutral-200 dark:bg-neutral-800"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href="https://openrouter.ai/models"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<RiExternalLinkLine className="h-3 w-3" />
|
|
||||||
Ver modelos disponíveis no OpenRouter
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
value={value}
|
|
||||||
onValueChange={onValueChange}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
disabled={disabled}
|
|
||||||
className="border-none bg-neutral-200 dark:bg-neutral-800"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Selecione um modelo" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{modelsByProvider[currentProvider].map((model) => (
|
|
||||||
<SelectItem key={model.id} value={model.id}>
|
|
||||||
{model.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
{!signupDisabled && (
|
||||||
<Link href="/signup" onClick={() => setOpen(false)}>
|
<Link href="/signup" onClick={() => setOpen(false)}>
|
||||||
<Button className="w-full gap-2">
|
<Button className="w-full gap-2">
|
||||||
Começar
|
Começar
|
||||||
<RiArrowRightSLine size={16} />
|
<RiArrowRightSLine size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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,8 +132,6 @@ export function CategoryReportFilters({
|
|||||||
const selectedText =
|
const selectedText =
|
||||||
selectedCategories.length === 0
|
selectedCategories.length === 0
|
||||||
? "Categoria"
|
? "Categoria"
|
||||||
: selectedCategories.length === categories.length
|
|
||||||
? "Todas"
|
|
||||||
: selectedCategories.length === 1
|
: selectedCategories.length === 1
|
||||||
? selectedCategories[0].name
|
? selectedCategories[0].name
|
||||||
: `${selectedCategories.length} selecionadas`;
|
: `${selectedCategories.length} selecionadas`;
|
||||||
@@ -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}
|
|
||||||
>
|
|
||||||
Todas
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs flex-1"
|
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
>
|
>
|
||||||
Limpar
|
Limpar seleção
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* 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,7 +101,11 @@ export async function getEligibleInstallmentsAction(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({
|
const eligibleInstallments: EligibleInstallment[] = rows
|
||||||
|
.filter(
|
||||||
|
(row) => comparePeriods(row.period, validatedAnticipationPeriod) > 0,
|
||||||
|
)
|
||||||
|
.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
amount: row.amount,
|
amount: row.amount,
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
|
||||||
|
|
||||||
|
if (payerIdsByRow.some((id) => !id)) {
|
||||||
|
return { success: false, error: "Pessoa obrigatória." };
|
||||||
|
}
|
||||||
|
|
||||||
// Valida ownership
|
// Valida ownership
|
||||||
const [payerOk, accountOk, cardOk] = await Promise.all([
|
const [ownedPayerIds, ownedCategoryIds, accountOk, cardOk] =
|
||||||
validatePayerOwnership(userId, payerId),
|
await Promise.all([
|
||||||
|
fetchOwnedPayerIds(userId, payerIdsByRow),
|
||||||
|
fetchOwnedCategoryIds(
|
||||||
|
userId,
|
||||||
|
rows.map((row) => row.categoryId),
|
||||||
|
),
|
||||||
validateContaOwnership(userId, accountId),
|
validateContaOwnership(userId, accountId),
|
||||||
validateCartaoOwnership(userId, cardId),
|
validateCartaoOwnership(userId, cardId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!payerOk) return { success: false, error: "Pessoa não encontrada." };
|
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 (!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,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,34 +54,118 @@ 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 />
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState onRetry={() => void refetch()} />
|
||||||
|
) : anticipations.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<div className="min-w-0 space-y-3">
|
||||||
|
<p className="text-left text-muted-foreground text-primary">
|
||||||
|
{anticipationCountLabel}
|
||||||
|
</p>
|
||||||
|
{anticipations.map((anticipation) => (
|
||||||
|
<AnticipationCard
|
||||||
|
key={anticipation.id}
|
||||||
|
anticipation={anticipation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
{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>
|
||||||
|
</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" />
|
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
|
||||||
<span className="ml-2 text-sm text-muted-foreground">
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
Carregando histórico...
|
Carregando histórico...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : isError ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ onRetry }: { onRetry: () => void }) {
|
||||||
|
return (
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
@@ -93,12 +180,16 @@ export function AnticipationHistoryDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
onClick={() => void refetch()}
|
onClick={onRetry}
|
||||||
>
|
>
|
||||||
Tentar novamente
|
Tentar novamente
|
||||||
</Button>
|
</Button>
|
||||||
</Empty>
|
</Empty>
|
||||||
) : anticipations.length === 0 ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
@@ -106,32 +197,9 @@ export function AnticipationHistoryDialog({
|
|||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
|
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription>
|
||||||
As antecipações realizadas para esta compra parcelada
|
As antecipações realizadas para esta compra parcelada aparecerão aqui.
|
||||||
aparecerão aqui.
|
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
) : (
|
|
||||||
anticipations.map((anticipation) => (
|
|
||||||
<AnticipationCard
|
|
||||||
key={anticipation.id}
|
|
||||||
anticipation={anticipation}
|
|
||||||
onViewLancamento={onViewLancamento}
|
|
||||||
onCanceled={handleCanceled}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isLoading && anticipations.length > 0 && (
|
|
||||||
<div className="border-t pt-4 text-center text-sm text-muted-foreground">
|
|
||||||
{anticipations.length}{" "}
|
|
||||||
{anticipations.length === 1
|
|
||||||
? "antecipação encontrada"
|
|
||||||
: "antecipações encontradas"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
%
|
{initial}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>{participant.firstName}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
);
|
||||||
<p className="ml-1 text-xs text-muted-foreground">
|
})}
|
||||||
{formatCurrency(safeToNumber(value))}
|
{summary.remainingCount > 0 ? (
|
||||||
</p>
|
<span>+{summary.remainingCount}</span>
|
||||||
|
) : null}
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>{summary.totalLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,77 +67,42 @@ 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
|
<div className="space-y-1">
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
|
|
||||||
formState.isSplit
|
|
||||||
? "border-primary/20 bg-primary/5"
|
|
||||||
: "border-border bg-transparent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-foreground">Dividir lançamento</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Atribuir parte do valor a outra pessoa.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{formState.isSplit ? (
|
|
||||||
<SplitModeToggle mode={splitMode} onModeChange={setSplitMode} />
|
|
||||||
) : null}
|
|
||||||
<CheckboxPrimitive.Root
|
|
||||||
checked={formState.isSplit}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onFieldChange("isSplit", Boolean(checked))
|
|
||||||
}
|
|
||||||
aria-label="Dividir lançamento"
|
|
||||||
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]",
|
|
||||||
formState.isSplit
|
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
|
||||||
: "border-input dark:bg-input/30",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
|
||||||
<RiSliceFill className="size-3" />
|
|
||||||
</CheckboxPrimitive.Indicator>
|
|
||||||
</CheckboxPrimitive.Root>
|
|
||||||
</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>
|
<Label htmlFor="payer">Pessoa</Label>
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select
|
<Select
|
||||||
value={formState.payerId ?? ""}
|
value={formState.payerId ?? ""}
|
||||||
onValueChange={(value) => onFieldChange("payerId", value)}
|
onValueChange={(value) => onFieldChange("payerId", value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger id="payer" className="w-full">
|
||||||
id="payer"
|
|
||||||
className={formState.isSplit ? "min-w-0 flex-1" : "w-full"}
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Selecione">
|
<SelectValue placeholder="Selecione">
|
||||||
{formState.payerId &&
|
{formState.payerId &&
|
||||||
(() => {
|
(() => {
|
||||||
@@ -232,70 +129,64 @@ export function PayerSection({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{formState.isSplit ? (
|
|
||||||
<SplitAmountField
|
|
||||||
mode={splitMode}
|
|
||||||
value={formState.primarySplitAmount}
|
|
||||||
totalAmount={totalAmount}
|
|
||||||
onAmountChange={handlePrimaryAmountChange}
|
|
||||||
ariaLabel="Porcentagem da pessoa"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-3 py-2.5 transition-colors",
|
||||||
|
formState.isSplit
|
||||||
|
? "border-primary/20 bg-primary/5"
|
||||||
|
: "border-border bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<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>
|
||||||
|
<SplitSummaryContent summary={splitSummary} />
|
||||||
|
</button>
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
checked={formState.isSplit}
|
||||||
|
onCheckedChange={(checked) => handleSplitToggle(Boolean(checked))}
|
||||||
|
aria-label="Dividir lançamento"
|
||||||
|
className={cn(
|
||||||
|
"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
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-input dark:bg-input/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
||||||
|
<RiSliceFill className="size-3" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
</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)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
Editar divisão
|
||||||
id="secondaryPayer"
|
</Button>
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -112,6 +112,7 @@ export function TransactionDialog({
|
|||||||
defaultPayerId,
|
defaultPayerId,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
{
|
{
|
||||||
|
defaultAccountId,
|
||||||
defaultCardId,
|
defaultCardId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
defaultPurchaseDate,
|
defaultPurchaseDate,
|
||||||
@@ -151,6 +152,7 @@ export function TransactionDialog({
|
|||||||
transaction,
|
transaction,
|
||||||
defaultPayerId,
|
defaultPayerId,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
|
defaultAccountId,
|
||||||
defaultCardId,
|
defaultCardId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
defaultPurchaseDate,
|
defaultPurchaseDate,
|
||||||
@@ -162,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) =>
|
||||||
@@ -255,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.";
|
||||||
@@ -272,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.";
|
||||||
@@ -305,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
|
||||||
@@ -327,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)
|
||||||
@@ -559,7 +588,7 @@ export function TransactionDialog({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1 pb-1"
|
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">
|
||||||
@@ -588,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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -661,7 +690,10 @@ export function TransactionDialog({
|
|||||||
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">
|
||||||
@@ -697,6 +729,16 @@ 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 ? (
|
||||||
|
|||||||
@@ -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,7 +74,25 @@ 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(
|
||||||
|
() =>
|
||||||
|
new Map(categoryOptions.map((option) => [option.value, option.group])),
|
||||||
|
[categoryOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCategoryCompatible = useCallback(
|
||||||
|
(
|
||||||
|
categoryId: string | null,
|
||||||
|
transactionType: ReviewRow["transactionType"],
|
||||||
|
) =>
|
||||||
|
!categoryId ||
|
||||||
|
categoryGroupById.get(categoryId) ===
|
||||||
|
categoryGroupByTransactionType[transactionType],
|
||||||
|
[categoryGroupById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleParsed = useCallback(
|
||||||
|
async (stmt: ImportStatement) => {
|
||||||
setStatement(stmt);
|
setStatement(stmt);
|
||||||
setIsChecking(true);
|
setIsChecking(true);
|
||||||
|
|
||||||
@@ -84,18 +107,30 @@ export function ImportPage({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
setRows(
|
setRows(
|
||||||
stmt.transactions.map((t) => ({
|
stmt.transactions.map((t) => {
|
||||||
|
const mappedCategoryId =
|
||||||
|
categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
...t,
|
...t,
|
||||||
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
|
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
|
||||||
selected: t.externalId ? !duplicates.has(t.externalId) : true,
|
selected: t.externalId ? !duplicates.has(t.externalId) : true,
|
||||||
categoryId:
|
payerId,
|
||||||
categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
|
categoryId: isCategoryCompatible(
|
||||||
})),
|
mappedCategoryId,
|
||||||
|
t.transactionType,
|
||||||
|
)
|
||||||
|
? mappedCategoryId
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsChecking(false);
|
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">
|
||||||
|
<CardTitle className="text-base leading-none">
|
||||||
{anticipation.installmentCount}{" "}
|
{anticipation.installmentCount}{" "}
|
||||||
{anticipation.installmentCount === 1
|
{anticipation.installmentCount === 1
|
||||||
? "parcela antecipada"
|
? "parcela antecipada"
|
||||||
: "parcelas antecipadas"}
|
: "parcelas antecipadas"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
|
||||||
<RiCalendarCheckLine className="mr-1 inline size-3.5" />
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{formatDate(anticipation.anticipationDate)}
|
<RiCalendarCheckLine className="size-3 shrink-0" />
|
||||||
</CardDescription>
|
<span>{formatDate(anticipation.anticipationDate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">
|
</div>
|
||||||
|
|
||||||
|
<Badge variant="secondary" className="shrink-0 rounded-full px-3">
|
||||||
{displayPeriod(anticipation.anticipationPeriod)}
|
{displayPeriod(anticipation.anticipationPeriod)}
|
||||||
</Badge>
|
</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>
|
||||||
</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"
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<dt className="text-muted-foreground">
|
|
||||||
{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>
|
|
||||||
<dt className="text-muted-foreground">Status do Lançamento</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<Badge variant={isSettled ? "success" : "outline"}>
|
|
||||||
{isSettled ? "Pago" : "Pendente"}
|
{isSettled ? "Pago" : "Pendente"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</dd>
|
</DetailItem>
|
||||||
</div>
|
|
||||||
|
|
||||||
{anticipation.payer && (
|
{anticipation.payer ? (
|
||||||
<div>
|
<DetailItem label="Pessoa">{anticipation.payer.name}</DetailItem>
|
||||||
<dt className="text-muted-foreground">Pessoa</dt>
|
) : (
|
||||||
<dd className="mt-1 font-medium">{anticipation.payer.name}</dd>
|
<div />
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
RiAttachment2,
|
RiAttachment2,
|
||||||
RiBankCard2Line,
|
|
||||||
RiChat1Line,
|
RiChat1Line,
|
||||||
RiCheckboxBlankCircleLine,
|
|
||||||
RiCheckboxCircleFill,
|
|
||||||
RiCheckLine,
|
|
||||||
RiDeleteBin5Line,
|
|
||||||
RiFileCopyLine,
|
|
||||||
RiFileList2Line,
|
|
||||||
RiGroupLine,
|
RiGroupLine,
|
||||||
RiHistoryLine,
|
|
||||||
RiMoreFill,
|
|
||||||
RiPencilLine,
|
|
||||||
RiRefundLine,
|
|
||||||
RiTimeLine,
|
RiTimeLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { DEFAULT_TRANSACTIONS_COLUMN_ORDER } from "@/features/transactions/lib/column-order";
|
import { DEFAULT_TRANSACTIONS_COLUMN_ORDER } from "@/features/transactions/lib/column-order";
|
||||||
import {
|
|
||||||
CREDIT_CARD_PAYMENT_METHOD,
|
|
||||||
SETTLEABLE_PAYMENT_METHODS,
|
|
||||||
} from "@/features/transactions/lib/constants";
|
|
||||||
import {
|
import {
|
||||||
CategoryIconBadge,
|
CategoryIconBadge,
|
||||||
EstablishmentLogo,
|
EstablishmentLogo,
|
||||||
@@ -35,28 +20,20 @@ import {
|
|||||||
AvatarImage,
|
AvatarImage,
|
||||||
} from "@/shared/components/ui/avatar";
|
} from "@/shared/components/ui/avatar";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/shared/components/ui/dropdown-menu";
|
|
||||||
import { Spinner } from "@/shared/components/ui/spinner";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/shared/components/ui/tooltip";
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
|
||||||
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 { formatDate } from "@/shared/utils/date";
|
import { formatDate } from "@/shared/utils/date";
|
||||||
import { getConditionIcon, getPaymentMethodIcon } from "@/shared/utils/icons";
|
import { getConditionIcon, getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import type { TransactionItem } from "../types";
|
import type { TransactionItem } from "../types";
|
||||||
|
import { TransactionActionsMenu } from "./transaction-actions-menu";
|
||||||
|
import { TransactionSettlementButton } from "./transaction-settlement-button";
|
||||||
|
|
||||||
type BuildColumnsArgs = {
|
type BuildColumnsArgs = {
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
@@ -426,7 +403,7 @@ function buildColumns({
|
|||||||
const initial = displayName.charAt(0).toUpperCase() || "?";
|
const initial = displayName.charAt(0).toUpperCase() || "?";
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<Avatar className="size-7">
|
<Avatar className="size-8">
|
||||||
<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}
|
||||||
@@ -477,15 +454,21 @@ function buildColumns({
|
|||||||
const content = (
|
const content = (
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{logoSrc && (
|
{logoSrc && (
|
||||||
<Image
|
<Avatar className="size-8">
|
||||||
src={logoSrc}
|
<AvatarImage src={logoSrc} alt={`Logo de ${label}`} />
|
||||||
alt={`Logo de ${label}`}
|
<AvatarFallback className="text-xs font-medium uppercase">
|
||||||
width={30}
|
{label}
|
||||||
height={30}
|
</AvatarFallback>
|
||||||
className="rounded-full"
|
</Avatar>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{label}</span>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"truncate underline-offset-2",
|
||||||
|
isOwnData && href && "group-hover:underline",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -503,7 +486,7 @@ function buildColumns({
|
|||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Link href={href} className="hover:underline">
|
<Link href={href} className="group">
|
||||||
{content}
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -545,195 +528,23 @@ function buildColumns({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(() => {
|
<TransactionSettlementButton
|
||||||
const paymentMethod = row.original.paymentMethod;
|
item={row.original}
|
||||||
const isCreditCard = paymentMethod === CREDIT_CARD_PAYMENT_METHOD;
|
isLoading={isSettlementLoading(row.original.id)}
|
||||||
const canToggleSettlement = (
|
onToggle={handleToggleSettlement}
|
||||||
SETTLEABLE_PAYMENT_METHODS as readonly string[]
|
/>
|
||||||
).includes(paymentMethod);
|
<TransactionActionsMenu
|
||||||
|
item={row.original}
|
||||||
if (!canToggleSettlement && !isCreditCard) return null;
|
currentUserId={currentUserId}
|
||||||
|
onEdit={handleEdit}
|
||||||
if (isCreditCard) {
|
onCopy={handleCopy}
|
||||||
const invoicePaid = Boolean(row.original.isSettled);
|
onImport={handleImport}
|
||||||
return (
|
onConfirmDelete={handleConfirmDelete}
|
||||||
<Tooltip>
|
onViewDetails={handleViewDetails}
|
||||||
<TooltipTrigger asChild>
|
onRefund={handleRefund}
|
||||||
<span className="inline-flex">
|
onAnticipate={handleAnticipate}
|
||||||
<Button
|
onViewAnticipationHistory={handleViewAnticipationHistory}
|
||||||
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" />
|
|
||||||
) : (
|
|
||||||
<RiBankCard2Line className="size-4" />
|
|
||||||
)}
|
|
||||||
<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 readOnly = row.original.readonly;
|
|
||||||
const loading = isSettlementLoading(row.original.id);
|
|
||||||
const settled = Boolean(row.original.isSettled);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => handleToggleSettlement(row.original)}
|
|
||||||
disabled={loading || readOnly}
|
|
||||||
className={cn(
|
|
||||||
"transition-colors",
|
|
||||||
settled
|
|
||||||
? "bg-success/10 text-success hover:bg-success/20 hover:text-success"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Spinner className="size-4" />
|
|
||||||
) : settled ? (
|
|
||||||
<RiCheckboxCircleFill className="size-4" />
|
|
||||||
) : (
|
|
||||||
<RiCheckboxBlankCircleLine className="size-4" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">
|
|
||||||
{settled ? "Desfazer pagamento" : "Marcar como pago"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">
|
|
||||||
{settled ? "Desfazer pagamento" : "Marcar como pago"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon-sm">
|
|
||||||
<RiMoreFill className="size-4" />
|
|
||||||
<span className="sr-only">Abrir ações do lançamento</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-44">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => handleViewDetails(row.original)}
|
|
||||||
>
|
|
||||||
<RiFileList2Line className="size-4" />
|
|
||||||
Detalhes
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{row.original.userId === currentUserId && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => handleEdit(row.original)}
|
|
||||||
disabled={row.original.readonly}
|
|
||||||
>
|
|
||||||
<RiPencilLine className="size-4" />
|
|
||||||
Editar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{row.original.categoriaName !== "Pagamentos" &&
|
|
||||||
row.original.userId === currentUserId && (
|
|
||||||
<DropdownMenuItem onSelect={() => handleCopy(row.original)}>
|
|
||||||
<RiFileCopyLine className="size-4" />
|
|
||||||
Copiar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{row.original.categoriaName !== "Pagamentos" &&
|
|
||||||
row.original.userId !== currentUserId && (
|
|
||||||
<DropdownMenuItem onSelect={() => handleImport(row.original)}>
|
|
||||||
<RiFileCopyLine className="size-4" />
|
|
||||||
Importar para Minha Conta
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{(() => {
|
|
||||||
const item = row.original;
|
|
||||||
const canRefund =
|
|
||||||
item.userId === currentUserId &&
|
|
||||||
item.transactionType === "Despesa" &&
|
|
||||||
item.condition === "À vista" &&
|
|
||||||
!item.splitGroupId &&
|
|
||||||
!item.readonly &&
|
|
||||||
!item.note?.startsWith(REFUND_NOTE_PREFIX);
|
|
||||||
|
|
||||||
if (!canRefund) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem onSelect={() => handleRefund(item)}>
|
|
||||||
<RiRefundLine className="size-4" />
|
|
||||||
Reembolso
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{row.original.userId === currentUserId && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onSelect={() => handleConfirmDelete(row.original)}
|
|
||||||
disabled={row.original.readonly}
|
|
||||||
>
|
|
||||||
<RiDeleteBin5Line className="size-4" />
|
|
||||||
Remover
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{row.original.userId === currentUserId &&
|
|
||||||
row.original.condition === "Parcelado" &&
|
|
||||||
row.original.seriesId && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{!row.original.isAnticipated && onAnticipate && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => handleAnticipate(row.original)}
|
|
||||||
>
|
|
||||||
<RiTimeLine className="size-4" />
|
|
||||||
Antecipar Parcelas
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onViewAnticipationHistory && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() =>
|
|
||||||
handleViewAnticipationHistory(row.original)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RiHistoryLine className="size-4" />
|
|
||||||
Histórico de Antecipações
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{row.original.isAnticipated && (
|
|
||||||
<DropdownMenuItem disabled>
|
|
||||||
<RiCheckLine className="size-4 text-success" />
|
|
||||||
Parcela Antecipada
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,12 +23,17 @@ import {
|
|||||||
import {
|
import {
|
||||||
AMOUNT_MAX_PARAM,
|
AMOUNT_MAX_PARAM,
|
||||||
AMOUNT_MIN_PARAM,
|
AMOUNT_MIN_PARAM,
|
||||||
|
DATE_END_PARAM,
|
||||||
|
DATE_START_PARAM,
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
SETTLED_FILTER_VALUES,
|
SETTLED_FILTER_VALUES,
|
||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
TRANSACTION_TYPES,
|
TRANSACTION_TYPES,
|
||||||
} from "@/features/transactions/lib/constants";
|
} from "@/features/transactions/lib/constants";
|
||||||
import { parsePositiveAmount } from "@/features/transactions/lib/page-helpers";
|
import {
|
||||||
|
parseDateFilterParam,
|
||||||
|
parsePositiveAmount,
|
||||||
|
} from "@/features/transactions/lib/page-helpers";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +44,7 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "@/shared/components/ui/command";
|
} from "@/shared/components/ui/command";
|
||||||
|
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
@@ -60,7 +66,13 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "@/shared/components/ui/select";
|
} from "@/shared/components/ui/select";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
|
import { Spinner } from "@/shared/components/ui/spinner";
|
||||||
import { Switch } from "@/shared/components/ui/switch";
|
import { Switch } from "@/shared/components/ui/switch";
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@/shared/components/ui/toggle-group";
|
||||||
import { slugify } from "@/shared/utils/string";
|
import { slugify } from "@/shared/utils/string";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import {
|
import {
|
||||||
@@ -83,6 +95,9 @@ const normalizeAmountParam = (raw: string): string | null => {
|
|||||||
return parsed === null ? null : parsed.toString();
|
return parsed === null ? null : parsed.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeDateParam = (raw: string): string | null =>
|
||||||
|
parseDateFilterParam(raw.trim());
|
||||||
|
|
||||||
function useDebouncedAmountFilter(
|
function useDebouncedAmountFilter(
|
||||||
param: string,
|
param: string,
|
||||||
searchParams: URLSearchParams | ReadonlyURLSearchParams,
|
searchParams: URLSearchParams | ReadonlyURLSearchParams,
|
||||||
@@ -135,6 +150,7 @@ function FilterSelect({
|
|||||||
value === FILTER_EMPTY_VALUE
|
value === FILTER_EMPTY_VALUE
|
||||||
? placeholder
|
? placeholder
|
||||||
: (current?.label ?? placeholder);
|
: (current?.label ?? placeholder);
|
||||||
|
const hasSelection = value !== FILTER_EMPTY_VALUE && Boolean(current);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
@@ -148,8 +164,13 @@ function FilterSelect({
|
|||||||
className={cn("text-sm border-dashed", widthClass)}
|
className={cn("text-sm border-dashed", widthClass)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span
|
||||||
{value !== FILTER_EMPTY_VALUE && current && renderContent
|
className={cn(
|
||||||
|
"truncate",
|
||||||
|
hasSelection ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{current && renderContent
|
||||||
? renderContent(current.label)
|
? renderContent(current.label)
|
||||||
: displayLabel}
|
: displayLabel}
|
||||||
</span>
|
</span>
|
||||||
@@ -173,6 +194,16 @@ type MultiOption = {
|
|||||||
render?: ReactNode;
|
render?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCategoryFilterGroup = (type?: string | null) => {
|
||||||
|
if (type === "receita") {
|
||||||
|
return "Receitas";
|
||||||
|
}
|
||||||
|
if (type === "despesa") {
|
||||||
|
return "Despesas";
|
||||||
|
}
|
||||||
|
return "Outras";
|
||||||
|
};
|
||||||
|
|
||||||
interface MultiSelectFilterProps {
|
interface MultiSelectFilterProps {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
options: MultiOption[];
|
options: MultiOption[];
|
||||||
@@ -255,15 +286,25 @@ function MultiSelectFilter({
|
|||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn(
|
className={cn(
|
||||||
"justify-between text-sm border-dashed font-normal",
|
"justify-between text-sm border-dashed font-normal shadow-none",
|
||||||
widthClass,
|
widthClass,
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<span className="truncate flex items-center gap-2">
|
<span
|
||||||
|
className={cn(
|
||||||
|
"truncate flex items-center gap-2",
|
||||||
|
selectedOptions.length > 0
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
</span>
|
</span>
|
||||||
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
|
<RiExpandUpDownLine
|
||||||
|
className="ml-2 size-4 shrink-0 opacity-50"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="start" className="w-[260px] p-0">
|
<PopoverContent align="start" className="w-[260px] p-0">
|
||||||
@@ -304,7 +345,10 @@ function MultiSelectFilter({
|
|||||||
{option.render ?? option.label}
|
{option.render ?? option.label}
|
||||||
</span>
|
</span>
|
||||||
{isSelected ? (
|
{isSelected ? (
|
||||||
<RiCheckLine className="ml-auto size-4 shrink-0" />
|
<RiCheckLine
|
||||||
|
className="ml-auto size-4 shrink-0"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
@@ -392,6 +436,13 @@ export function TransactionsFilters({
|
|||||||
[searchParams, pathname, router],
|
[searchParams, pathname, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDateFilterChange = useCallback(
|
||||||
|
(key: string, value: string) => {
|
||||||
|
handleFilterChange(key, normalizeDateParam(value));
|
||||||
|
},
|
||||||
|
[handleFilterChange],
|
||||||
|
);
|
||||||
|
|
||||||
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
|
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
|
||||||
const currentSearchParam = searchParams.get("q") ?? "";
|
const currentSearchParam = searchParams.get("q") ?? "";
|
||||||
|
|
||||||
@@ -484,6 +535,7 @@ export function TransactionsFilters({
|
|||||||
categoryOptions.map((option) => ({
|
categoryOptions.map((option) => ({
|
||||||
value: option.slug,
|
value: option.slug,
|
||||||
label: option.label,
|
label: option.label,
|
||||||
|
group: getCategoryFilterGroup(option.type),
|
||||||
render: (
|
render: (
|
||||||
<CategorySelectContent label={option.label} icon={option.icon} />
|
<CategorySelectContent label={option.label} icon={option.icon} />
|
||||||
),
|
),
|
||||||
@@ -509,27 +561,49 @@ export function TransactionsFilters({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const hasDateRangeFilter =
|
||||||
const hasActiveFilters =
|
Boolean(searchParams.get(DATE_START_PARAM)) ||
|
||||||
searchParams.get("type") ||
|
Boolean(searchParams.get(DATE_END_PARAM));
|
||||||
searchParams.getAll("condition").length > 0 ||
|
const hasAmountFilter =
|
||||||
searchParams.getAll("payment").length > 0 ||
|
Boolean(searchParams.get(AMOUNT_MIN_PARAM)) ||
|
||||||
searchParams.getAll("payer").length > 0 ||
|
Boolean(searchParams.get(AMOUNT_MAX_PARAM));
|
||||||
searchParams.getAll("category").length > 0 ||
|
const activeFilterCount = [
|
||||||
searchParams.getAll("accountCard").length > 0 ||
|
Boolean(searchParams.get("type")),
|
||||||
searchParams.get("settled") ||
|
searchParams.getAll("condition").length > 0,
|
||||||
searchParams.get("hasAttachment") ||
|
searchParams.getAll("payment").length > 0,
|
||||||
searchParams.get("isDivided") ||
|
searchParams.getAll("payer").length > 0,
|
||||||
searchParams.get(AMOUNT_MIN_PARAM) ||
|
searchParams.getAll("category").length > 0,
|
||||||
searchParams.get(AMOUNT_MAX_PARAM);
|
searchParams.getAll("accountCard").length > 0,
|
||||||
|
Boolean(searchParams.get("settled")),
|
||||||
|
Boolean(searchParams.get("hasAttachment")),
|
||||||
|
Boolean(searchParams.get("isDivided")),
|
||||||
|
hasAmountFilter,
|
||||||
|
hasDateRangeFilter,
|
||||||
|
].filter(Boolean).length;
|
||||||
|
const hasActiveFilters = activeFilterCount > 0;
|
||||||
|
const settledFilterValue = searchParams.get("settled") ?? FILTER_EMPTY_VALUE;
|
||||||
|
|
||||||
const handleResetFilters = () => {
|
const handleResetFilters = () => {
|
||||||
handleReset();
|
handleReset();
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetDateRange = () => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
nextParams.delete(DATE_START_PARAM);
|
||||||
|
nextParams.delete(DATE_END_PARAM);
|
||||||
|
nextParams.delete("page");
|
||||||
|
startTransition(() => {
|
||||||
|
const target = nextParams.toString()
|
||||||
|
? `${pathname}?${nextParams.toString()}`
|
||||||
|
: pathname;
|
||||||
|
router.replace(target, { scroll: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
aria-busy={isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-2 md:flex-row md:flex-wrap md:items-center",
|
"flex flex-col gap-2 md:flex-row md:flex-wrap md:items-center",
|
||||||
className,
|
className,
|
||||||
@@ -553,7 +627,7 @@ export function TransactionsFilters({
|
|||||||
aria-label="Limpar busca"
|
aria-label="Limpar busca"
|
||||||
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<RiCloseLine className="size-4" />
|
<RiCloseLine className="size-4" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -575,12 +649,19 @@ export function TransactionsFilters({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
|
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
|
||||||
aria-label="Abrir filtros"
|
aria-label={isPending ? "Aplicando filtros" : "Abrir filtros"}
|
||||||
>
|
>
|
||||||
<RiFilterLine className="size-4" />
|
{isPending ? (
|
||||||
Filtros
|
<Spinner className="size-4" role="presentation" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RiFilterLine className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
{isPending ? "Aplicando..." : "Filtros"}
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<span className="absolute -top-1 -right-1 size-3 rounded-full bg-primary" />
|
<span
|
||||||
|
className="absolute -top-1 -right-1 size-3 rounded-full bg-primary"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
@@ -594,7 +675,7 @@ export function TransactionsFilters({
|
|||||||
aria-label="Limpar filtros"
|
aria-label="Limpar filtros"
|
||||||
className="text-xs text-muted-foreground hover:text-foreground h-9 px-2"
|
className="text-xs text-muted-foreground hover:text-foreground h-9 px-2"
|
||||||
>
|
>
|
||||||
<RiCloseLine className="size-3.5" />
|
<RiCloseLine className="size-3.5" aria-hidden />
|
||||||
Limpar
|
Limpar
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -607,9 +688,11 @@ export function TransactionsFilters({
|
|||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 space-y-4">
|
<div className="flex-1 overflow-y-auto px-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<label className="text-sm font-medium">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
Tipo de Lançamento
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Tipo de lançamento
|
||||||
</label>
|
</label>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
param="type"
|
param="type"
|
||||||
@@ -628,9 +711,9 @@ export function TransactionsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
Condição de Lançamento
|
Condição de pagamento
|
||||||
</label>
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todas"
|
placeholder="Todas"
|
||||||
@@ -643,9 +726,9 @@ export function TransactionsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
Forma de Pagamento
|
Forma de pagamento
|
||||||
</label>
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todas"
|
placeholder="Todas"
|
||||||
@@ -658,8 +741,10 @@ export function TransactionsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Pessoa</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Pessoa
|
||||||
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todas"
|
placeholder="Todas"
|
||||||
options={payerMultiOptions}
|
options={payerMultiOptions}
|
||||||
@@ -673,8 +758,10 @@ export function TransactionsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Categoria</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Categoria
|
||||||
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todas"
|
placeholder="Todas"
|
||||||
options={categoryMultiOptions}
|
options={categoryMultiOptions}
|
||||||
@@ -685,11 +772,14 @@ export function TransactionsFilters({
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
searchable
|
searchable
|
||||||
searchPlaceholder="Buscar categoria..."
|
searchPlaceholder="Buscar categoria..."
|
||||||
|
groupOrder={["Despesas", "Receitas", "Outras"]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Conta/Cartão</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Conta/Cartão
|
||||||
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todos"
|
placeholder="Todos"
|
||||||
options={accountCardMultiOptions}
|
options={accountCardMultiOptions}
|
||||||
@@ -703,9 +793,59 @@ export function TransactionsFilters({
|
|||||||
groupOrder={["Contas", "Cartões"]}
|
groupOrder={["Contas", "Cartões"]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Intervalo de datas
|
||||||
|
</label>
|
||||||
|
{hasDateRangeFilter ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetDateRange}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Limpar período
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-center">
|
||||||
|
<DatePicker
|
||||||
|
value={searchParams.get(DATE_START_PARAM) ?? ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleDateFilterChange(DATE_START_PARAM, value)
|
||||||
|
}
|
||||||
|
placeholder="Data inicial"
|
||||||
|
disabled={isPending}
|
||||||
|
inputClassName="border-dashed"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
<span className="hidden text-xs text-muted-foreground sm:block">
|
||||||
|
até
|
||||||
|
</span>
|
||||||
|
<DatePicker
|
||||||
|
value={searchParams.get(DATE_END_PARAM) ?? ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleDateFilterChange(DATE_END_PARAM, value)
|
||||||
|
}
|
||||||
|
placeholder="Data final"
|
||||||
|
disabled={isPending}
|
||||||
|
inputClassName="border-dashed"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Faixa de valor</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Faixa de valor
|
||||||
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -715,7 +855,9 @@ export function TransactionsFilters({
|
|||||||
placeholder="Mínimo"
|
placeholder="Mínimo"
|
||||||
aria-label="Valor mínimo"
|
aria-label="Valor mínimo"
|
||||||
value={valorMinValue}
|
value={valorMinValue}
|
||||||
onChange={(event) => setValorMinValue(event.target.value)}
|
onChange={(event) =>
|
||||||
|
setValorMinValue(event.target.value)
|
||||||
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="text-sm border-dashed"
|
className="text-sm border-dashed"
|
||||||
/>
|
/>
|
||||||
@@ -728,61 +870,53 @@ export function TransactionsFilters({
|
|||||||
placeholder="Máximo"
|
placeholder="Máximo"
|
||||||
aria-label="Valor máximo"
|
aria-label="Valor máximo"
|
||||||
value={valorMaxValue}
|
value={valorMaxValue}
|
||||||
onChange={(event) => setValorMaxValue(event.target.value)}
|
onChange={(event) =>
|
||||||
|
setValorMaxValue(event.target.value)
|
||||||
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="text-sm border-dashed"
|
className="text-sm border-dashed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-medium">Status</p>
|
<ToggleGroup
|
||||||
<div className="space-y-3">
|
type="single"
|
||||||
<div className="flex items-center justify-between">
|
value={settledFilterValue}
|
||||||
<label
|
onValueChange={(value) => {
|
||||||
htmlFor="filter-pago"
|
if (!value) return;
|
||||||
className="text-sm text-muted-foreground cursor-pointer"
|
|
||||||
>
|
|
||||||
Somente pagos
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
id="filter-pago"
|
|
||||||
checked={
|
|
||||||
searchParams.get("settled") ===
|
|
||||||
SETTLED_FILTER_VALUES.PAID
|
|
||||||
}
|
|
||||||
disabled={isPending}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
handleFilterChange(
|
handleFilterChange(
|
||||||
"settled",
|
"settled",
|
||||||
checked ? SETTLED_FILTER_VALUES.PAID : null,
|
value === FILTER_EMPTY_VALUE ? null : value,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
variant="outline"
|
||||||
</div>
|
size="sm"
|
||||||
<div className="flex items-center justify-between">
|
className="grid w-full grid-cols-3 rounded-md bg-muted/30 p-0.5"
|
||||||
<label
|
aria-label="Status de pagamento"
|
||||||
htmlFor="filter-nao-pago"
|
|
||||||
className="text-sm text-muted-foreground cursor-pointer"
|
|
||||||
>
|
>
|
||||||
Somente não pagos
|
<ToggleGroupItem
|
||||||
</label>
|
value={FILTER_EMPTY_VALUE}
|
||||||
<Switch
|
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||||
id="filter-nao-pago"
|
>
|
||||||
checked={
|
Todos
|
||||||
searchParams.get("settled") ===
|
</ToggleGroupItem>
|
||||||
SETTLED_FILTER_VALUES.UNPAID
|
<ToggleGroupItem
|
||||||
}
|
value={SETTLED_FILTER_VALUES.PAID}
|
||||||
disabled={isPending}
|
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||||
onCheckedChange={(checked) => {
|
>
|
||||||
handleFilterChange(
|
Pagos
|
||||||
"settled",
|
</ToggleGroupItem>
|
||||||
checked ? SETTLED_FILTER_VALUES.UNPAID : null,
|
<ToggleGroupItem
|
||||||
);
|
value={SETTLED_FILTER_VALUES.UNPAID}
|
||||||
}}
|
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||||
/>
|
>
|
||||||
</div>
|
Não pagos
|
||||||
</div>
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -824,14 +958,45 @@ export function TransactionsFilters({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DrawerFooter>
|
<DrawerFooter>
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2">
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{hasActiveFilters
|
||||||
|
? `${activeFilterCount} ${
|
||||||
|
activeFilterCount === 1
|
||||||
|
? "filtro ativo"
|
||||||
|
: "filtros ativos"
|
||||||
|
}`
|
||||||
|
: "Nenhum filtro ativo"}
|
||||||
|
</span>
|
||||||
|
{isPending ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<Spinner
|
||||||
|
className="size-3"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
Aplicando filtros...
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={handleResetFilters}
|
onClick={handleResetFilters}
|
||||||
disabled={isPending || !hasActiveFilters}
|
disabled={isPending || !hasActiveFilters}
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Limpar filtros
|
Limpar
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</DrawerFooter>
|
</DrawerFooter>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiArrowLeftRightLine,
|
||||||
|
RiArrowRightDownLine,
|
||||||
|
RiArrowRightUpLine,
|
||||||
|
RiAttachment2,
|
||||||
|
RiCalendarEventLine,
|
||||||
|
RiChat1Line,
|
||||||
|
RiGroupLine,
|
||||||
|
RiTimeLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { formatDate } from "@/shared/utils/date";
|
||||||
|
import { getConditionIcon, getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
import type { TransactionItem } from "../types";
|
||||||
|
import { TransactionActionsMenu } from "./transaction-actions-menu";
|
||||||
|
import { TransactionSettlementButton } from "./transaction-settlement-button";
|
||||||
|
|
||||||
|
type TransactionsMobileListProps = {
|
||||||
|
data: 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;
|
||||||
|
onToggleSettlement?: (item: TransactionItem) => void;
|
||||||
|
onAnticipate?: (item: TransactionItem) => void;
|
||||||
|
onViewAnticipationHistory?: (item: TransactionItem) => void;
|
||||||
|
isSettlementLoading: (id: string) => boolean;
|
||||||
|
showActions?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransactionsMobileList({
|
||||||
|
data,
|
||||||
|
currentUserId,
|
||||||
|
onEdit,
|
||||||
|
onCopy,
|
||||||
|
onImport,
|
||||||
|
onConfirmDelete,
|
||||||
|
onViewDetails,
|
||||||
|
onRefund,
|
||||||
|
onToggleSettlement,
|
||||||
|
onAnticipate,
|
||||||
|
onViewAnticipationHistory,
|
||||||
|
isSettlementLoading,
|
||||||
|
showActions = true,
|
||||||
|
}: TransactionsMobileListProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 md:hidden">
|
||||||
|
{data.map((item) => (
|
||||||
|
<TransactionMobileCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onCopy={onCopy}
|
||||||
|
onImport={onImport}
|
||||||
|
onConfirmDelete={onConfirmDelete}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
onRefund={onRefund}
|
||||||
|
onToggleSettlement={onToggleSettlement}
|
||||||
|
onAnticipate={onAnticipate}
|
||||||
|
onViewAnticipationHistory={onViewAnticipationHistory}
|
||||||
|
isSettlementLoading={isSettlementLoading}
|
||||||
|
showActions={showActions}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransactionMobileCardProps = Omit<TransactionsMobileListProps, "data"> & {
|
||||||
|
item: TransactionItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TransactionMobileCard({
|
||||||
|
item,
|
||||||
|
currentUserId,
|
||||||
|
onEdit,
|
||||||
|
onCopy,
|
||||||
|
onImport,
|
||||||
|
onConfirmDelete,
|
||||||
|
onViewDetails,
|
||||||
|
onRefund,
|
||||||
|
onToggleSettlement,
|
||||||
|
onAnticipate,
|
||||||
|
onViewAnticipationHistory,
|
||||||
|
isSettlementLoading,
|
||||||
|
showActions = true,
|
||||||
|
}: TransactionMobileCardProps) {
|
||||||
|
const installmentBadge =
|
||||||
|
item.currentInstallment && item.installmentCount
|
||||||
|
? `${item.currentInstallment} de ${item.installmentCount}`
|
||||||
|
: null;
|
||||||
|
const isBoleto = item.paymentMethod === "Boleto" && item.dueDate;
|
||||||
|
const dueDateLabel =
|
||||||
|
isBoleto && item.dueDate ? `Venc. ${formatDate(item.dueDate)}` : null;
|
||||||
|
const hasNote = Boolean(item.note?.trim().length);
|
||||||
|
const isLastInstallment =
|
||||||
|
item.currentInstallment === item.installmentCount &&
|
||||||
|
item.installmentCount &&
|
||||||
|
item.installmentCount > 1;
|
||||||
|
const isReceita = item.transactionType === "Receita";
|
||||||
|
const isTransfer = item.transactionType === "Transferência";
|
||||||
|
const isIncomingTransfer = isTransfer && Number(item.amount) > 0;
|
||||||
|
const payerLabel = item.pagadorName?.trim() || "Sem pessoa";
|
||||||
|
const payerDisplayName = payerLabel.split(/\s+/)[0] ?? payerLabel;
|
||||||
|
const paymentMethodLabel =
|
||||||
|
item.paymentMethod === "Transferência bancária"
|
||||||
|
? "Transf. bancária"
|
||||||
|
: item.paymentMethod;
|
||||||
|
|
||||||
|
const type =
|
||||||
|
item.categoriaName === "Saldo inicial"
|
||||||
|
? "Saldo inicial"
|
||||||
|
: item.transactionType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border bg-card px-3 py-2.5 shadow-xs",
|
||||||
|
item.paymentMethod === "Boleto" &&
|
||||||
|
item.dueDate &&
|
||||||
|
!item.isSettled &&
|
||||||
|
new Date(item.dueDate) < new Date() &&
|
||||||
|
"border-destructive/20 bg-destructive/3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<EstablishmentLogo name={item.name} size={34} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="truncate text-sm font-semibold leading-tight">
|
||||||
|
{item.name}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<RiCalendarEventLine className="size-3.5" aria-hidden />
|
||||||
|
{formatDate(item.purchaseDate)}
|
||||||
|
</span>
|
||||||
|
{dueDateLabel ? (
|
||||||
|
<span className="font-medium text-primary">
|
||||||
|
{dueDateLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="truncate">{payerDisplayName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<MoneyValues
|
||||||
|
amount={item.amount}
|
||||||
|
showPositiveSign={isReceita || isIncomingTransfer}
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap text-sm font-semibold",
|
||||||
|
isReceita ? "text-success" : "text-foreground",
|
||||||
|
isTransfer && "text-info",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
|
<IconBadge
|
||||||
|
label={type}
|
||||||
|
compact
|
||||||
|
className={getTransactionTypeIconClassName(type)}
|
||||||
|
>
|
||||||
|
{getTransactionTypeIcon(type)}
|
||||||
|
</IconBadge>
|
||||||
|
<IconBadge label={paymentMethodLabel} compact>
|
||||||
|
{getPaymentMethodIcon(item.paymentMethod)}
|
||||||
|
</IconBadge>
|
||||||
|
<IconBadge label={item.condition} compact>
|
||||||
|
{getConditionIcon(item.condition)}
|
||||||
|
</IconBadge>
|
||||||
|
{installmentBadge ? (
|
||||||
|
<Badge variant="outline" className="px-1.5 text-xs">
|
||||||
|
{installmentBadge}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{item.isDivided ? (
|
||||||
|
<IconBadge label="Dividido entre pessoas" compact>
|
||||||
|
<RiGroupLine className="size-3.5" aria-hidden />
|
||||||
|
</IconBadge>
|
||||||
|
) : null}
|
||||||
|
{isLastInstallment ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex size-6 items-center justify-center rounded-full border text-muted-foreground">
|
||||||
|
<Image
|
||||||
|
src="/icons/party.svg"
|
||||||
|
alt=""
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
className="size-3.5"
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Última parcela</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Última parcela!</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{item.isAnticipated ? (
|
||||||
|
<IconBadge label="Parcela antecipada" compact>
|
||||||
|
<RiTimeLine className="size-3.5" aria-hidden />
|
||||||
|
</IconBadge>
|
||||||
|
) : null}
|
||||||
|
{hasNote ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex size-6 items-center justify-center rounded-full border text-muted-foreground">
|
||||||
|
<RiChat1Line className="size-3.5" aria-hidden />
|
||||||
|
<span className="sr-only">Ver anotação</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
className="max-w-xs whitespace-pre-line"
|
||||||
|
>
|
||||||
|
{item.note}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{item.hasAttachments ? (
|
||||||
|
<IconBadge label="Possui anexos" compact>
|
||||||
|
<RiAttachment2 className="size-3.5" aria-hidden />
|
||||||
|
</IconBadge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{showActions ? (
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<TransactionSettlementButton
|
||||||
|
item={item}
|
||||||
|
isLoading={isSettlementLoading(item.id)}
|
||||||
|
onToggle={onToggleSettlement}
|
||||||
|
/>
|
||||||
|
<TransactionActionsMenu
|
||||||
|
item={item}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onCopy={onCopy}
|
||||||
|
onImport={onImport}
|
||||||
|
onConfirmDelete={onConfirmDelete}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
onRefund={onRefund}
|
||||||
|
onAnticipate={onAnticipate}
|
||||||
|
onViewAnticipationHistory={onViewAnticipationHistory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconBadge({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
compact = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
compact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (!children) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border text-xs text-muted-foreground",
|
||||||
|
compact ? "size-6 justify-center" : "gap-1 px-2 py-0.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{compact ? <span className="sr-only">{label}</span> : label}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTransactionTypeIcon(type: string) {
|
||||||
|
if (type === "Receita" || type === "Saldo inicial") {
|
||||||
|
return <RiArrowRightDownLine className="size-3.5" aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "Transferência") {
|
||||||
|
return <RiArrowLeftRightLine className="size-3.5" aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RiArrowRightUpLine className="size-3.5" aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTransactionTypeIconClassName(type: string) {
|
||||||
|
if (type === "Receita" || type === "Saldo inicial") {
|
||||||
|
return "border-success/30 bg-success/5 text-success";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "Transferência") {
|
||||||
|
return "border-info/30 bg-info/5 text-info";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "border-destructive/30 bg-destructive/5 text-destructive";
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ import type {
|
|||||||
import { TransactionsBulkBar } from "./transactions-bulk-bar";
|
import { TransactionsBulkBar } from "./transactions-bulk-bar";
|
||||||
import { getTransactionColumns } from "./transactions-columns";
|
import { getTransactionColumns } from "./transactions-columns";
|
||||||
import { TransactionsFilters } from "./transactions-filters";
|
import { TransactionsFilters } from "./transactions-filters";
|
||||||
|
import { TransactionsMobileList } from "./transactions-mobile-list";
|
||||||
import { TransactionsPagination } from "./transactions-pagination";
|
import { TransactionsPagination } from "./transactions-pagination";
|
||||||
|
|
||||||
type TransactionsTableProps = {
|
type TransactionsTableProps = {
|
||||||
@@ -174,7 +175,7 @@ export function TransactionsTable({
|
|||||||
: getPaginationRowModel(),
|
: getPaginationRowModel(),
|
||||||
manualPagination: isServerPaginated,
|
manualPagination: isServerPaginated,
|
||||||
pageCount: serverPagination?.totalPages,
|
pageCount: serverPagination?.totalPages,
|
||||||
enableRowSelection: true,
|
enableRowSelection: (row) => !row.original.readonly,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowModel = table.getRowModel();
|
const rowModel = table.getRowModel();
|
||||||
@@ -349,7 +350,23 @@ export function TransactionsTable({
|
|||||||
<CardContent className="px-2 py-4 sm:px-4">
|
<CardContent className="px-2 py-4 sm:px-4">
|
||||||
{hasRows ? (
|
{hasRows ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<TransactionsMobileList
|
||||||
|
data={rowModel.rows.map((row) => row.original)}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onCopy={onCopy}
|
||||||
|
onImport={onImport}
|
||||||
|
onConfirmDelete={onConfirmDelete}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
onRefund={onRefund}
|
||||||
|
onToggleSettlement={onToggleSettlement}
|
||||||
|
onAnticipate={onAnticipate}
|
||||||
|
onViewAnticipationHistory={onViewAnticipationHistory}
|
||||||
|
isSettlementLoading={isSettlementLoading ?? (() => false)}
|
||||||
|
showActions={showActions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="hidden overflow-x-auto md:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|||||||
@@ -43,15 +43,34 @@ const loadPdfDeps = async () => {
|
|||||||
return { jsPDF, autoTable };
|
return { jsPDF, autoTable };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatPeriodDate = (dateString: string) =>
|
||||||
|
formatDateOnly(dateString, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}) ?? dateString;
|
||||||
|
|
||||||
export function TransactionsExport({
|
export function TransactionsExport({
|
||||||
lancamentos,
|
lancamentos,
|
||||||
period,
|
period,
|
||||||
exportContext,
|
exportContext,
|
||||||
}: TransactionsExportProps) {
|
}: TransactionsExportProps) {
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const dateStartFilter = exportContext?.filters.dateStartFilter ?? null;
|
||||||
|
const dateEndFilter = exportContext?.filters.dateEndFilter ?? null;
|
||||||
|
const periodLabel =
|
||||||
|
dateStartFilter || dateEndFilter
|
||||||
|
? `${dateStartFilter ? formatPeriodDate(dateStartFilter) : "Início"} até ${
|
||||||
|
dateEndFilter ? formatPeriodDate(dateEndFilter) : "hoje"
|
||||||
|
}`
|
||||||
|
: displayPeriod(period);
|
||||||
|
const filePeriodSlug =
|
||||||
|
dateStartFilter || dateEndFilter
|
||||||
|
? `${dateStartFilter ?? "inicio"}-${dateEndFilter ?? "hoje"}`
|
||||||
|
: period;
|
||||||
|
|
||||||
const getFileName = (extension: string) => {
|
const getFileName = (extension: string) => {
|
||||||
return `lancamentos-${period}.${extension}`;
|
return `lancamentos-${filePeriodSlug}.${extension}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
@@ -251,7 +270,7 @@ export function TransactionsExport({
|
|||||||
doc.text("Lançamentos", titleX, 15);
|
doc.text("Lançamentos", titleX, 15);
|
||||||
|
|
||||||
doc.setFontSize(10);
|
doc.setFontSize(10);
|
||||||
doc.text(`Período: ${displayPeriod(period)}`, titleX, 22);
|
doc.text(`Período: ${periodLabel}`, titleX, 22);
|
||||||
doc.text(
|
doc.text(
|
||||||
`Gerado em: ${
|
`Gerado em: ${
|
||||||
formatDateTime(new Date(), {
|
formatDateTime(new Date(), {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export type TransactionFilterOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
|
type?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccountCardFilterOption = TransactionFilterOption & {
|
export type AccountCardFilterOption = TransactionFilterOption & {
|
||||||
|
|||||||
@@ -33,3 +33,5 @@ export const SETTLED_FILTER_VALUES = {
|
|||||||
|
|
||||||
export const AMOUNT_MIN_PARAM = "valorMin";
|
export const AMOUNT_MIN_PARAM = "valorMin";
|
||||||
export const AMOUNT_MAX_PARAM = "valorMax";
|
export const AMOUNT_MAX_PARAM = "valorMax";
|
||||||
|
export const DATE_START_PARAM = "dataInicio";
|
||||||
|
export const DATE_END_PARAM = "dataFim";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user