Compare commits

...

12 Commits

Author SHA1 Message Date
Felipe Coutinho
e50eeba36e feat(insights): adiciona suporte ao Ollama 2026-05-28 20:01:44 -03:00
Felipe Coutinho
26cb18a9ad chore(release): prepara versao 2.7.0 2026-05-28 11:00:27 -03:00
Felipe Coutinho
382727a96d chore(deps): atualiza dependencias do workspace 2026-05-28 11:00:20 -03:00
Felipe Coutinho
0df648c7f3 chore(config): atualiza ambiente e setup 2026-05-28 11:00:14 -03:00
Felipe Coutinho
27f361923c style(ui): ajusta tipografia e descricoes 2026-05-28 10:59:46 -03:00
Felipe Coutinho
60b2612e8a feat(relatorios): refina indicadores e filtros 2026-05-28 10:59:36 -03:00
Felipe Coutinho
0171b0ce2f feat(lancamentos): refina filtros e tabela responsiva 2026-05-28 10:59:24 -03:00
Felipe Coutinho
311369f81b feat(lancamentos): amplia divisao e resumo do modal 2026-05-28 10:59:13 -03:00
Felipe Coutinho
ef2c8c50e8 feat(contas): adiciona rendimento pelo extrato 2026-05-28 10:58:59 -03:00
Felipe Coutinho
5319d8a5a6 feat(insights): adiciona suporte ao MiniMax 2026-05-28 10:58:52 -03:00
Felipe Coutinho
37247e319c feat(auth): adiciona sessao persistente no login 2026-05-28 10:58:43 -03:00
Felipe Coutinho
766af2b347 fix(ci): atualiza actions para checkout em Node 24 2026-05-23 13:27:22 -03:00
67 changed files with 3837 additions and 1347 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.6.4-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.7.0-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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",

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -229,14 +229,22 @@ if (await askYesNo(" E-mail via Resend (notificações e convites)?")) {
let anthropicKey = "";
let 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);

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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> {

View File

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

View File

@@ -17,6 +17,11 @@ import {
DialogTrigger,
} 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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -73,6 +73,7 @@ export function WidgetSettingsDialog({
</div>
</div>
<Switch
aria-label={`${isVisible ? "Ocultar" : "Exibir"} widget ${widget.title}`}
checked={isVisible}
onCheckedChange={() => onToggleWidget(widget.id)}
/>

View File

@@ -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,

View File

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

View File

@@ -405,6 +405,7 @@ const buildInstallmentExpensesData = (
dueDate: row.dueDate,
purchaseDate: row.purchaseDate,
period: row.period,
isSettled: row.isSettled,
}))
.sort((a, b) => {
const remainingA =

View File

@@ -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

View File

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

View File

@@ -5,7 +5,9 @@ import {
RiFlashlightLine,
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];

View File

@@ -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>
);

View File

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

View File

@@ -1,94 +1,96 @@
"use client";
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>
);
}

View File

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

View 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>
);
}

View File

@@ -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";

View 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.",
};
}

View File

@@ -0,0 +1 @@
export const USER_INSTRUCTIONS_MAX_LENGTH = 1000;

View File

@@ -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,
}),
);

View File

@@ -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"
>

View File

@@ -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}

View File

@@ -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.");
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -96,7 +96,7 @@ export interface CategorySectionProps extends BaseFieldSectionProps {
export interface PayerSectionProps extends BaseFieldSectionProps {
payerOptions: SelectOption[];
secondaryPayerOptions: SelectOption[];
splitPayerOptions: SelectOption[];
totalAmount: number;
}

View File

@@ -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 ? (

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
),
});

View File

@@ -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"

View File

@@ -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";
}

View File

@@ -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) => (

View File

@@ -57,6 +57,7 @@ export type TransactionFilterOption = {
label: string;
icon?: string | null;
avatarUrl?: string | null;
type?: string | null;
};
export type AccountCardFilterOption = TransactionFilterOption & {

View File

@@ -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

View File

@@ -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(

View File

@@ -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
>

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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" },