mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e50eeba36e | ||
|
|
26cb18a9ad | ||
|
|
382727a96d | ||
|
|
0df648c7f3 | ||
|
|
27f361923c | ||
|
|
60b2612e8a | ||
|
|
0171b0ce2f | ||
|
|
311369f81b | ||
|
|
ef2c8c50e8 | ||
|
|
5319d8a5a6 | ||
|
|
37247e319c | ||
|
|
766af2b347 |
@@ -19,6 +19,9 @@ BETTER_AUTH_SECRET=your-secret-key-here-change-this
|
||||
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 ===
|
||||
APP_PORT=3000
|
||||
@@ -56,7 +59,10 @@ UMAMI_DOMAINS=
|
||||
ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=
|
||||
MINIMAX_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434/v1
|
||||
OLLAMA_API_KEY=
|
||||
|
||||
# === Logo.dev (Opcional) ===
|
||||
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev
|
||||
|
||||
7
.github/workflows/docker-publish.yml
vendored
7
.github/workflows/docker-publish.yml
vendored
@@ -13,20 +13,19 @@ on:
|
||||
|
||||
env:
|
||||
DOCKER_IMAGE_NAME: openmonetis
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
@@ -46,7 +45,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -5,9 +5,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -16,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -5,6 +5,41 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||
|
||||
## [2.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.
|
||||
|
||||
32
README.md
32
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.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
@@ -62,7 +62,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
||||
|
||||
### Funcionalidades
|
||||
|
||||
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, 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.
|
||||
💰 **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.
|
||||
|
||||
@@ -72,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.
|
||||
|
||||
🤖 **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.
|
||||
|
||||
@@ -96,7 +96,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
||||
- **shadcn/ui** (Radix UI) + **Tailwind CSS**
|
||||
- **Docker** (multi-stage build)
|
||||
- **Biome** (linting + formatting)
|
||||
- **Vercel AI SDK** (Claude, GPT, Gemini, OpenRouter)
|
||||
- **Vercel AI SDK** (Claude, GPT, Gemini, MiniMax, OpenRouter, Ollama)
|
||||
|
||||
---
|
||||
|
||||
@@ -447,6 +447,8 @@ 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_ENDPOINT=
|
||||
@@ -470,7 +472,10 @@ RESEND_FROM_EMAIL=
|
||||
ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=
|
||||
MINIMAX_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)
|
||||
# Ambas as variáveis são runtime — basta definir no host; nenhum build arg necessário.
|
||||
@@ -478,6 +483,25 @@ LOGO_DEV_TOKEN=
|
||||
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
|
||||
|
||||
22
package.json
22
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.6.4",
|
||||
"version": "2.7.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
"scripts": {
|
||||
@@ -31,9 +31,10 @@
|
||||
"mockup": "tsx scripts/mock-data.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.78",
|
||||
"@ai-sdk/google": "^3.0.75",
|
||||
"@ai-sdk/openai": "^3.0.64",
|
||||
"@ai-sdk/anthropic": "^3.0.79",
|
||||
"@ai-sdk/google": "^3.0.79",
|
||||
"@ai-sdk/openai": "^3.0.65",
|
||||
"@ai-sdk/openai-compatible": "^2.0.48",
|
||||
"@aws-sdk/client-s3": "^3.1050.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1050.0",
|
||||
"@better-auth/passkey": "^1.6.11",
|
||||
@@ -63,16 +64,16 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"ai": "^6.0.185",
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"ai": "^6.0.191",
|
||||
"better-auth": "1.6.11",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.2.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"jspdf": "^4.2.1",
|
||||
@@ -85,11 +86,12 @@
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.6",
|
||||
"recharts": "3.8.1",
|
||||
"resend": "^6.12.3",
|
||||
"resend": "^6.12.4",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "1.1.2",
|
||||
"vercel-minimax-ai-provider": "^0.0.2",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -103,7 +105,7 @@
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"knip": "^6.14.1",
|
||||
"knip": "^6.14.2",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsx": "4.22.3",
|
||||
"typescript": "6.0.3"
|
||||
|
||||
686
pnpm-lock.yaml
generated
686
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,24 @@ minimumReleaseAgeExclude:
|
||||
- '@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"],
|
||||
display: "swap",
|
||||
variable: "--font-inter",
|
||||
|
||||
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 openaiKey = "";
|
||||
let googleAiKey = "";
|
||||
let minimaxKey = "";
|
||||
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}`);
|
||||
anthropicKey = await ask(" ANTHROPIC_API_KEY: ");
|
||||
openaiKey = await ask(" OPENAI_API_KEY: ");
|
||||
googleAiKey = await ask(" GOOGLE_GENERATIVE_AI_API_KEY: ");
|
||||
minimaxKey = await ask(" MINIMAX_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
|
||||
let publicDomain = "";
|
||||
@@ -285,6 +293,9 @@ const envContent = [
|
||||
"# === Better Auth ===",
|
||||
`BETTER_AUTH_SECRET=${authSecret}`,
|
||||
`BETTER_AUTH_URL=${betterAuthUrl}`,
|
||||
"DISABLE_SIGNUP=false",
|
||||
"AUTH_SESSION_EXPIRES_IN_DAYS=30",
|
||||
"AUTH_SESSION_UPDATE_AGE_HOURS=24",
|
||||
"",
|
||||
"# === Portas ===",
|
||||
"APP_PORT=3000",
|
||||
@@ -310,7 +321,10 @@ const envContent = [
|
||||
opt("ANTHROPIC_API_KEY", anthropicKey),
|
||||
opt("OPENAI_API_KEY", openaiKey),
|
||||
opt("GOOGLE_GENERATIVE_AI_API_KEY", googleAiKey),
|
||||
opt("MINIMAX_API_KEY", minimaxKey),
|
||||
opt("OPENROUTER_API_KEY", openrouterKey),
|
||||
opt("OLLAMA_BASE_URL", ollamaBaseUrl),
|
||||
opt("OLLAMA_API_KEY", ollamaApiKey),
|
||||
].join("\n");
|
||||
|
||||
writeFileSync(join(targetDir, ".env"), envContent);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { AccountDialog } from "@/features/accounts/components/account-dialog";
|
||||
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 type { Account } from "@/features/accounts/components/types";
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
||||
import { getBusinessDateString } from "@/shared/utils/date";
|
||||
import { parsePeriodParam } from "@/shared/utils/period";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
@@ -52,6 +54,17 @@ const resolveDefaultPaymentMethod = (
|
||||
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) {
|
||||
await connection();
|
||||
const { accountId } = await params;
|
||||
@@ -109,6 +122,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
accountSummary;
|
||||
|
||||
const periodLabel = `${capitalize(monthName)} de ${year}`;
|
||||
const defaultYieldDate = resolveDefaultYieldDate(selectedPeriod);
|
||||
|
||||
const accountDialogData: Account = {
|
||||
id: account.id,
|
||||
@@ -152,11 +166,17 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
totalExpenses={totalExpenses}
|
||||
logo={account.logo}
|
||||
balanceAdjustment={
|
||||
<AdjustBalanceDialog
|
||||
accountId={account.id}
|
||||
period={selectedPeriod}
|
||||
currentBalance={currentBalance}
|
||||
/>
|
||||
<>
|
||||
<AddYieldDialog
|
||||
accountId={account.id}
|
||||
defaultDate={defaultYieldDate}
|
||||
/>
|
||||
<AdjustBalanceDialog
|
||||
accountId={account.id}
|
||||
period={selectedPeriod}
|
||||
currentBalance={currentBalance}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<AccountDialog
|
||||
|
||||
@@ -16,8 +16,7 @@ export default function RootLayout({
|
||||
icon={<RiBankLine />}
|
||||
title="Contas"
|
||||
subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas,
|
||||
despesas e transações previstas. Use o seletor abaixo para navegar pelos
|
||||
meses e visualizar as movimentações correspondentes."
|
||||
despesas e transações previstas."
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
|
||||
@@ -16,8 +16,7 @@ export default function RootLayout({
|
||||
icon={<RiBankCard2Line />}
|
||||
title="Cartões"
|
||||
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
|
||||
visualizar as movimentações correspondentes."
|
||||
e transações previstas."
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
|
||||
@@ -16,8 +16,7 @@ export default function RootLayout({
|
||||
icon={<RiArrowLeftRightLine />}
|
||||
title="Lançamentos"
|
||||
subtitle="Acompanhe todos os lançamentos financeiros do mês selecionado incluindo
|
||||
receitas, despesas e transações previstas. Use o seletor abaixo para
|
||||
navegar pelos meses e visualizar as movimentações correspondentes."
|
||||
receitas, despesas e transações previstas."
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
--accent: oklch(94.8% 0.009 65);
|
||||
--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);
|
||||
--warning: oklch(78.357% 0.15147 68.301);
|
||||
--warning-foreground: oklch(20% 0.04 85);
|
||||
@@ -90,7 +90,7 @@
|
||||
|
||||
.dark {
|
||||
--background: oklch(18% 0.004 55);
|
||||
--foreground: oklch(93% 0.008 80);
|
||||
--foreground: #feefe1;
|
||||
--card: oklch(21.531% 0.00369 48.293);
|
||||
--card-foreground: var(--foreground);
|
||||
--popover: oklch(24% 0.004 55);
|
||||
|
||||
@@ -32,9 +32,20 @@ import {
|
||||
formatCurrency,
|
||||
formatDecimalForDbRequired,
|
||||
} 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";
|
||||
|
||||
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({
|
||||
name: z
|
||||
.string({ message: "Informe o nome da conta." })
|
||||
@@ -408,6 +419,107 @@ const adjustAccountBalanceSchema = z.object({
|
||||
|
||||
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(
|
||||
input: AdjustAccountBalanceInput,
|
||||
): Promise<ActionResult> {
|
||||
|
||||
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,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
|
||||
type AdjustBalanceDialogProps = {
|
||||
@@ -79,17 +84,22 @@ export function AdjustBalanceDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Ajustar saldo"
|
||||
>
|
||||
<RiEqualizerLine className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-primary hover:text-primary"
|
||||
aria-label="Ajustar saldo"
|
||||
>
|
||||
<RiEqualizerLine className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Ajustar saldo</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ajustar saldo</DialogTitle>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
@@ -38,6 +39,7 @@ export function LoginForm({
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [loadingEmail, setLoadingEmail] = useState(false);
|
||||
@@ -60,7 +62,7 @@ export function LoginForm({
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/dashboard",
|
||||
rememberMe: false,
|
||||
rememberMe,
|
||||
},
|
||||
{
|
||||
onRequest: () => {
|
||||
@@ -186,6 +188,24 @@ export function LoginForm({
|
||||
/>
|
||||
</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>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card } from "@/shared/components/ui/card";
|
||||
import type { CategoryType } from "@/shared/lib/categories/constants";
|
||||
import { currencyFormatter } from "@/shared/utils/currency";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type CategorySummary = {
|
||||
id: string;
|
||||
@@ -32,19 +33,40 @@ export function CategoryDetailHeader({
|
||||
percentageChange,
|
||||
transactionCount,
|
||||
}: CategoryDetailHeaderProps) {
|
||||
const absoluteChange = currentTotal - previousTotal;
|
||||
const variationLabel =
|
||||
typeof percentageChange === "number"
|
||||
? formatPercentage(percentageChange, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
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 (
|
||||
<Card className="px-4">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<Card className="px-5 py-5">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<CategoryIconBadge
|
||||
icon={category.icon}
|
||||
@@ -59,41 +81,59 @@ export function CategoryDetailHeader({
|
||||
<TransactionTypeBadge kind={category.type} />
|
||||
<span>
|
||||
{transactionCount}{" "}
|
||||
{transactionCount === 1 ? "lançamento" : "lançamentos"} no{" "}
|
||||
período
|
||||
{transactionCount === 1 ? "lançamento" : "lançamentos"} em{" "}
|
||||
{currentPeriodLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-4 sm:grid-cols-2 lg:w-auto lg:grid-cols-3">
|
||||
<div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<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">
|
||||
Total em {currentPeriodLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
<p className="mt-1 text-3xl font-semibold tracking-tight">
|
||||
{currencyFormatter.format(currentTotal)}
|
||||
</p>
|
||||
</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">
|
||||
Total em {previousPeriodLabel}
|
||||
</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)}
|
||||
</p>
|
||||
</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">
|
||||
Variação vs mês anterior
|
||||
Variação
|
||||
</p>
|
||||
<PercentageChangeIndicator
|
||||
value={percentageChange}
|
||||
label={variationLabel}
|
||||
positiveTrend={category.type === "receita" ? "up" : "down"}
|
||||
className="mt-1 gap-1 text-lg font-semibold"
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
<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
|
||||
value={percentageChange}
|
||||
label={variationLabel}
|
||||
positiveTrend={category.type === "receita" ? "up" : "down"}
|
||||
className="gap-1 text-lg font-semibold"
|
||||
iconClassName="size-4"
|
||||
showFlatIcon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RiExternalLinkLine, RiWallet3Line } from "@remixicon/react";
|
||||
import { RiExternalLinkLine } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||
@@ -63,7 +63,7 @@ export function CategoryBreakdownListItem({
|
||||
/>
|
||||
</Link>
|
||||
</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>
|
||||
{formatPercentage(
|
||||
category.percentageOfTotal,
|
||||
@@ -77,10 +77,9 @@ export function CategoryBreakdownListItem({
|
||||
<span
|
||||
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
|
||||
>
|
||||
<RiWallet3Line className="size-3 shrink-0" />
|
||||
{budgetExceeded ? (
|
||||
<>
|
||||
excedeu{" "}
|
||||
Excedeu{" "}
|
||||
<span className="font-medium">
|
||||
{formatCurrency(exceededAmount)}
|
||||
</span>
|
||||
|
||||
@@ -20,6 +20,7 @@ export function InstallmentExpenseListItem({
|
||||
const {
|
||||
compactLabel,
|
||||
isLast,
|
||||
remainingLabel,
|
||||
remainingInstallments,
|
||||
remainingAmount,
|
||||
endDate,
|
||||
@@ -65,15 +66,22 @@ export function InstallmentExpenseListItem({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{endDate ? `Termina em ${endDate}` : null}
|
||||
{" · Restante "}
|
||||
<MoneyValues
|
||||
amount={remainingAmount}
|
||||
className="inline-block font-semibold"
|
||||
/>{" "}
|
||||
({remainingInstallments})
|
||||
</p>
|
||||
{remainingInstallments === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{endDate ? `Termina em ${endDate}` : null}
|
||||
{" · Quitado"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{endDate ? `Termina em ${endDate}` : null}
|
||||
{` · ${remainingLabel}: `}
|
||||
<MoneyValues
|
||||
amount={remainingAmount}
|
||||
className="inline-block font-semibold"
|
||||
/>{" "}
|
||||
({remainingInstallments}x)
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Progress value={progress} className="mt-1 h-2" />
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { RiInformationLine } from "@remixicon/react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/shared/components/ui/hover-card";
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
|
||||
type MetricsCardInfoButtonProps = {
|
||||
label: string;
|
||||
@@ -19,8 +19,8 @@ export function MetricsCardInfoButton({
|
||||
helpLines,
|
||||
}: MetricsCardInfoButtonProps) {
|
||||
return (
|
||||
<HoverCard openDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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"
|
||||
@@ -28,17 +28,22 @@ export function MetricsCardInfoButton({
|
||||
>
|
||||
<RiInformationLine className="size-4" aria-hidden />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-80 space-y-3">
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className="max-w-80 space-y-3 p-3 text-left"
|
||||
>
|
||||
<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>
|
||||
<ul className="space-y-2 text-xs text-muted-foreground">
|
||||
<ul className="space-y-2 text-xs text-background/80">
|
||||
{helpLines.map((line) => (
|
||||
<li key={`${label}-${line}`}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export function WidgetSettingsDialog({
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={`${isVisible ? "Ocultar" : "Exibir"} widget ${widget.title}`}
|
||||
checked={isVisible}
|
||||
onCheckedChange={() => onToggleWidget(widget.id)}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import {
|
||||
calculateLastInstallmentDate,
|
||||
formatLastInstallmentDate,
|
||||
} from "@/shared/lib/installments/utils";
|
||||
import { calculateLastInstallmentDate } from "@/shared/lib/installments/utils";
|
||||
import { capitalize } from "@/shared/utils/string";
|
||||
|
||||
type InstallmentExpenseDisplay = {
|
||||
compactLabel: string | null;
|
||||
isLast: boolean;
|
||||
remainingLabel: "Próx." | "Aberto";
|
||||
remainingInstallments: number;
|
||||
remainingAmount: number;
|
||||
endDate: string | null;
|
||||
@@ -38,21 +37,30 @@ const isInstallmentLast = (
|
||||
const calculateInstallmentRemainingCount = (
|
||||
currentInstallment: number | null,
|
||||
installmentCount: number | null,
|
||||
isSettled: boolean | null,
|
||||
) => {
|
||||
if (!currentInstallment || !installmentCount) {
|
||||
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 = (
|
||||
amount: number,
|
||||
currentInstallment: number | null,
|
||||
installmentCount: number | null,
|
||||
isSettled: boolean | null,
|
||||
) =>
|
||||
amount *
|
||||
calculateInstallmentRemainingCount(currentInstallment, installmentCount);
|
||||
calculateInstallmentRemainingCount(
|
||||
currentInstallment,
|
||||
installmentCount,
|
||||
isSettled,
|
||||
);
|
||||
|
||||
const formatInstallmentEndDate = (
|
||||
period: string,
|
||||
@@ -69,7 +77,12 @@ const formatInstallmentEndDate = (
|
||||
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 = (
|
||||
@@ -89,7 +102,8 @@ const buildInstallmentProgress = (
|
||||
export const buildInstallmentExpenseDisplay = (
|
||||
expense: InstallmentExpense,
|
||||
): InstallmentExpenseDisplay => {
|
||||
const { amount, currentInstallment, installmentCount, period } = expense;
|
||||
const { amount, currentInstallment, installmentCount, isSettled, period } =
|
||||
expense;
|
||||
|
||||
return {
|
||||
compactLabel: buildInstallmentCompactLabel(
|
||||
@@ -97,14 +111,17 @@ export const buildInstallmentExpenseDisplay = (
|
||||
installmentCount,
|
||||
),
|
||||
isLast: isInstallmentLast(currentInstallment, installmentCount),
|
||||
remainingLabel: isSettled === true ? "Próx." : "Aberto",
|
||||
remainingInstallments: calculateInstallmentRemainingCount(
|
||||
currentInstallment,
|
||||
installmentCount,
|
||||
isSettled,
|
||||
),
|
||||
remainingAmount: calculateInstallmentRemainingAmount(
|
||||
amount,
|
||||
currentInstallment,
|
||||
installmentCount,
|
||||
isSettled,
|
||||
),
|
||||
endDate: formatInstallmentEndDate(
|
||||
period,
|
||||
|
||||
@@ -8,6 +8,7 @@ export type InstallmentExpense = {
|
||||
dueDate: Date | null;
|
||||
purchaseDate: Date;
|
||||
period: string;
|
||||
isSettled: boolean | null;
|
||||
};
|
||||
|
||||
export type InstallmentExpensesData = {
|
||||
|
||||
@@ -405,6 +405,7 @@ const buildInstallmentExpensesData = (
|
||||
dueDate: row.dueDate,
|
||||
purchaseDate: row.purchaseDate,
|
||||
period: row.period,
|
||||
isSettled: row.isSettled,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const remainingA =
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"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 { getUser } from "@/shared/lib/auth/server";
|
||||
import {
|
||||
type InsightsResponse,
|
||||
InsightsResponseSchema,
|
||||
} 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 type { ActionResult } from "./types";
|
||||
|
||||
@@ -19,6 +17,7 @@ const PERIOD_REGEX = /^\d{4}-\d{2}$/;
|
||||
export async function generateInsightsAction(
|
||||
period: string,
|
||||
modelId: string,
|
||||
userInstructions?: string,
|
||||
): Promise<ActionResult<InsightsResponse>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
@@ -30,50 +29,23 @@ export async function generateInsightsAction(
|
||||
};
|
||||
}
|
||||
|
||||
const selectedModel = AVAILABLE_MODELS.find((m) => m.id === modelId);
|
||||
const isOpenRouterFormat = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/.test(
|
||||
modelId,
|
||||
);
|
||||
if (!selectedModel && !isOpenRouterFormat) {
|
||||
const normalizedUserInstructions = userInstructions?.trim() ?? "";
|
||||
if (normalizedUserInstructions.length > USER_INSTRUCTIONS_MAX_LENGTH) {
|
||||
return {
|
||||
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);
|
||||
|
||||
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({
|
||||
model,
|
||||
model: resolvedModel.model,
|
||||
schema: InsightsResponseSchema,
|
||||
system: INSIGHTS_SYSTEM_PROMPT,
|
||||
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)}
|
||||
- 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:
|
||||
1. Comportamentos Observados (behaviors): 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,
|
||||
RiLightbulbLine,
|
||||
RiRocketLine,
|
||||
RiSparklingLine,
|
||||
} from "@remixicon/react";
|
||||
import type React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -22,6 +24,7 @@ import { cn } from "@/shared/utils/ui";
|
||||
|
||||
interface InsightsGridProps {
|
||||
insights: InsightsResponse;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2 px-1 text-muted-foreground">
|
||||
<p>
|
||||
No período selecionado ({formattedPeriod}), identificamos os
|
||||
principais comportamentos e gatilhos que impactaram seu padrão de
|
||||
consumo.
|
||||
</p>
|
||||
<p>Segue um panorama prático com recomendações acionáveis.</p>
|
||||
</div>
|
||||
<Card className="overflow-hidden border-primary/10 bg-linear-to-br from-primary/10 via-card to-card">
|
||||
<CardContent className="px-4 py-1">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<RiSparklingLine className="size-5" />
|
||||
</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">
|
||||
{insights.categories.map((categoryData) => {
|
||||
const categoryConfig = INSIGHT_CATEGORIES[categoryData.category];
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiAlertLine,
|
||||
RiDeleteBinLine,
|
||||
RiEyeLine,
|
||||
RiFlashlightLine,
|
||||
RiLightbulbLine,
|
||||
RiLoader4Line,
|
||||
RiRocketLine,
|
||||
RiSaveLine,
|
||||
RiSparklingLine,
|
||||
} from "@remixicon/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
deleteSavedInsightsAction,
|
||||
@@ -21,8 +24,6 @@ import {
|
||||
savedInsightsQueryKey,
|
||||
useSavedInsights,
|
||||
} 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 { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
@@ -47,6 +48,9 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||
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 insights = draftInsights ?? savedInsights?.insights ?? null;
|
||||
const selectedModel =
|
||||
@@ -59,20 +63,45 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||
draftInsights === null && savedInsightsQuery.error instanceof Error
|
||||
? savedInsightsQuery.error.message
|
||||
: null;
|
||||
const shouldShowAnalysisArea = Boolean(
|
||||
isPending ||
|
||||
isLoadingSavedInsights ||
|
||||
insights ||
|
||||
error ||
|
||||
savedInsightsError,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void period;
|
||||
setDraftInsights(null);
|
||||
setSelectedModelOverride(null);
|
||||
setError(null);
|
||||
setShouldScrollToAnalysis(false);
|
||||
}, [period]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldScrollToAnalysis || !shouldShowAnalysisArea) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
analysisAreaRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
});
|
||||
setShouldScrollToAnalysis(false);
|
||||
}, [shouldScrollToAnalysis, shouldShowAnalysisArea]);
|
||||
|
||||
const handleAnalyze = () => {
|
||||
setError(null);
|
||||
setShouldScrollToAnalysis(true);
|
||||
onAnalyze?.();
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await generateInsightsAction(period, selectedModel);
|
||||
const result = await generateInsightsAction(
|
||||
period,
|
||||
selectedModel,
|
||||
userInstructions,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setDraftInsights(result.data);
|
||||
@@ -145,145 +174,173 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||
|
||||
return (
|
||||
<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
|
||||
value={selectedModel}
|
||||
onValueChange={setSelectedModelOverride}
|
||||
period={period}
|
||||
onAnalyze={handleAnalyze}
|
||||
userInstructions={userInstructions}
|
||||
onUserInstructionsChange={setUserInstructions}
|
||||
onCancel={() => {
|
||||
setSelectedModelOverride(null);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isPending}
|
||||
isLoadingSavedInsights={isLoadingSavedInsights}
|
||||
/>
|
||||
|
||||
{/* Analyze Button */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<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 &&
|
||||
!insights &&
|
||||
!error &&
|
||||
!savedInsightsError && (
|
||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiSparklingLine className="size-6 text-primary" />}
|
||||
title="Nenhuma análise realizada"
|
||||
description="Clique no botão acima para gerar insights inteligentes sobre seus
|
||||
dados financeiros do mês selecionado."
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{!isPending && !isLoadingSavedInsights && error && (
|
||||
<ErrorState
|
||||
title="Erro ao gerar insights"
|
||||
error={error}
|
||||
onRetry={handleAnalyze}
|
||||
/>
|
||||
)}
|
||||
{!isPending &&
|
||||
!isLoadingSavedInsights &&
|
||||
!error &&
|
||||
savedInsightsError && (
|
||||
{shouldShowAnalysisArea && (
|
||||
<div className="min-h-[320px] scroll-mt-6" ref={analysisAreaRef}>
|
||||
{(isPending || isLoadingSavedInsights) && <LoadingState />}
|
||||
{!isPending && !isLoadingSavedInsights && error && (
|
||||
<ErrorState
|
||||
title="Erro ao carregar insights salvos"
|
||||
error={savedInsightsError}
|
||||
onRetry={() => void savedInsightsQuery.refetch()}
|
||||
title="Erro ao gerar insights"
|
||||
error={error}
|
||||
onRetry={handleAnalyze}
|
||||
/>
|
||||
)}
|
||||
{!isPending &&
|
||||
!isLoadingSavedInsights &&
|
||||
insights &&
|
||||
!error &&
|
||||
!savedInsightsError && <InsightsGrid insights={insights} />}
|
||||
</div>
|
||||
{!isPending &&
|
||||
!isLoadingSavedInsights &&
|
||||
!error &&
|
||||
savedInsightsError && (
|
||||
<ErrorState
|
||||
title="Erro ao carregar insights salvos"
|
||||
error={savedInsightsError}
|
||||
onRetry={() => void savedInsightsQuery.refetch()}
|
||||
/>
|
||||
)}
|
||||
{!isPending &&
|
||||
!isLoadingSavedInsights &&
|
||||
insights &&
|
||||
!error &&
|
||||
!savedInsightsError && (
|
||||
<InsightsGrid
|
||||
insights={insights}
|
||||
action={
|
||||
<div className="flex flex-col items-start sm:items-end">
|
||||
<Button
|
||||
onClick={isSaved ? handleDelete : handleSave}
|
||||
disabled={isSaving || isPending || isLoadingSavedInsights}
|
||||
variant={isSaved ? "destructive" : "secondary"}
|
||||
>
|
||||
{isSaved ? (
|
||||
<>
|
||||
<RiDeleteBinLine className="mr-2 size-4" />
|
||||
{isSaving ? "Removendo..." : "Remover análise"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiSaveLine className="mr-2 size-4" />
|
||||
{isSaving ? "Salvando..." : "Salvar análise"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{isSaved && savedDate && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Salva em{" "}
|
||||
{format(new Date(savedDate), "dd/MM/yyyy 'às' HH:mm", {
|
||||
locale: ptBR,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Intro text skeleton */}
|
||||
<div className="space-y-2 px-1">
|
||||
<Skeleton className="h-5 w-full max-w-2xl" />
|
||||
<Skeleton className="h-5 w-full max-w-md" />
|
||||
</div>
|
||||
const categories = [
|
||||
{
|
||||
label: "Comportamentos",
|
||||
icon: RiEyeLine,
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
},
|
||||
{
|
||||
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 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i} className="relative overflow-hidden">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="overflow-hidden border-primary/10 bg-linear-to-br from-primary/10 via-card to-card">
|
||||
<CardContent className="px-4 py-1">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<RiLoader4Line className="size-5 animate-spin" />
|
||||
</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 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>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{categories.map((category) => {
|
||||
const Icon = category.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={category.label}
|
||||
className="relative min-h-[390px] overflow-hidden"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`size-5 ${category.color}`} />
|
||||
<span className={`font-semibold ${category.color}`}>
|
||||
{category.label}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div className="space-y-2" key={index}>
|
||||
<div className="flex items-start gap-2">
|
||||
<Skeleton className="mt-0.5 size-4 shrink-0 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-[82%]" />
|
||||
</div>
|
||||
</div>
|
||||
{(index === 1 || index === 3) && (
|
||||
<Skeleton className="ml-6 h-10 w-[72%] rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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";
|
||||
|
||||
import { RiExternalLinkLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
type AIProvider,
|
||||
AVAILABLE_MODELS,
|
||||
DEFAULT_PROVIDER,
|
||||
PROVIDERS,
|
||||
} from "@/features/insights/constants";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { AnalysisSummaryCard } from "./analysis-summary-card";
|
||||
import { CUSTOM_MODEL_VALUE, ModelSelectionCard } from "./model-selection-card";
|
||||
import { ProviderSelectionCard } from "./provider-selection-card";
|
||||
|
||||
interface ModelSelectorProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
period: string;
|
||||
onAnalyze: () => void;
|
||||
userInstructions: string;
|
||||
onUserInstructionsChange: (value: string) => void;
|
||||
onCancel?: () => void;
|
||||
disabled?: boolean;
|
||||
isLoadingSavedInsights?: boolean;
|
||||
}
|
||||
|
||||
const PROVIDER_ICON_PATHS: Record<
|
||||
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",
|
||||
},
|
||||
};
|
||||
const CUSTOM_MODEL_PROVIDERS = ["openrouter", "ollama"] as const;
|
||||
|
||||
function isCustomModelProvider(
|
||||
provider: AIProvider,
|
||||
): provider is (typeof CUSTOM_MODEL_PROVIDERS)[number] {
|
||||
return CUSTOM_MODEL_PROVIDERS.includes(
|
||||
provider as (typeof CUSTOM_MODEL_PROVIDERS)[number],
|
||||
);
|
||||
}
|
||||
|
||||
function getProviderFromValue(value: string): AIProvider | null {
|
||||
if (value.startsWith("openrouter:")) {
|
||||
return "openrouter";
|
||||
}
|
||||
|
||||
if (value.startsWith("ollama:")) {
|
||||
return "ollama";
|
||||
}
|
||||
|
||||
if (value.includes("/")) {
|
||||
return "openrouter";
|
||||
}
|
||||
|
||||
return AVAILABLE_MODELS.find((model) => model.id === value)?.provider ?? null;
|
||||
}
|
||||
|
||||
function stripCustomProviderPrefix(value: string, provider: AIProvider) {
|
||||
if (!isCustomModelProvider(provider)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.startsWith(`${provider}:`)
|
||||
? value.slice(`${provider}:`.length)
|
||||
: value;
|
||||
}
|
||||
|
||||
function getModelLabel(modelId: string) {
|
||||
const model = AVAILABLE_MODELS.find((item) => item.id === modelId);
|
||||
if (model) return model.name;
|
||||
|
||||
const provider = getProviderFromValue(modelId);
|
||||
return provider ? stripCustomProviderPrefix(modelId, provider) : modelId;
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
value,
|
||||
onValueChange,
|
||||
period,
|
||||
onAnalyze,
|
||||
userInstructions,
|
||||
onUserInstructionsChange,
|
||||
onCancel,
|
||||
disabled,
|
||||
isLoadingSavedInsights,
|
||||
}: ModelSelectorProps) {
|
||||
// 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);
|
||||
const detectedProvider = getProviderFromValue(value);
|
||||
if (detectedProvider && isCustomModelProvider(detectedProvider)) {
|
||||
setCustomModel(stripCustomProviderPrefix(value, detectedProvider));
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomModel(value);
|
||||
}, [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;
|
||||
}
|
||||
const currentProvider = getProviderFromValue(value) ?? DEFAULT_PROVIDER;
|
||||
|
||||
// Se o modelo tem "/" é OpenRouter
|
||||
if (value.includes("/")) {
|
||||
return "openrouter";
|
||||
}
|
||||
|
||||
// Caso contrário, tente detectar baseado no modelo
|
||||
const model = AVAILABLE_MODELS.find((m) => m.id === value);
|
||||
return model?.provider ?? DEFAULT_PROVIDER;
|
||||
}, [value, selectedProvider]);
|
||||
|
||||
// Agrupar modelos por provider
|
||||
const modelsByProvider = useMemo(() => {
|
||||
const grouped: Record<
|
||||
AIProvider,
|
||||
@@ -97,7 +99,9 @@ export function ModelSelector({
|
||||
openai: [],
|
||||
anthropic: [],
|
||||
google: [],
|
||||
minimax: [],
|
||||
openrouter: [],
|
||||
ollama: [],
|
||||
};
|
||||
|
||||
AVAILABLE_MODELS.forEach((model) => {
|
||||
@@ -107,130 +111,88 @@ export function ModelSelector({
|
||||
return grouped;
|
||||
}, []);
|
||||
|
||||
// Atualizar provider (seleciona primeiro modelo daquele provider)
|
||||
const handleProviderChange = (newProvider: AIProvider) => {
|
||||
setSelectedProvider(newProvider);
|
||||
const providerModels = modelsByProvider[currentProvider];
|
||||
const selectedModelIsKnown = providerModels.some(
|
||||
(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") {
|
||||
// Para OpenRouter, usa o modelo customizado ou limpa o valor
|
||||
onValueChange(customModel || "");
|
||||
setCustomModel("");
|
||||
onValueChange("openrouter:");
|
||||
return;
|
||||
}
|
||||
|
||||
const firstModel = modelsByProvider[newProvider][0];
|
||||
if (firstModel) {
|
||||
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) => {
|
||||
setCustomModel(modelName);
|
||||
onValueChange(modelName);
|
||||
onValueChange(`${currentProvider}:${modelName}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start p-6">
|
||||
{/* Descrição */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Definir modelo de análise</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Escolha o provedor de IA e o modelo específico que será utilizado para
|
||||
gerar insights sobre seus dados financeiros. <br />
|
||||
Diferentes modelos podem oferecer perspectivas variadas na análise.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Seletor */}
|
||||
<div className="flex flex-col gap-4 min-w-xs">
|
||||
<RadioGroup
|
||||
value={currentProvider}
|
||||
onValueChange={(v) => handleProviderChange(v as AIProvider)}
|
||||
disabled={disabled}
|
||||
className="gap-3"
|
||||
>
|
||||
{(Object.keys(PROVIDERS) as AIProvider[]).map((providerId) => {
|
||||
const provider = PROVIDERS[providerId];
|
||||
const iconPaths = PROVIDER_ICON_PATHS[providerId];
|
||||
|
||||
return (
|
||||
<div key={providerId} className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
value={providerId}
|
||||
id={`provider-${providerId}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="size-6 relative">
|
||||
<Image
|
||||
src={iconPaths.light}
|
||||
alt={provider.name}
|
||||
width={22}
|
||||
height={22}
|
||||
className={iconPaths.dark ? "dark:hidden" : ""}
|
||||
/>
|
||||
{iconPaths.dark && (
|
||||
<Image
|
||||
src={iconPaths.dark}
|
||||
alt={provider.name}
|
||||
width={22}
|
||||
height={22}
|
||||
className="hidden dark:block"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Label
|
||||
htmlFor={`provider-${providerId}`}
|
||||
className="text-sm font-medium cursor-pointer flex-1"
|
||||
>
|
||||
{provider.name}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
|
||||
{/* Seletor de Modelo */}
|
||||
{currentProvider === "openrouter" ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={customModel}
|
||||
onChange={(e) => handleCustomModelChange(e.target.value)}
|
||||
placeholder="Ex: anthropic/claude-3.5-sonnet"
|
||||
disabled={disabled}
|
||||
className="border-none bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
<a
|
||||
href="https://openrouter.ai/models"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiExternalLinkLine className="h-3 w-3" />
|
||||
Ver modelos disponíveis no OpenRouter
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
<section className="space-y-4">
|
||||
<div className="grid items-stretch gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="space-y-4">
|
||||
<ProviderSelectionCard
|
||||
currentProvider={currentProvider}
|
||||
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>
|
||||
)}
|
||||
onProviderChange={handleProviderChange}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<AnalysisSummaryCard
|
||||
period={period}
|
||||
currentProvider={currentProvider}
|
||||
selectedModelLabel={selectedModelLabel}
|
||||
userInstructions={userInstructions}
|
||||
onUserInstructionsChange={onUserInstructionsChange}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
59
src/features/insights/components/provider-icon.tsx
Normal file
59
src/features/insights/components/provider-icon.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Image from "next/image";
|
||||
import { type AIProvider, PROVIDERS } from "@/features/insights/constants";
|
||||
|
||||
const PROVIDER_ICON_PATHS: Partial<
|
||||
Record<
|
||||
AIProvider,
|
||||
{ light: string; dark?: string; width?: number; height?: number }
|
||||
>
|
||||
> = {
|
||||
openai: {
|
||||
light: "/providers/chatgpt.svg",
|
||||
dark: "/providers/chatgpt_dark_mode.svg",
|
||||
},
|
||||
anthropic: {
|
||||
light: "/providers/claude.svg",
|
||||
},
|
||||
google: {
|
||||
light: "/providers/gemini.svg",
|
||||
},
|
||||
minimax: {
|
||||
light: "/providers/minimax.svg",
|
||||
},
|
||||
openrouter: {
|
||||
light: "/providers/openrouter_light.svg",
|
||||
dark: "/providers/openrouter_dark.svg",
|
||||
},
|
||||
ollama: {
|
||||
light: "/providers/ollama_light.svg",
|
||||
dark: "/providers/ollama_dark.svg",
|
||||
width: 17,
|
||||
height: 22,
|
||||
},
|
||||
};
|
||||
|
||||
export function ProviderIcon({ provider }: { provider: AIProvider }) {
|
||||
const iconPaths = PROVIDER_ICON_PATHS[provider];
|
||||
if (!iconPaths) return null;
|
||||
|
||||
return (
|
||||
<div className="relative flex size-10 items-center justify-center">
|
||||
<Image
|
||||
src={iconPaths.light}
|
||||
alt={PROVIDERS[provider].name}
|
||||
width={iconPaths.width ?? 32}
|
||||
height={iconPaths.height ?? 32}
|
||||
className={iconPaths.dark ? "dark:hidden" : ""}
|
||||
/>
|
||||
{iconPaths.dark && (
|
||||
<Image
|
||||
src={iconPaths.dark}
|
||||
alt={PROVIDERS[provider].name}
|
||||
width={iconPaths.width ?? 32}
|
||||
height={iconPaths.height ?? 32}
|
||||
className="hidden dark:block"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/features/insights/components/provider-selection-card.tsx
Normal file
109
src/features/insights/components/provider-selection-card.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { RiCheckLine } from "@remixicon/react";
|
||||
import { type AIProvider, PROVIDERS } from "@/features/insights/constants";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { ProviderIcon } from "./provider-icon";
|
||||
|
||||
const PROVIDER_DETAILS: Record<AIProvider, { description: string }> = {
|
||||
openai: {
|
||||
description: "Qualidade e equilíbrio entre análise e custo.",
|
||||
},
|
||||
anthropic: {
|
||||
description: "Forte em raciocínio e análises profundas.",
|
||||
},
|
||||
google: {
|
||||
description: "Ideal para integração e velocidade.",
|
||||
},
|
||||
minimax: {
|
||||
description: "Eficiente para grandes volumes de dados.",
|
||||
},
|
||||
openrouter: {
|
||||
description: "Acesso a múltiplos modelos via API.",
|
||||
},
|
||||
ollama: {
|
||||
description: "Execução local com privacidade total.",
|
||||
},
|
||||
};
|
||||
|
||||
interface ProviderSelectionCardProps {
|
||||
currentProvider: AIProvider;
|
||||
disabled?: boolean;
|
||||
onProviderChange: (provider: AIProvider) => void;
|
||||
}
|
||||
|
||||
export function ProviderSelectionCard({
|
||||
currentProvider,
|
||||
disabled,
|
||||
onProviderChange,
|
||||
}: ProviderSelectionCardProps) {
|
||||
return (
|
||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Definir modelo de análise
|
||||
</h2>
|
||||
<p className="max-w-2xl text-muted-foreground text-sm leading-relaxed">
|
||||
Escolha o provedor de IA e o modelo específico que serão usados para
|
||||
gerar insights sobre seus dados financeiros. Diferentes modelos
|
||||
podem oferecer perspectivas variadas na análise.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-sm">1. Provedor de IA</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Selecione o provedor que melhor atende às suas necessidades.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{(Object.keys(PROVIDERS) as AIProvider[]).map((providerId) => {
|
||||
const provider = PROVIDERS[providerId];
|
||||
const details = PROVIDER_DETAILS[providerId];
|
||||
const isSelected = currentProvider === providerId;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={providerId}
|
||||
onClick={() => onProviderChange(providerId)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group relative rounded-2xl border p-4 text-left transition-all hover:border-primary/60 hover:bg-primary/5 disabled:cursor-not-allowed disabled:opacity-70",
|
||||
isSelected &&
|
||||
"border-primary bg-primary/10 shadow-sm ring-1 ring-primary/20",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex size-4 shrink-0 items-center justify-center rounded-full border text-transparent transition-colors",
|
||||
isSelected &&
|
||||
"border-primary bg-primary text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
<RiCheckLine className="size-3" />
|
||||
</div>
|
||||
|
||||
<ProviderIcon provider={providerId} />
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<span className="font-semibold text-sm leading-none">
|
||||
{provider.name}
|
||||
</span>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{details.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
/**
|
||||
* Tipos de providers disponíveis
|
||||
*/
|
||||
export type AIProvider = "openai" | "anthropic" | "google" | "openrouter";
|
||||
export type AIProvider =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "google"
|
||||
| "minimax"
|
||||
| "openrouter"
|
||||
| "ollama";
|
||||
|
||||
/**
|
||||
* Metadados dos providers
|
||||
@@ -10,22 +16,26 @@ export const PROVIDERS = {
|
||||
openai: {
|
||||
id: "openai" as const,
|
||||
name: "ChatGPT",
|
||||
icon: "RiOpenaiLine",
|
||||
},
|
||||
anthropic: {
|
||||
id: "anthropic" as const,
|
||||
name: "Claude AI",
|
||||
icon: "RiRobot2Line",
|
||||
},
|
||||
google: {
|
||||
id: "google" as const,
|
||||
name: "Gemini",
|
||||
icon: "RiGoogleLine",
|
||||
},
|
||||
minimax: {
|
||||
id: "minimax" as const,
|
||||
name: "MiniMax",
|
||||
},
|
||||
openrouter: {
|
||||
id: "openrouter" as const,
|
||||
name: "OpenRouter",
|
||||
icon: "RiRouterLine",
|
||||
},
|
||||
ollama: {
|
||||
id: "ollama" as const,
|
||||
name: "Ollama",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -42,8 +52,8 @@ export const AVAILABLE_MODELS = [
|
||||
|
||||
// Anthropic
|
||||
{
|
||||
id: "claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
provider: "anthropic" as const,
|
||||
},
|
||||
{
|
||||
@@ -73,6 +83,49 @@ export const AVAILABLE_MODELS = [
|
||||
name: "Gemini 3.1 Flash Lite",
|
||||
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;
|
||||
|
||||
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;
|
||||
@@ -50,7 +50,9 @@ export function buildReadOnlyOptionSets(
|
||||
categoriaOptionsMap.set(item.categoryId, {
|
||||
value: item.categoryId,
|
||||
label: normalizeOptionLabel(item.categoriaName, "Category"),
|
||||
group: item.categoriaType,
|
||||
slug: item.categoryId,
|
||||
icon: item.categoriaIcon,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -67,6 +69,8 @@ export function buildReadOnlyOptionSets(
|
||||
(option) => ({
|
||||
slug: option.value,
|
||||
label: option.label,
|
||||
type: option.group,
|
||||
icon: option.icon,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -31,8 +31,12 @@ import {
|
||||
getCurrentPeriod,
|
||||
periodToDate,
|
||||
} from "@/shared/utils/period";
|
||||
import { slugify } from "@/shared/utils/string";
|
||||
import type { CategoryReportFiltersProps } from "./types";
|
||||
|
||||
const getCategorySearchValue = (name: string, id: string) =>
|
||||
`${name} ${slugify(name)} ${id}`;
|
||||
|
||||
/**
|
||||
* Category Report Filters Component
|
||||
* Provides filters for categories selection and date range
|
||||
@@ -53,7 +57,14 @@ export function CategoryReportFilters({
|
||||
const filteredCategories = useMemo(() => {
|
||||
if (!searchValue) return categories;
|
||||
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]);
|
||||
|
||||
// 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
|
||||
const handleClearAll = () => {
|
||||
onFiltersChange({
|
||||
@@ -130,11 +132,9 @@ export function CategoryReportFilters({
|
||||
const selectedText =
|
||||
selectedCategories.length === 0
|
||||
? "Categoria"
|
||||
: selectedCategories.length === categories.length
|
||||
? "Todas"
|
||||
: selectedCategories.length === 1
|
||||
? selectedCategories[0].name
|
||||
: `${selectedCategories.length} selecionadas`;
|
||||
: selectedCategories.length === 1
|
||||
? selectedCategories[0].name
|
||||
: `${selectedCategories.length} selecionadas`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -168,25 +168,18 @@ export function CategoryReportFilters({
|
||||
<CommandList>
|
||||
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{/* Select All / Clear All */}
|
||||
<div className="flex gap-1 p-2 border-b">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs flex-1"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
Todas
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs flex-1"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
{filters.selectedCategories.length > 0 ? (
|
||||
<div className="p-2 border-b">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
Limpar seleção
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Category List */}
|
||||
{filteredCategories.map((category) => {
|
||||
@@ -200,7 +193,10 @@ export function CategoryReportFilters({
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.id}
|
||||
value={getCategorySearchValue(
|
||||
category.name,
|
||||
category.id,
|
||||
)}
|
||||
onSelect={() => handleCategoryToggle(category.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
|
||||
@@ -5,7 +5,6 @@ import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { formatPeriodLabel } from "@/features/reports/lib/utils";
|
||||
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||
import StatusDot from "@/shared/components/feedback/status-dot";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
@@ -37,6 +36,13 @@ export function CategoryTable({
|
||||
categories,
|
||||
periods,
|
||||
}: CategoryTableProps) {
|
||||
const categoryColumnLabel =
|
||||
title === "Despesas"
|
||||
? "Categoria Despesa"
|
||||
: title === "Receitas"
|
||||
? "Categoria Receita"
|
||||
: "Categoria";
|
||||
|
||||
// Calculate section totals
|
||||
const sectionTotals = useMemo(() => {
|
||||
const totalsMap = new Map<string, number>();
|
||||
@@ -73,7 +79,7 @@ export function CategoryTable({
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[240px] min-w-[240px] font-medium">
|
||||
Categoria
|
||||
{categoryColumnLabel}
|
||||
</TableHead>
|
||||
{periods.map((period) => (
|
||||
<TableHead
|
||||
@@ -114,14 +120,6 @@ export function CategoryTable({
|
||||
<TableRow key={category.categoryId}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot
|
||||
color={
|
||||
category.type === "receita"
|
||||
? "bg-success"
|
||||
: "bg-destructive"
|
||||
}
|
||||
/>
|
||||
|
||||
<CategoryIconBadge
|
||||
icon={category.icon}
|
||||
name={category.name}
|
||||
|
||||
@@ -155,14 +155,20 @@ export async function validateAllOwnership(
|
||||
fields: {
|
||||
payerId?: string | null;
|
||||
secondaryPayerId?: string | null;
|
||||
splitPayerIds?: Array<string | null | undefined>;
|
||||
categoryId?: string | null;
|
||||
accountId?: string | null;
|
||||
cardId?: string | null;
|
||||
},
|
||||
): Promise<string | null> {
|
||||
const payerIds = [
|
||||
fields.payerId,
|
||||
fields.secondaryPayerId,
|
||||
...(fields.splitPayerIds ?? []),
|
||||
];
|
||||
const [ownedPayerIds, ownedCategoryIds, ownedAccountIds, ownedCardIds] =
|
||||
await Promise.all([
|
||||
fetchOwnedPayerIds(userId, [fields.payerId, fields.secondaryPayerId]),
|
||||
fetchOwnedPayerIds(userId, payerIds),
|
||||
fetchOwnedCategoryIds(userId, [fields.categoryId]),
|
||||
fetchOwnedAccountIds(userId, [fields.accountId]),
|
||||
fetchOwnedCardIds(userId, [fields.cardId]),
|
||||
@@ -171,6 +177,7 @@ export async function validateAllOwnership(
|
||||
const checks = [
|
||||
!fields.payerId || ownedPayerIds.has(fields.payerId),
|
||||
!fields.secondaryPayerId || ownedPayerIds.has(fields.secondaryPayerId),
|
||||
(fields.splitPayerIds ?? []).every((id) => !id || ownedPayerIds.has(id)),
|
||||
!fields.categoryId || ownedCategoryIds.has(fields.categoryId),
|
||||
!fields.accountId || ownedAccountIds.has(fields.accountId),
|
||||
!fields.cardId || ownedCardIds.has(fields.cardId),
|
||||
@@ -178,7 +185,8 @@ export async function validateAllOwnership(
|
||||
|
||||
const errors = [
|
||||
"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.",
|
||||
"Conta não encontrada.",
|
||||
"Cartão não encontrado.",
|
||||
@@ -322,6 +330,14 @@ const baseFields = z.object({
|
||||
}),
|
||||
payerId: uuidSchema("Payer").nullable().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),
|
||||
primarySplitAmount: z.coerce.number().min(0).optional(),
|
||||
secondarySplitAmount: z.coerce.number().min(0).optional(),
|
||||
@@ -434,6 +450,8 @@ const refineLancamento = (
|
||||
}
|
||||
|
||||
if (data.isSplit) {
|
||||
const shares = resolveSplitShares(data);
|
||||
|
||||
if (!data.payerId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -442,30 +460,38 @@ const refineLancamento = (
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.secondaryPayerId) {
|
||||
if (shares.length < 2) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secondaryPayerId"],
|
||||
message: "Selecione a pessoa secundário 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.",
|
||||
path: ["splitShares"],
|
||||
message: "Selecione pelo menos uma pessoa para dividir o lançamento.",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
data.primarySplitAmount !== undefined &&
|
||||
data.secondarySplitAmount !== undefined
|
||||
) {
|
||||
const sum = data.primarySplitAmount + data.secondarySplitAmount;
|
||||
const uniquePayerIds = new Set(shares.map((share) => share.payerId));
|
||||
if (uniquePayerIds.size !== shares.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
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);
|
||||
if (Math.abs(sum - total) > 0.01) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["primarySplitAmount"],
|
||||
path: ["splitShares"],
|
||||
message: "A soma das divisões deve ser igual ao valor total.",
|
||||
});
|
||||
}
|
||||
@@ -561,11 +587,41 @@ type Share = {
|
||||
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 = ({
|
||||
totalCents,
|
||||
payerId,
|
||||
isSplit,
|
||||
secondaryPayerId,
|
||||
splitShares,
|
||||
primarySplitAmountCents,
|
||||
secondarySplitAmountCents,
|
||||
}: {
|
||||
@@ -573,10 +629,18 @@ export const buildShares = ({
|
||||
payerId: string | null;
|
||||
isSplit: boolean;
|
||||
secondaryPayerId?: string;
|
||||
splitShares?: SplitShareInput[];
|
||||
primarySplitAmountCents?: number;
|
||||
secondarySplitAmountCents?: number;
|
||||
}): Share[] => {
|
||||
if (isSplit) {
|
||||
if (splitShares && splitShares.length > 0) {
|
||||
return splitShares.map((share) => ({
|
||||
payerId: share.payerId,
|
||||
amountCents: Math.round(share.amount * 100),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!payerId || !secondaryPayerId) {
|
||||
throw new Error("Configuração de divisão inválida para o lançamento.");
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function createTransactionAction(
|
||||
const ownershipError = await validateAllOwnership(user.id, {
|
||||
payerId: data.payerId,
|
||||
secondaryPayerId: data.secondaryPayerId,
|
||||
splitPayerIds: data.splitShares?.map((share) => share.payerId),
|
||||
categoryId: data.categoryId,
|
||||
accountId: data.accountId,
|
||||
cardId: data.cardId,
|
||||
@@ -84,6 +85,7 @@ export async function createTransactionAction(
|
||||
payerId: data.payerId ?? null,
|
||||
isSplit: data.isSplit ?? false,
|
||||
secondaryPayerId: data.secondaryPayerId,
|
||||
splitShares: data.splitShares,
|
||||
primarySplitAmountCents: data.primarySplitAmount
|
||||
? Math.round(data.primarySplitAmount * 100)
|
||||
: undefined,
|
||||
@@ -207,6 +209,7 @@ export async function updateTransactionAction(
|
||||
const ownershipError = await validateAllOwnership(user.id, {
|
||||
payerId: data.payerId,
|
||||
secondaryPayerId: data.secondaryPayerId,
|
||||
splitPayerIds: data.splitShares?.map((share) => share.payerId),
|
||||
categoryId: data.categoryId,
|
||||
accountId: data.accountId,
|
||||
cardId: data.cardId,
|
||||
@@ -477,6 +480,7 @@ export async function updateTransactionSplitPairAction(
|
||||
|
||||
const ownershipError = await validateAllOwnership(user.id, {
|
||||
payerId: data.payerId,
|
||||
splitPayerIds: data.splitShares?.map((share) => share.payerId),
|
||||
categoryId: data.categoryId,
|
||||
accountId: data.accountId,
|
||||
cardId: data.cardId,
|
||||
|
||||
@@ -39,8 +39,8 @@ export function SplitPairDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar lançamento dividido</DialogTitle>
|
||||
<DialogDescription>
|
||||
Este lançamento está dividido com outra pessoa. Escolha o que deseja
|
||||
editar:
|
||||
Este lançamento está dividido com outras pessoas. Escolha o que
|
||||
deseja editar:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -63,7 +63,7 @@ export function SplitPairDialog({
|
||||
Apenas este lançamento
|
||||
</Label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,11 +75,11 @@ export function SplitPairDialog({
|
||||
htmlFor="split-both"
|
||||
className="text-sm cursor-pointer font-medium"
|
||||
>
|
||||
Atualizar os dois lançamentos
|
||||
Atualizar toda a divisão
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Aplica nome, data, categoria e outros campos compartilhados
|
||||
nos dois lados da divisão
|
||||
Aplica nome, data, categoria e outros campos compartilhados em
|
||||
todo o grupo da divisão
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { RiSliceFill } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/shared/components/ui/avatar";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
@@ -13,120 +17,48 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/shared/components/ui/toggle-group";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDecimalForDbRequired,
|
||||
normalizeDecimalInput,
|
||||
} from "@/shared/utils/currency";
|
||||
import { safeToNumber } from "@/shared/utils/number";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { PayerSelectContent } from "../../select-items";
|
||||
import { getSplitSummaryData, SplitConfigDialog } from "./split-config-dialog";
|
||||
import type { PayerSectionProps } from "./transaction-dialog-types";
|
||||
|
||||
type SplitInputMode = "currency" | "percentage";
|
||||
type SplitSummary = ReturnType<typeof getSplitSummaryData>;
|
||||
|
||||
const SPLIT_MODE_OPTIONS = [
|
||||
{ value: "currency", label: "R$" },
|
||||
{ value: "percentage", label: "%" },
|
||||
] 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"
|
||||
/>
|
||||
);
|
||||
function SplitSummaryContent({ summary }: { summary: SplitSummary }) {
|
||||
if (summary.type === "text") {
|
||||
return <p className="text-xs text-muted-foreground">{summary.label}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[45%] space-y-1">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={amountToPercent(value, totalAmount)}
|
||||
onChange={(event) => {
|
||||
const sanitized = event.target.value.replace(/[^\d.,]/g, "");
|
||||
onAmountChange(percentToAmount(sanitized, totalAmount));
|
||||
}}
|
||||
placeholder="0"
|
||||
aria-label={ariaLabel}
|
||||
className="h-9 w-full 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>
|
||||
<p className="ml-1 text-xs text-muted-foreground">
|
||||
{formatCurrency(safeToNumber(value))}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{summary.count} pessoas:</span>
|
||||
{summary.participants.map((participant, index) => {
|
||||
const initial = participant.label.charAt(0).toUpperCase() || "?";
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`${participant.label}-${index}`}
|
||||
className="inline-flex min-w-0 items-center gap-0.5"
|
||||
>
|
||||
<Avatar className="size-4 border border-border/60 bg-background">
|
||||
<AvatarImage
|
||||
src={getAvatarSrc(participant.avatarUrl)}
|
||||
alt={`Avatar de ${participant.label}`}
|
||||
/>
|
||||
<AvatarFallback className="text-[0.55rem] font-medium uppercase">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{participant.firstName}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{summary.remainingCount > 0 ? (
|
||||
<span>+{summary.remainingCount}</span>
|
||||
) : null}
|
||||
<span aria-hidden>·</span>
|
||||
<span>{summary.totalLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,53 +67,93 @@ export function PayerSection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
payerOptions,
|
||||
secondaryPayerOptions,
|
||||
splitPayerOptions,
|
||||
totalAmount,
|
||||
}: PayerSectionProps) {
|
||||
const [splitMode, setSplitMode] = useState<SplitInputMode>("currency");
|
||||
const [splitConfigOpen, setSplitConfigOpen] = useState(false);
|
||||
const splitSummary = getSplitSummaryData(
|
||||
formState,
|
||||
payerOptions,
|
||||
totalAmount,
|
||||
);
|
||||
|
||||
const handlePrimaryAmountChange = (value: string) => {
|
||||
onFieldChange("primarySplitAmount", value);
|
||||
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
||||
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
|
||||
const handleSplitToggle = (checked: boolean) => {
|
||||
onFieldChange("isSplit", checked);
|
||||
|
||||
if (checked) {
|
||||
setSplitConfigOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecondaryAmountChange = (value: string) => {
|
||||
onFieldChange("secondarySplitAmount", value);
|
||||
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
||||
onFieldChange("primarySplitAmount", remaining.toFixed(2));
|
||||
const handleSplitCardClick = () => {
|
||||
if (formState.isSplit) {
|
||||
setSplitConfigOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
handleSplitToggle(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="payer">Pessoa</Label>
|
||||
<Select
|
||||
value={formState.payerId ?? ""}
|
||||
onValueChange={(value) => onFieldChange("payerId", value)}
|
||||
>
|
||||
<SelectTrigger id="payer" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.payerId &&
|
||||
(() => {
|
||||
const selectedOption = payerOptions.find(
|
||||
(opt) => opt.value === formState.payerId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PayerSelectContent
|
||||
label={selectedOption.label}
|
||||
avatarUrl={selectedOption.avatarUrl}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{payerOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PayerSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
|
||||
"rounded-lg border px-3 py-2.5 transition-colors",
|
||||
formState.isSplit
|
||||
? "border-primary/20 bg-primary/5"
|
||||
: "border-border bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<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>
|
||||
<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}
|
||||
<SplitSummaryContent summary={splitSummary} />
|
||||
</button>
|
||||
<CheckboxPrimitive.Root
|
||||
checked={formState.isSplit}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("isSplit", Boolean(checked))
|
||||
}
|
||||
onCheckedChange={(checked) => handleSplitToggle(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]",
|
||||
"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",
|
||||
@@ -192,110 +164,29 @@ export function PayerSection({
|
||||
</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>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formState.payerId ?? ""}
|
||||
onValueChange={(value) => onFieldChange("payerId", value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="payer"
|
||||
className={formState.isSplit ? "min-w-0 flex-1" : "w-full"}
|
||||
>
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.payerId &&
|
||||
(() => {
|
||||
const selectedOption = payerOptions.find(
|
||||
(opt) => opt.value === formState.payerId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PayerSelectContent
|
||||
label={selectedOption.label}
|
||||
avatarUrl={selectedOption.avatarUrl}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{payerOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PayerSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.isSplit ? (
|
||||
<SplitAmountField
|
||||
mode={splitMode}
|
||||
value={formState.primarySplitAmount}
|
||||
totalAmount={totalAmount}
|
||||
onAmountChange={handlePrimaryAmountChange}
|
||||
ariaLabel="Porcentagem da pessoa"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formState.isSplit ? (
|
||||
<div className="w-full space-y-1 mb-1">
|
||||
<Label htmlFor="secondaryPayer">Dividir com</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formState.secondaryPayerId ?? ""}
|
||||
onValueChange={(value) =>
|
||||
onFieldChange("secondaryPayerId", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="secondaryPayer"
|
||||
disabled={secondaryPayerOptions.length === 0}
|
||||
className="w-[55%]"
|
||||
>
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.secondaryPayerId &&
|
||||
(() => {
|
||||
const selectedOption = secondaryPayerOptions.find(
|
||||
(opt) => opt.value === formState.secondaryPayerId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PayerSelectContent
|
||||
label={selectedOption.label}
|
||||
avatarUrl={selectedOption.avatarUrl}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{secondaryPayerOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PayerSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SplitAmountField
|
||||
mode={splitMode}
|
||||
value={formState.secondarySplitAmount}
|
||||
totalAmount={totalAmount}
|
||||
onAmountChange={handleSecondaryAmountChange}
|
||||
ariaLabel="Porcentagem do segundo pagador"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3 w-full"
|
||||
onClick={() => setSplitConfigOpen(true)}
|
||||
>
|
||||
Editar divisão
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SplitConfigDialog
|
||||
open={splitConfigOpen}
|
||||
onOpenChange={setSplitConfigOpen}
|
||||
formState={formState}
|
||||
onFieldChange={onFieldChange}
|
||||
payerOptions={payerOptions}
|
||||
splitPayerOptions={splitPayerOptions}
|
||||
totalAmount={totalAmount}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export interface CategorySectionProps extends BaseFieldSectionProps {
|
||||
|
||||
export interface PayerSectionProps extends BaseFieldSectionProps {
|
||||
payerOptions: SelectOption[];
|
||||
secondaryPayerOptions: SelectOption[];
|
||||
splitPayerOptions: SelectOption[];
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
detachTransactionAttachmentAction,
|
||||
getPresignedUploadUrlAction,
|
||||
} from "@/features/transactions/actions/attachments";
|
||||
import {
|
||||
filterSecondaryPayerOptions,
|
||||
groupAndSortCategories,
|
||||
} from "@/features/transactions/lib/category-helpers";
|
||||
import { groupAndSortCategories } from "@/features/transactions/lib/category-helpers";
|
||||
import {
|
||||
applyFieldDependencies,
|
||||
buildTransactionInitialState,
|
||||
@@ -50,6 +47,7 @@ import type {
|
||||
FormState,
|
||||
TransactionDialogProps,
|
||||
} from "./transaction-dialog-types";
|
||||
import { TransactionSummaryCard } from "./transaction-summary-card";
|
||||
|
||||
export function TransactionDialog({
|
||||
mode,
|
||||
@@ -166,13 +164,6 @@ export function TransactionDialog({
|
||||
mode,
|
||||
]);
|
||||
|
||||
const primaryPayerId = formState.payerId;
|
||||
|
||||
const secondaryPayerOptions = useMemo(
|
||||
() => filterSecondaryPayerOptions(splitPayerOptions, primaryPayerId),
|
||||
[splitPayerOptions, primaryPayerId],
|
||||
);
|
||||
|
||||
const categoryGroups = useMemo(() => {
|
||||
const filtered = categoryOptions.filter(
|
||||
(option) =>
|
||||
@@ -259,14 +250,6 @@ export function TransactionDialog({
|
||||
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);
|
||||
if (Number.isNaN(amountValue)) {
|
||||
const message = "Informe um valor válido.";
|
||||
@@ -276,6 +259,44 @@ export function TransactionDialog({
|
||||
}
|
||||
|
||||
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) {
|
||||
const message = "Selecione uma categoria.";
|
||||
@@ -309,9 +330,7 @@ export function TransactionDialog({
|
||||
paymentMethod:
|
||||
formState.paymentMethod as CreateTransactionInput["paymentMethod"],
|
||||
payerId: formState.payerId ?? null,
|
||||
secondaryPayerId: formState.isSplit
|
||||
? formState.secondaryPayerId
|
||||
: undefined,
|
||||
splitShares: normalizedSplitShares,
|
||||
isSplit: formState.isSplit,
|
||||
primarySplitAmount: formState.isSplit
|
||||
? Number.parseFloat(formState.primarySplitAmount) || undefined
|
||||
@@ -598,7 +617,7 @@ export function TransactionDialog({
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
payerOptions={payerOptions}
|
||||
secondaryPayerOptions={secondaryPayerOptions}
|
||||
splitPayerOptions={splitPayerOptions}
|
||||
totalAmount={totalAmount}
|
||||
/>
|
||||
|
||||
@@ -671,7 +690,10 @@ export function TransactionDialog({
|
||||
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">
|
||||
<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
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="min-w-0 overflow-hidden space-y-3 pt-3">
|
||||
@@ -707,6 +729,16 @@ export function TransactionDialog({
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<TransactionSummaryCard
|
||||
formState={formState}
|
||||
payerOptions={payerOptions}
|
||||
accountOptions={accountOptions}
|
||||
cardOptions={cardOptions}
|
||||
categoryOptions={categoryOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function PayerSelectContent({
|
||||
|
||||
return (
|
||||
<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}`} />
|
||||
<AvatarFallback className="text-xs font-medium uppercase">
|
||||
{initial}
|
||||
|
||||
@@ -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 {
|
||||
RiAttachment2,
|
||||
RiBankCard2Line,
|
||||
RiChat1Line,
|
||||
RiCheckboxBlankCircleLine,
|
||||
RiCheckboxCircleFill,
|
||||
RiCheckLine,
|
||||
RiDeleteBin5Line,
|
||||
RiFileCopyLine,
|
||||
RiFileList2Line,
|
||||
RiGroupLine,
|
||||
RiHistoryLine,
|
||||
RiMoreFill,
|
||||
RiPencilLine,
|
||||
RiRefundLine,
|
||||
RiTimeLine,
|
||||
} from "@remixicon/react";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
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 {
|
||||
CategoryIconBadge,
|
||||
EstablishmentLogo,
|
||||
@@ -35,28 +20,20 @@ import {
|
||||
AvatarImage,
|
||||
} from "@/shared/components/ui/avatar";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
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 BuildColumnsArgs = {
|
||||
currentUserId: string;
|
||||
@@ -551,195 +528,23 @@ function buildColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const paymentMethod = row.original.paymentMethod;
|
||||
const isCreditCard = paymentMethod === CREDIT_CARD_PAYMENT_METHOD;
|
||||
const canToggleSettlement = (
|
||||
SETTLEABLE_PAYMENT_METHODS as readonly string[]
|
||||
).includes(paymentMethod);
|
||||
|
||||
if (!canToggleSettlement && !isCreditCard) return null;
|
||||
|
||||
if (isCreditCard) {
|
||||
const invoicePaid = Boolean(row.original.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" />
|
||||
) : (
|
||||
<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.readonly &&
|
||||
row.original.userId === currentUserId && (
|
||||
<DropdownMenuItem onSelect={() => handleCopy(row.original)}>
|
||||
<RiFileCopyLine className="size-4" />
|
||||
Copiar
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!row.original.readonly &&
|
||||
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>
|
||||
<TransactionSettlementButton
|
||||
item={row.original}
|
||||
isLoading={isSettlementLoading(row.original.id)}
|
||||
onToggle={handleToggleSettlement}
|
||||
/>
|
||||
<TransactionActionsMenu
|
||||
item={row.original}
|
||||
currentUserId={currentUserId}
|
||||
onEdit={handleEdit}
|
||||
onCopy={handleCopy}
|
||||
onImport={handleImport}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onViewDetails={handleViewDetails}
|
||||
onRefund={handleRefund}
|
||||
onAnticipate={handleAnticipate}
|
||||
onViewAnticipationHistory={handleViewAnticipationHistory}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
SelectTrigger,
|
||||
} 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 {
|
||||
ToggleGroup,
|
||||
@@ -193,6 +194,16 @@ type MultiOption = {
|
||||
render?: ReactNode;
|
||||
};
|
||||
|
||||
const getCategoryFilterGroup = (type?: string | null) => {
|
||||
if (type === "receita") {
|
||||
return "Receitas";
|
||||
}
|
||||
if (type === "despesa") {
|
||||
return "Despesas";
|
||||
}
|
||||
return "Outras";
|
||||
};
|
||||
|
||||
interface MultiSelectFilterProps {
|
||||
placeholder: string;
|
||||
options: MultiOption[];
|
||||
@@ -290,7 +301,10 @@ function MultiSelectFilter({
|
||||
>
|
||||
{triggerLabel}
|
||||
</span>
|
||||
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
<RiExpandUpDownLine
|
||||
className="ml-2 size-4 shrink-0 opacity-50"
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[260px] p-0">
|
||||
@@ -331,7 +345,10 @@ function MultiSelectFilter({
|
||||
{option.render ?? option.label}
|
||||
</span>
|
||||
{isSelected ? (
|
||||
<RiCheckLine className="ml-auto size-4 shrink-0" />
|
||||
<RiCheckLine
|
||||
className="ml-auto size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
);
|
||||
@@ -518,6 +535,7 @@ export function TransactionsFilters({
|
||||
categoryOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
group: getCategoryFilterGroup(option.type),
|
||||
render: (
|
||||
<CategorySelectContent label={option.label} icon={option.icon} />
|
||||
),
|
||||
@@ -585,6 +603,7 @@ export function TransactionsFilters({
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-busy={isPending}
|
||||
className={cn(
|
||||
"flex flex-col gap-2 md:flex-row md:flex-wrap md:items-center",
|
||||
className,
|
||||
@@ -608,7 +627,7 @@ export function TransactionsFilters({
|
||||
aria-label="Limpar busca"
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
<RiCloseLine className="size-4" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -630,12 +649,19 @@ export function TransactionsFilters({
|
||||
<Button
|
||||
variant="outline"
|
||||
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" />
|
||||
Filtros
|
||||
{isPending ? (
|
||||
<Spinner className="size-4" role="presentation" aria-hidden />
|
||||
) : (
|
||||
<RiFilterLine className="size-4" aria-hidden />
|
||||
)}
|
||||
{isPending ? "Aplicando..." : "Filtros"}
|
||||
{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>
|
||||
</DrawerTrigger>
|
||||
@@ -649,7 +675,7 @@ export function TransactionsFilters({
|
||||
aria-label="Limpar filtros"
|
||||
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
|
||||
</Button>
|
||||
)}
|
||||
@@ -746,6 +772,7 @@ export function TransactionsFilters({
|
||||
disabled={isPending}
|
||||
searchable
|
||||
searchPlaceholder="Buscar categoria..."
|
||||
groupOrder={["Despesas", "Receitas", "Outras"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -775,7 +802,7 @@ export function TransactionsFilters({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Período
|
||||
Intervalo de datas
|
||||
</label>
|
||||
{hasDateRangeFilter ? (
|
||||
<button
|
||||
@@ -932,15 +959,33 @@ export function TransactionsFilters({
|
||||
|
||||
<DrawerFooter>
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hasActiveFilters
|
||||
? `${activeFilterCount} ${
|
||||
activeFilterCount === 1
|
||||
? "filtro ativo"
|
||||
: "filtros ativos"
|
||||
}`
|
||||
: "Nenhum filtro ativo"}
|
||||
</span>
|
||||
<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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -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 { getTransactionColumns } from "./transactions-columns";
|
||||
import { TransactionsFilters } from "./transactions-filters";
|
||||
import { TransactionsMobileList } from "./transactions-mobile-list";
|
||||
import { TransactionsPagination } from "./transactions-pagination";
|
||||
|
||||
type TransactionsTableProps = {
|
||||
@@ -349,7 +350,23 @@ export function TransactionsTable({
|
||||
<CardContent className="px-2 py-4 sm:px-4">
|
||||
{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>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
|
||||
@@ -57,6 +57,7 @@ export type TransactionFilterOption = {
|
||||
label: string;
|
||||
icon?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
type?: string | null;
|
||||
};
|
||||
|
||||
export type AccountCardFilterOption = TransactionFilterOption & {
|
||||
|
||||
@@ -73,6 +73,7 @@ export type TransactionFormState = {
|
||||
paymentMethod: string;
|
||||
payerId: string | undefined;
|
||||
secondaryPayerId: string | undefined;
|
||||
splitShares: Array<{ payerId: string; amount: string }>;
|
||||
isSplit: boolean;
|
||||
primarySplitAmount: string;
|
||||
secondarySplitAmount: string;
|
||||
@@ -171,6 +172,7 @@ export function buildTransactionInitialState(
|
||||
paymentMethod,
|
||||
payerId: fallbackPayerId ?? undefined,
|
||||
secondaryPayerId: undefined,
|
||||
splitShares: [],
|
||||
isSplit: false,
|
||||
|
||||
primarySplitAmount: "",
|
||||
@@ -332,6 +334,7 @@ export function applyFieldDependencies(
|
||||
// When split is disabled, clear secondary pagador and split fields
|
||||
if (key === "isSplit" && value === false) {
|
||||
updates.secondaryPayerId = undefined;
|
||||
updates.splitShares = [];
|
||||
updates.primarySplitAmount = "";
|
||||
updates.secondarySplitAmount = "";
|
||||
}
|
||||
@@ -340,9 +343,8 @@ export function applyFieldDependencies(
|
||||
if (key === "isSplit" && value === true) {
|
||||
const totalAmount = Number.parseFloat(currentState.amount) || 0;
|
||||
if (totalAmount > 0) {
|
||||
const half = (totalAmount / 2).toFixed(2);
|
||||
updates.primarySplitAmount = half;
|
||||
updates.secondarySplitAmount = half;
|
||||
updates.primarySplitAmount = totalAmount.toFixed(2);
|
||||
updates.secondarySplitAmount = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,12 +352,20 @@ export function applyFieldDependencies(
|
||||
if (key === "amount" && typeof value === "string" && currentState.isSplit) {
|
||||
const totalAmount = Number.parseFloat(value) || 0;
|
||||
if (totalAmount > 0) {
|
||||
const half = (totalAmount / 2).toFixed(2);
|
||||
updates.primarySplitAmount = half;
|
||||
updates.secondarySplitAmount = half;
|
||||
const otherTotal = currentState.splitShares.reduce(
|
||||
(total, share) => total + (Number.parseFloat(share.amount) || 0),
|
||||
0,
|
||||
);
|
||||
updates.primarySplitAmount = Math.max(
|
||||
0,
|
||||
totalAmount - otherTotal,
|
||||
).toFixed(2);
|
||||
} else {
|
||||
updates.primarySplitAmount = "";
|
||||
updates.secondarySplitAmount = "";
|
||||
updates.splitShares = currentState.splitShares.map((share) => ({
|
||||
...share,
|
||||
amount: "",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +375,23 @@ export function applyFieldDependencies(
|
||||
if (secondaryValue && secondaryValue === value) {
|
||||
updates.secondaryPayerId = undefined;
|
||||
}
|
||||
if (currentState.splitShares.some((share) => share.payerId === value)) {
|
||||
const nextShares = currentState.splitShares.filter(
|
||||
(share) => share.payerId !== value,
|
||||
);
|
||||
updates.splitShares = nextShares;
|
||||
if (currentState.isSplit) {
|
||||
const totalAmount = Number.parseFloat(currentState.amount) || 0;
|
||||
const otherTotal = nextShares.reduce(
|
||||
(total, share) => total + (Number.parseFloat(share.amount) || 0),
|
||||
0,
|
||||
);
|
||||
updates.primarySplitAmount = Math.max(
|
||||
0,
|
||||
totalAmount - otherTotal,
|
||||
).toFixed(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When isSettled changes and payment method is Boleto
|
||||
|
||||
@@ -118,6 +118,9 @@ export type SlugMaps = {
|
||||
type FilterOption = {
|
||||
slug: string;
|
||||
label: string;
|
||||
icon?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
type?: string | null;
|
||||
};
|
||||
|
||||
type AccountCardFilterOption = FilterOption & {
|
||||
@@ -686,7 +689,12 @@ export const buildOptionSets = ({
|
||||
);
|
||||
|
||||
const categoryFilterOptions = sortByLabel(
|
||||
categoryFiltersRaw.map(({ slug, label, icon }) => ({ slug, label, icon })),
|
||||
categoryFiltersRaw.map(({ slug, label, type, icon }) => ({
|
||||
slug,
|
||||
label,
|
||||
type,
|
||||
icon,
|
||||
})),
|
||||
);
|
||||
|
||||
const accountCardFilterOptions = sortByLabel(
|
||||
|
||||
@@ -44,7 +44,10 @@ export function NavMenu() {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop */}
|
||||
<nav className="hidden md:flex items-center justify-center flex-1 gap-4">
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
className="hidden md:flex items-center justify-center flex-1 gap-4"
|
||||
>
|
||||
<NavigationMenu viewport={false}>
|
||||
<NavigationMenuList className="gap-2">
|
||||
<NavigationMenuItem>
|
||||
@@ -97,7 +100,7 @@ export function NavMenu() {
|
||||
size="icon-sm"
|
||||
className="-order-1 md:hidden"
|
||||
>
|
||||
<RiMenuLine className="size-5" />
|
||||
<RiMenuLine className="size-5" aria-hidden />
|
||||
<span className="sr-only">Abrir menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
@@ -105,10 +108,13 @@ export function NavMenu() {
|
||||
<SheetHeader className="border-b border-border/60 p-4">
|
||||
<SheetTitle>Menu</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="p-3 overflow-y-auto">
|
||||
<nav
|
||||
className="p-3 overflow-y-auto"
|
||||
aria-label="Menu principal mobile"
|
||||
>
|
||||
<MobileLink
|
||||
href="/dashboard"
|
||||
icon={<RiDashboardLine className="size-4" />}
|
||||
icon={<RiDashboardLine className="size-4" aria-hidden />}
|
||||
onClick={close}
|
||||
preservePeriod
|
||||
>
|
||||
|
||||
@@ -13,9 +13,9 @@ export default function PageDescription({
|
||||
<span className="text-primary">{icon}</span>
|
||||
{title}
|
||||
</h1>
|
||||
<h2 className="text-sm max-w-2xl text-muted-foreground leading-relaxed">
|
||||
<p className="text-sm max-w-2xl text-muted-foreground leading-relaxed">
|
||||
{subtitle}
|
||||
</h2>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,14 +66,15 @@ export function ExpandableWidgetCard({
|
||||
contentClassName={EXPANDABLE_CONTENT_CLASSNAME}
|
||||
overlay={
|
||||
hasOverflow ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-linear-to-t from-card to-transparent pt-12 pb-6">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-linear-to-t from-card to-transparent pt-8 pb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="pointer-events-auto text-xs"
|
||||
onClick={() => setIsOpen(true)}
|
||||
aria-label="Expandir para ver todo o conteúdo"
|
||||
>
|
||||
Ver tudo{" "}
|
||||
Expandir
|
||||
<RiExpandDiagonalLine className="size-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -94,7 +95,7 @@ export function ExpandableWidgetCard({
|
||||
<p className="text-muted-foreground text-sm">{subtitle}</p>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
<div className="scrollbar-hide max-h-[calc(85vh-6rem)] overflow-y-auto pb-6">
|
||||
<div className="-mr-3 max-h-[calc(85vh-6rem)] overflow-y-auto pb-6 pr-3 [scrollbar-gutter:stable]">
|
||||
{children}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -14,6 +14,25 @@ import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
|
||||
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
const DEFAULT_SESSION_EXPIRES_IN_DAYS = 30;
|
||||
const DEFAULT_SESSION_UPDATE_AGE_HOURS = 24;
|
||||
|
||||
function parsePositiveIntegerEnv(name: string, fallback: number): number {
|
||||
const value = process.env[name];
|
||||
if (!value) return fallback;
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
const sessionExpiresInDays = parsePositiveIntegerEnv(
|
||||
"AUTH_SESSION_EXPIRES_IN_DAYS",
|
||||
DEFAULT_SESSION_EXPIRES_IN_DAYS,
|
||||
);
|
||||
const sessionUpdateAgeHours = parsePositiveIntegerEnv(
|
||||
"AUTH_SESSION_UPDATE_AGE_HOURS",
|
||||
DEFAULT_SESSION_UPDATE_AGE_HOURS,
|
||||
);
|
||||
|
||||
/**
|
||||
* Extrai nome do usuário do perfil do Google com fallback hierárquico:
|
||||
@@ -77,6 +96,8 @@ export const auth = betterAuth({
|
||||
|
||||
// Session configuration - Safari compatibility
|
||||
session: {
|
||||
expiresIn: sessionExpiresInDays * 24 * 60 * 60,
|
||||
updateAge: sessionUpdateAgeHours * 60 * 60,
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 60 * 5, // 5 minutes
|
||||
|
||||
@@ -34,6 +34,7 @@ export const DEFAULT_CATEGORIES: DefaultCategory[] = [
|
||||
// Receitas
|
||||
{ name: "Salário", type: "receita", icon: "RiWallet3Line" },
|
||||
{ name: "Freelance", type: "receita", icon: "RiUserStarLine" },
|
||||
{ name: "Rendimentos", type: "receita", icon: "RiFundsLine" },
|
||||
{ name: "Investimentos", type: "receita", icon: "RiStockLine" },
|
||||
{ name: "Vendas", type: "receita", icon: "RiShoppingCartLine" },
|
||||
{ name: "Prêmios", type: "receita", icon: "RiMedalLine" },
|
||||
|
||||
Reference in New Issue
Block a user