18 Commits

Author SHA1 Message Date
Felipe Coutinho
94bf93194f chore: ajustes de componentes, estilos, dependências e métricas do dashboard
- dashboard: melhorias em métricas, filtros de transações e overview de período
- transactions: colunas, tabela e página com novos campos e ajustes de exibição
- ui: card, table, navigation-menu, navbar, month-picker, logo-picker, theme-toggler
- calculator: ajustes de display, keypad e estado
- calendar: melhorias de grid e day-cell
- insights: atualização de constantes
- settings: pequenos ajustes
- pnpm-lock: atualização de dependências
- pdf.worker: atualização do worker

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:53 +00:00
Felipe Coutinho
d55173e8c1 refactor(transactions): remover exports mortos dateFormatter e monthFormatter
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:45 +00:00
Felipe Coutinho
4a73088c09 chore(assets): substituir dashboard-preview de webp para png
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:41 +00:00
Felipe Coutinho
eaa20448a8 chore(landing): remover seção de galeria de telas
Remove a seção "Veja o que você pode fazer" com o componente ScreenshotTabs,
as 14 imagens preview-*.webp, o link #telas do nav e o export pwaCompatList sem uso.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:37 +00:00
Felipe Coutinho
367d78d43d fix(dashboard/anotacoes): corrigir divergência de fuso no formatter de datas
Intl.DateTimeFormat sem timeZone usava o fuso do servidor (UTC) no SSR
e o fuso do browser (BRT) no cliente, causando erro de hidratação.
Ambos os formatters passam a usar timeZone: "America/Sao_Paulo" explicitamente.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:29 +00:00
Felipe Coutinho
2fc6d11d78 feat(dashboard/boletos): nome do boleto como link para lançamentos do período
- nome do boleto virou link para /transactions?q=<nome>
- quando o período selecionado não é o atual, inclui ?periodo=<mes-ano> na URL
- ícone RiExternalLinkLine ao lado do nome, mesmo padrão do widget de faturas

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:23 +00:00
Felipe Coutinho
0f5c735be0 feat(dashboard): confirmação de pagamento com conta e data para faturas e boletos
- widget de faturas abre modal com seleção de conta de origem e data antes de pagar
- widget de boletos ganha a mesma paridade: modal com conta de pagamento e data
- toggleTransactionSettlementAction aceita paymentAccountId e paymentDate opcionais
- DashboardBill expõe accountId para inicializar o modal com a conta já vinculada

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:16 +00:00
Felipe Coutinho
4bea6330bf feat(faturas/extrato): ajuste de fatura, reembolso e ajuste de saldo da conta
- botão "Ajustar fatura" na página da fatura abre dialog com input do valor real
  e preview da diferença; action faz upsert/delete idempotente do lançamento de ajuste
- opção "Reembolso" no dropdown de ações de despesas à vista cria receita espelhada
  no extrato ou fatura correta, vinculada ao lançamento original
- botão "Ajustar saldo" no extrato da conta compara saldo real informado e gera
  lançamento de ajuste por (accountId, period) via upsert/delete idempotente
- constantes INVOICE_ADJUSTMENT_NAME, ACCOUNT_BALANCE_ADJUSTMENT_NAME,
  REFUND_NOTE_PREFIX e buildRefundNote() centralizadas em shared/lib/accounts/constants.ts
- extrato agora contabiliza transferências internas em Entradas e Saídas corretamente

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:08:07 +00:00
Felipe Coutinho
8389752172 feat(cartoes): exigir limite e bloquear lançamentos acima do disponível
- campo limite passa a ser NOT NULL DEFAULT 0 no schema (migration 0029)
- validação Zod com requiredDecimalSchema garante valor positivo no formulário
- validateCardLimit() em transactions/actions/core.ts bloqueia criação e edição
  de despesas em cartão que ultrapassem o limite disponível, retornando mensagem
  com o valor exato restante
- tipos Card.limit e Card.limitAvailable deixam de ser nullable
- branch "sem limite registrado" removido de card-item.tsx

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:07:56 +00:00
Felipe Coutinho
19b5aa00ee docs(changelog): registrar vetorização dos logos em v2.4.4
Adiciona ao bloco da v2.4.4 as mudanças de logo (split em LogoIcon/
LogoText, SVGs inline, troca dos PNGs por SVGs no public/ e
rasterização em alta resolução nos PDFs) e o fix do baseUrl no
tsconfig. Também atualiza a data da release para 2026-04-27.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 00:13:54 +00:00
Felipe Coutinho
863ccc0fd2 refactor(exports): renderizar logos SVG em alta resolução no PDF
Atualiza loadExportLogoDataUrl para carregar SVGs e rasterizar no canvas
a 4× a resolução natural antes de retornar o data URL — preserva nitidez
quando o PDF amplia a imagem. Default do path mudou para
/images/logo_text.svg.

Os exports de categorias e lançamentos agora apontam para os arquivos
.svg em vez dos .png removidos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 00:11:21 +00:00
Felipe Coutinho
29d99cbedb chore(assets): trocar PNGs do logo por SVGs vetorizados
- adiciona public/images/logo_small.svg e logo_text.svg com width/height
  explícitos (necessário para naturalWidth/Height funcionar via <img>)
- remove os PNGs antigos (logo_small.png e logo_text.png)
- atualiza referência no README.md (header) para logo_small.svg

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 00:11:15 +00:00
Felipe Coutinho
dbeb98bbe4 refactor(logo): vetorizar e separar LogoIcon/LogoText em arquivos próprios
Substitui as PNGs raster do componente Logo por SVGs inline e quebra
em dois subcomponentes reutilizáveis:

- LogoIcon (src/shared/components/logo-icon.tsx): SVG do ícone laranja
  (viewBox 0 0 200 200), aceita SVGProps via spread
- LogoText (src/shared/components/logo-text.tsx): SVG do wordmark
  (viewBox 0 0 574.201 89.6), fill #000 + dark:invert para alternar
  preto/branco conforme o tema
- Logo (orquestrador): mantém a API atual (variants full/compact/small,
  invertTextOnDark, colorIcon, iconClassName, textClassName) e agora
  renderiza os SVGs em vez de next/image

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 00:11:10 +00:00
Felipe Coutinho
c0436dc2ac fix(tsconfig): remover baseUrl para evitar erro de deprecação no TS 7
A remoção de "ignoreDeprecations": "6.0" no commit anterior reabriu o
erro TS5101 sobre baseUrl. Como moduleResolution: bundler resolve os
paths relativos ao próprio tsconfig.json, baseUrl é redundante e pode
ser removido.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:58:09 +00:00
Felipe Coutinho
e1e76fadc0 chore(release): v2.4.4
Versão dedicada a remover a dependência de pgcrypto e a enxugar os
backups. CHANGELOG, badge do README e fluxo de restore atualizados;
script pnpm db:extensions removido do package.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:52:54 +00:00
Felipe Coutinho
9b2c15ef7d chore: ajustes de configuração diversos
- tsconfig: target ES2017 → ES2022, remove ignoreDeprecations 6.0
- gitignore: ignora pasta .codex
- next.config: remove linha em branco supérflua

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:52:50 +00:00
Felipe Coutinho
fbe3fceb9f chore(backup): escopar dumps aos schemas public e drizzle
Adiciona --schema=public --schema=drizzle aos pg_dump (modos remote e
docker), descartando os schemas internos do Supabase (auth, realtime,
storage, vault, graphql, etc.). Restaurações em PostgreSQL padrão
deixam de produzir os ~148 erros de role/extension does not exist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:52:42 +00:00
Felipe Coutinho
39f3cd8b20 feat(payers): gerar share_code na aplicação e remover pgcrypto
Move a geração do share_code do PostgreSQL para a camada de aplicação,
eliminando a dependência da extensão pgcrypto no setup do banco.

- schema: drop default substr(encode(gen_random_bytes(24), 'base64'), 1, 24)
  da coluna share_code em pagadores (continua NOT NULL)
- nova util generateShareCode() em shared/lib/payers/share-code.ts
  (server-only, usa crypto.randomBytes do Node)
- chamadas explícitas em createPayerAction, ensureDefaultPagadorForUser,
  resetUserAppData e mock-data ao inserir pagadores
- migration 0028_fancy_reaper renumerada (0027 já estava ocupado por
  arquivo órfão); journal e snapshot atualizados
- remove etapa de habilitação de pgcrypto do docker-entrypoint.sh
- remove scripts/postgres/ (init.sql e enable-extensions.ts)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:52:36 +00:00
122 changed files with 9348 additions and 1335 deletions

1
.gitignore vendored
View File

@@ -106,6 +106,7 @@ docker-compose.override.yml
.cursor/
QWEN.md
AGENTS.md
.codex
# === Backups locais ===
/backup/

View File

@@ -5,7 +5,77 @@ 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/).
## [Unreleased]
## [2.5.0] - 2026-05-01
Esta versão melhora o fechamento de faturas, a correção de lançamentos já registrados e a conferência de saldos contra o extrato do banco. O novo **ajuste de fatura** fecha a conta entre o total calculado pelo sistema e o valor real cobrado pelo banco, sem exigir que o usuário reabra lançamentos individuais. A mesma ideia foi estendida para **contas correntes**: na página do extrato, ao lado de "Saldo ao final do período", o usuário informa o saldo real e o sistema cria (ou atualiza) um lançamento de ajuste no período visualizado. Também entra o fluxo de **reembolso** para despesas à vista: pelo menu de ações do lançamento, o usuário informa a data do reembolso e o sistema cria uma receita espelhada no extrato ou na fatura correta. O widget de boletos do dashboard ganhou paridade com o widget de faturas — confirmação de pagamento agora pede conta de origem e data antes de quitar o boleto. Por fim, o **limite do cartão** passou a ser obrigatório e o sistema bloqueia despesas em cartão que ultrapassem o limite disponível, retornando uma mensagem com o valor exato disponível. As operações mantêm rastro no lançamento gerado e respeitam a proteção de faturas já pagas.
### Adicionado
- Nome do boleto no widget de Boletos agora é um link para `/transactions?q=<nome>`, incluindo `?periodo=<mes-ano>` automaticamente quando o período selecionado não é o atual. Ícone `RiExternalLinkLine` ao lado do nome, igual ao padrão do widget de Faturas.
- Botão "Ajustar fatura" ao lado do valor na página da fatura.
- Dialog `AdjustInvoiceDialog` com input de valor correto e preview da diferença.
- Action `adjustInvoiceAction` que faz upsert/delete idempotente do lançamento de ajuste.
- Botão "Ajustar saldo" ao lado do valor na página do extrato da conta.
- Dialog `AdjustBalanceDialog` com input do saldo correto e preview da diferença que será lançada (receita ou despesa).
- Action `adjustAccountBalanceAction` que faz upsert/delete idempotente do lançamento de ajuste por `(accountId, period)`.
- Opção "Reembolso" no dropdown de ações de despesas à vista, posicionada após "Copiar" e antes de "Remover".
- Dialog `RefundTransactionDialog` com seleção da data do reembolso e indicação do período de destino.
- Action `refundTransactionAction` que cria uma receita de reembolso vinculada ao lançamento original.
- Constantes compartilhadas `INVOICE_ADJUSTMENT_NAME`, `ACCOUNT_BALANCE_ADJUSTMENT_NAME`, `REFUND_NOTE_PREFIX` e `buildRefundNote()` em `shared/lib/accounts/constants.ts`.
- Validação de limite de cartão: `validateCardLimit()` em `transactions/actions/core.ts` calcula o uso atual do cartão (somando lançamentos não quitados, com a mesma regra usada em `cards/queries.ts` para recorrentes) e bloqueia criação ou edição de despesa em cartão que ultrapasse o disponível, retornando "Lançamento de R$ X excede o limite disponível do cartão (R$ Y)."
- Schema reutilizável `requiredDecimalSchema(fieldName)` em `shared/lib/schemas/common.ts` — número/string positiva (`> 0`) com mensagens parametrizáveis.
### Alterado
- **Limite do cartão é obrigatório**: campo `limite` em `cartoes` ganhou `NOT NULL DEFAULT 0` no schema, validação Zod com `requiredDecimalSchema("limite")`, atributo `required` no input do formulário e checagem client-side antes do submit. Tipos `Card.limit` e `Card.limitAvailable` deixam de ser nullable; branch "Ainda não há limite registrado" foi removido de `card-item.tsx` e a derivação defensiva em `cards/[cardId]/invoice` foi simplificada.
- Migration `0029_friendly_spitfire`: preenche com `0` registros legados antes do `SET NOT NULL` para não quebrar bancos com cartões sem limite.
- Métricas principais passam a tratar reembolsos como abatimento de despesa, não como receita comum.
- Cards de receitas/despesas, série histórica do dashboard e resumo do extrato agora preservam o efeito líquido do reembolso no balanço sem inflar entradas e saídas.
- Pagamento de fatura agora abre confirmação com conta de origem selecionável; por padrão vem a conta vinculada ao cartão, mas o usuário pode escolher outra conta antes de confirmar.
- Widget de faturas no dashboard ganhou a mesma confirmação: o modal "Confirmar pagamento" agora pede conta de origem e data antes de marcar a fatura como paga, alinhando o comportamento ao da página de fatura.
- Widget de boletos no dashboard ganhou a mesma paridade: o modal "Confirmar pagamento" passou a oferecer seleção de **conta de pagamento** e **data do pagamento**, com mesma estrutura de cards de detalhes, métricas, separator e formulário condicional do widget de faturas.
- `toggleTransactionSettlementAction` agora aceita `paymentAccountId` e `paymentDate` opcionais para boletos — quando informados, atualiza a `accountId` do lançamento e usa a data escolhida em `boletoPaymentDate` (em vez da data atual).
- `DashboardBill` passa a expor `accountId` para que o dialog inicialize a conta com o valor já vinculado ao boleto.
- Widget "Lançamentos por Categorias" agora ignora a categoria "Transferência interna" — transferências entre contas próprias deixam de poluir o ranking de categorias.
### Corrigido
- Erro de hidratação no widget de Anotações: `Intl.DateTimeFormat` sem `timeZone` usava o fuso do servidor (UTC) no SSR e o fuso do browser (BRT) no cliente, resultando em datas divergentes. Ambos os formatters passam a usar `timeZone: "America/Sao_Paulo"` explicitamente.
- Extrato da conta agora contabiliza transferências internas nos cards de **Entradas** e **Saídas**: transferência recebida soma em Entradas, transferência enviada soma em Saídas. Antes o saldo final refletia o movimento mas os cards permaneciam zerados, gerando inconsistência visível na tela (issue #47).
### Removido
- Seção "Veja o que você pode fazer" (galeria de screenshots com abas) da landing page, junto com o componente `ScreenshotTabs`, as 14 imagens `preview-*.webp`, o bloco `screenshots` em `images.ts`, o link `#telas` do nav e o export `pwaCompatList` sem uso.
- Exports mortos `dateFormatter` e `monthFormatter` de `features/transactions/formatting-helpers.ts`.
## [2.4.4] - 2026-04-27
Esta versão remove a dependência da extensão `pgcrypto` do PostgreSQL para a geração do `share_code` em pagadores. O default a nível de banco (`gen_random_bytes`) foi removido — agora a aplicação gera o código sempre via `crypto.randomBytes` do Node.js, num utilitário compartilhado. A consequência prática é que o setup inicial fica mais simples: não há mais script de habilitação de extensão, nem etapa extra no primeiro `db:push`, e bancos restaurados de dumps externos não precisam ter `pgcrypto` instalada. O script de backup também foi enxugado para gerar dumps focados nos schemas relevantes (`public` e `drizzle`), descartando os schemas internos do Supabase e eliminando os ~148 erros de restore em PostgreSQL padrão. Por fim, os logos da marca (ícone laranja e wordmark) foram vetorizados: as PNGs antigas foram substituídas por SVGs inline em componentes próprios e por arquivos `.svg` no `public/`, escalando perfeitamente em qualquer tamanho — inclusive nos PDFs exportados, que agora rasterizam o SVG em alta resolução.
### Alterado
- Schema: coluna `share_code` em `pagadores` perdeu o default `substr(encode(gen_random_bytes(24), 'base64'), 1, 24)` — campo continua `NOT NULL` e a aplicação passa a fornecer o valor explicitamente em todas as inserções
- Pagadores: nova função utilitária `generateShareCode()` em `src/shared/lib/payers/share-code.ts` (server-only) — usa `crypto.randomBytes(18).toString("base64url").slice(0, 24)`
- Pagadores: `createPayerAction`, `ensureDefaultPagadorForUser`, `resetUserAppData` (settings) e `mock-data.ts` agora chamam `generateShareCode()` ao inserir um pagador
- Backup: `scripts/backup.sh` agora dumpa apenas os schemas `public` e `drizzle` — schemas internos do Supabase (`auth`, `realtime`, `storage`, `vault`, `graphql`, `graphql_public`, `extensions`, `pgbouncer`) e suas extensions/roles deixam de poluir os dumps. Restaurações em PostgreSQL padrão passam a executar sem os ~148 erros de `role/extension does not exist`
- Logo: `Logo` foi quebrado em três arquivos — `src/shared/components/logo.tsx` (orquestrador), `logo-icon.tsx` (ícone laranja em SVG inline, viewBox `0 0 200 200`) e `logo-text.tsx` (wordmark em SVG inline, viewBox `0 0 574.201 89.6`). API pública (`variant`, `invertTextOnDark`, `colorIcon`, `iconClassName`, `textClassName`) preservada
- Assets: `public/images/logo_small.png` e `logo_text.png` substituídos por `logo_small.svg` e `logo_text.svg` (com `width`/`height` explícitos para compatibilidade com `<img>` em canvas)
- Exports: `loadExportLogoDataUrl` agora carrega SVG e rasteriza no canvas a 4× a resolução natural antes de gerar o data URL — mantém nitidez quando o PDF amplia a imagem
### Removido
- Pasta `scripts/postgres/` (continha `init.sql` e `enable-extensions.ts`)
- Script `pnpm db:extensions` no `package.json`
- Referências ao `pnpm db:extensions` no README
- `public/images/logo_small.png` e `public/images/logo_text.png` (substituídos pelos `.svg`)
### Corrigido
- Migrations: conflito de numeração resolvido — `0027_fancy_reaper` renomeado para `0028_fancy_reaper` (o número 0027 já estava ocupado pelo arquivo órfão `0027_glorious_mindworm`); journal e snapshot atualizados
- TS: removido `baseUrl` do `tsconfig.json` para evitar erro `TS5101` (deprecação no TS 7) — `moduleResolution: bundler` resolve os `paths` relativos ao próprio `tsconfig`, dispensando `baseUrl`
### Documentação
- README: seção Backup atualizada — arquivos gerados agora especificam que apenas os schemas `public` e `drizzle` são dumpados
- README: seção Restore reescrita com o fluxo correto para banco Docker (`DROP SCHEMA public CASCADE` + `pg_restore --clean --if-exists --disable-triggers`)
- README: comando rápido de Docker Compose de backup/restore substituído por `pnpm backup`
- README: header passa a apontar para `logo_small.svg`
## [2.4.3] - 2026-04-25
@@ -64,7 +134,7 @@ Esta versão é quase toda sobre organização e polimento. O código interno do
- UI: budget-card, card-item e componentes do calendário (day-cell, event-modal) com layout revisado
- UI: auth-card-shell simplificado (removido glassmorphism e blob animado)
- Landing: imagens de preview atualizadas; `mainFeatures` + `extraFeatures` unificados em grid único; dark mode nos botões de CTA
- Navbar: dark mode corrigido no navbar-shell (`dark:bg-card`, `dark:border-b-border/60`)
- Navbar: dark mode corrigido no navbar-shell (`dark:bg-card`, `dark:border-b-border`)
- Logo.dev: `NEXT_PUBLIC_LOGO_DEV_TOKEN` renomeado para `LOGO_DEV_TOKEN` (agora lido em runtime server-side apenas)
- UI: conceito "Pagador/Pagadores" renomeado para **"Pessoa/Pessoas"** em toda a interface — labels, títulos, toasts, mensagens de erro, cabeçalhos de tabela e exportações. Código, rotas (`/payers`) e schema do banco (`pagadores`) permanecem inalterados; a divergência entre UI e código é intencional
- Deps: next 16.2.3 → 16.2.4, better-auth 1.6.2 → 1.6.5, ai 6.0.159 → 6.0.168 e outros patches menores

389
DESIGN.md Normal file
View File

@@ -0,0 +1,389 @@
# Design System Inspired by OpenMonetis
## 1. Visual Theme & Atmosphere
OpenMonetis embodies a warm, approachable financial management aesthetic grounded in trust and transparency. The design system combines a rich warm-orange accent palette with a sophisticated warm-neutral foundation, creating an interface that feels both professional and inviting. The typography and spacing work together to emphasize clarity and hierarchy, supporting the open-source ethos of personal financial control. The visual atmosphere prioritizes legibility and calm navigation, with generous whitespace and deliberate color restraint—the bold orange is reserved for critical calls-to-action and highlights, while the warm grays and blacks anchor the interface with stability and focus.
**Key Characteristics**
- Warm, approachable color story with a dominant orange accent (`#FF7733`)
- Generous whitespace and breathing room between sections
- High contrast between backgrounds and text for accessibility
- Clear typographic hierarchy using Inter for all text and UI
- Minimal elevation and shadow treatment—mostly flat design
- Subtle border accents in warm grays to define surfaces
- Open-source transparency reflected in straightforward, honest design language
## 2. Color Palette & Roles
### Primary
- **Primary Accent** (`#FF7733`): Used for primary call-to-action buttons, highlights, and key interactive elements throughout the interface; draws user attention to the most important actions
- **Primary Dark** (`#443732`): Warm-brown anchor color used extensively for text, headings, and interactive elements; provides the primary text color across the system
### Interactive
- **Interactive Neutral** (`#0F0D0C`): Near-black used for primary text and strong emphasis elements; highest contrast state
- **Interactive Overlay** (`#0006`): Transparent black overlay at 24% opacity; used for hover states, modals, and depth layering
### Neutral Scale
- **Neutral 900** (`#2A2827`): Very dark warm gray; used for secondary text and disabled states
- **Neutral 800** (`#322C2A`): Dark warm gray; alternative text color for lower-emphasis content
- **Neutral 700** (`#676260`): Medium warm gray; used for tertiary text, captions, and metadata
- **Neutral 50** (`#FCF7F6`): Almost-white warm cream; primary background color for light surfaces
- **Neutral 100** (`#F8F6F4`): Very light warm gray; secondary background and subtle surface distinction
- **Neutral 200** (`#F5F2EF`): Light warm gray; tertiary background and card interiors
- **Neutral 300** (`#F0EEEC`): Pale warm gray; border and divider lines
### Surface & Borders
- **Surface** (`#FFFFFF`): Pure white; primary card and container background; high-contrast surface
- **Border Light** (`#F0EEEC`): Pale warm gray used for subtle borders between elements
- **Border Dark** (`#2A2827`): Dark warm gray; stronger borders for defined card boundaries
### Semantic / Status
- **Success** (`#0E9D6E`): Green used for positive status indicators, confirmation states, and success messages
- **Warning** (`#F7A439`): Amber used for cautionary states, warnings, and attention-drawing alerts
- **Error** (`#F53F2D`): Red-orange used for error states, destructive actions, and validation failures
- **Error Alt** (`#D40C1A`): Deep red alternative for critical errors and danger states
## 3. Typography Rules
### Font Family
**Primary:** Inter (sans-serif)
Fallback: `Inter, system-ui, -apple-system, sans-serif`
**Monospace:** ui-monospace
Fallback: `ui-monospace, 'Courier New', monospace`
### Hierarchy
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|------|--------|-------------|----------------|-------|
| Display / H1 | Inter | 60px | 600 | 60px | 0px | Page titles and hero headlines; maximum visual impact |
| Heading H2 | Inter | 36px | 600 | 40px | 0px | Section headers and major subsections |
| Heading H3 | Inter | 16px | 600 | 20px | 0px | Card titles and smaller section headers |
| Body | Inter | 20px | 400 | 28px | 0px | Descriptive text, paragraphs, and long-form content |
| Body Secondary | Inter | 16px | 400 | 24px | 0px | Standard body text, list items, and descriptions |
| Emphasis / Span | Inter | 14px | 500 | 20px | 0px | Emphasized text, labels, and badge content |
| Button | Inter | 14px | 500 | 20px | 0px | All button text; medium weight for clarity |
| Caption | Inter | 14px | 400 | 20px | 0px | Metadata, timestamps, and small supporting text |
| Code | ui-monospace | 14px | 400 | 20px | 0px | Code blocks and technical content |
### Principles
- **Contrast & Clarity:** Text color `#0F0D0C` on light backgrounds; `#FFFFFF` on dark backgrounds
- **Weight Hierarchy:** Use 600 weight for all headings; 500 for interactive/emphasized text; 400 for body
- **Scale Progression:** Sizes increase in meaningful increments (14 → 16 → 20 → 36 → 60); maintain consistent rhythm
- **Line Height:** Body text uses 1.4× line height multiplier for comfortable reading; headings use tighter ratio (1:1) for impact
- **Readability First:** Avoid line lengths over 80 characters for long-form content; increase line height on smaller screens
## 4. Component Stylings
### Buttons
#### Primary Button
- **Background:** `#FF7733`
- **Text Color:** `#FFFFFF`
- **Font Size:** `14px`
- **Font Weight:** `500`
- **Font Family:** `Inter`
- **Padding:** `8px 16px`
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Height:** `40px`
- **Box Shadow:** `none`
- **Hover State:** Darken background to `#E55F1F`; add subtle shadow `0px 2px 8px rgba(0, 0, 0, 0.12)`
- **Active State:** Darken further to `#CC5118`; increase shadow
- **Disabled State:** Background `#E8E3E0`; text color `#999890`; cursor not-allowed
#### Secondary Button
- **Background:** `#FFFFFF`
- **Text Color:** `#2A2827`
- **Font Size:** `14px`
- **Font Weight:** `500`
- **Font Family:** `Inter`
- **Padding:** `8px 24px`
- **Border Radius:** `9.2px`
- **Border:** `1px solid #F0EEEC`
- **Height:** `40px`
- **Box Shadow:** `0px 1px 3px rgba(0, 0, 0, 0.06)`
- **Hover State:** Background `#F8F6F4`; border color `#E8E3E0`
- **Active State:** Background `#F0EEEC`; border color `#D5CCCA`
- **Disabled State:** Background `#FAFAF8`; text color `#BFBAB7`; border color `#F0EEEC`
#### Ghost Button
- **Background:** `transparent`
- **Text Color:** `#443732`
- **Font Size:** `14px`
- **Font Weight:** `500`
- **Font Family:** `Inter`
- **Padding:** `6px 8px`
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Height:** `32px`
- **Box Shadow:** `none`
- **Hover State:** Background `rgba(68, 55, 50, 0.05)`
- **Active State:** Background `rgba(68, 55, 50, 0.1)`
- **Disabled State:** Text color `#BFBAB7`; cursor not-allowed
#### Icon Button
- **Background:** `transparent`
- **Icon Color:** `#443732`
- **Size:** `32px` × `32px`
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Padding:** `0px`
- **Box Shadow:** `none`
- **Hover State:** Background `rgba(68, 55, 50, 0.08)`
- **Active State:** Background `rgba(68, 55, 50, 0.12)`
### Cards & Containers
#### Standard Card
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `11.2px`
- **Padding:** `24px`
- **Box Shadow:** `none`
- **Text Color:** `#2A2827`
- **Hover State:** Border color `#E8E3E0`; box-shadow `0px 4px 12px rgba(0, 0, 0, 0.08)`
#### Card with Top Border
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `15.2px 15.2px 0px 0px` (top corners only)
- **Padding:** `24px`
- **Box Shadow:** `none`
- **Top Border Color:** `#FF7733` (3px height implied)
#### Surface Container (Header/Nav)
- **Background:** `#FF7733`
- **Height:** `64px`
- **Padding:** `0px 24px`
- **Box Shadow:** `none`
- **Text Color:** `#FFFFFF`
- **Border:** `0px solid transparent`
#### Light Surface
- **Background:** `#F8F6F4`
- **Border:** `0px solid transparent`
- **Border Radius:** `11.2px`
- **Padding:** `16px`
- **Box Shadow:** `none`
### Inputs & Forms
#### Text Input
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `9.2px`
- **Padding:** `12px 16px`
- **Font Size:** `16px`
- **Text Color:** `#2A2827`
- **Line Height:** `24px`
- **Placeholder Color:** `#999890`
- **Focus State:** Border color `#FF7733`; box-shadow `0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
- **Error State:** Border color `#F53F2D`; background `#FEF5F3`
- **Disabled State:** Background `#F8F6F4`; text color `#BFBAB7`; border color `#F0EEEC`
#### Select / Dropdown
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `9.2px`
- **Padding:** `12px 16px`
- **Font Size:** `16px`
- **Text Color:** `#2A2827`
- **Focus State:** Border color `#FF7733`; outline `0px`
- **Hover State:** Background `#FAFAF8`
#### Checkbox & Radio
- **Size:** `20px` × `20px`
- **Border Radius:** `4px` (checkbox), `50%` (radio)
- **Border:** `2px solid #F0EEEC`
- **Background:** `#FFFFFF`
- **Checked Background:** `#FF7733`
- **Checked Border:** `2px solid #FF7733`
- **Checked Icon Color:** `#FFFFFF`
- **Focus:** Border `2px solid #FF7733`; box-shadow `0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
### Navigation
#### Primary Navigation
- **Background:** `#FF7733`
- **Height:** `64px`
- **Padding:** `0px 48px`
- **Display:** flex; align-items: center; gap `32px`
- **Link Color:** `#FFFFFF`
- **Link Font Size:** `16px`
- **Link Font Weight:** `400`
- **Link Hover:** Opacity `0.8`
- **Link Active:** Text decoration underline; opacity `1.0`
#### Secondary Navigation / Tabs
- **Background:** `transparent`
- **Border Bottom:** `2px solid #F0EEEC`
- **Tab Padding:** `16px 24px`
- **Tab Color:** `#676260`
- **Tab Font Size:** `16px`
- **Tab Hover:** Color `#443732`
- **Tab Active:** Color `#FF7733`; border-bottom color `#FF7733`
#### Breadcrumb Navigation
- **Font Size:** `14px`
- **Color:** `#676260`
- **Separator:** `/` with `0px 8px` margin
- **Link Color:** `#443732`
- **Link Hover:** Color `#FF7733`
- **Current (Active):** Color `#2A2827`; font-weight `500`
### Badges & Status Indicators
#### Badge Default
- **Background:** `#F8F6F4`
- **Text Color:** `#443732`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
- **Border:** `0px solid transparent`
#### Badge Success
- **Background:** `#E8F5F0`
- **Text Color:** `#0E9D6E`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
#### Badge Warning
- **Background:** `#FEF5E8`
- **Text Color:** `#F7A439`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
#### Badge Error
- **Background:** `#FEF5F3`
- **Text Color:** `#F53F2D`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
## 5. Layout Principles
### Spacing System
- **Base Unit:** `4px`
- **Scale:** `4px`, `8px`, `12px`, `16px`, `24px`, `32px`, `48px`, `56px`, `64px`, `80px`, `96px`, `128px`
**Usage Contexts:**
- **48px:** Tight spacing within compact components (icon-text pairs, inline elements)
- **1216px:** Standard padding inside cards, inputs, and buttons
- **2432px:** Section gaps, spacing between components on a page
- **4864px:** Large section separations, hero spacing
- **80128px:** Hero margins, page-level vertical rhythm
### Grid & Container
- **Max Width:** `1440px` for full-width containers
- **Content Width:** `1152px` for typical page layouts
- **Column Strategy:** 12-column grid system; gutter `24px`
- **Container Padding:** `48px` on desktop (left + right)
- **Section Pattern:** Full-width containers with internal max-width constraint
### Whitespace Philosophy
OpenMonetis prioritizes breathing room and visual clarity. Whitespace is intentional and strategic—surrounding headings, separating card groups, and framing key messages. The warm neutral backgrounds (`#F8F6F4`, `#F5F2EF`) create natural visual separation without hard borders. Minimum margin between major sections is `64px` vertically; minimum padding inside containers is `16px`.
### Border Radius Scale
- **Sharp Corners:** `0px` (utility container tops, category selectors)
- **Subtle Radius:** `9.2px` (buttons, small inputs, icon buttons)
- **Standard Radius:** `11.2px` (cards, standard containers, modals)
- **Rounded Top:** `15.2px 15.2px 0px 0px` (card headers, sheet-style containers)
- **Pill Shape:** `24px` (badges, full-rounded tags, avatar images)
- **Circle:** `50%` (avatar images, radial elements)
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (None) | No shadow, `border: 1px solid #F0EEEC` | Default cards, inputs, containers; baseline surfaces |
| Subtle (sm) | `0px 1px 3px rgba(0, 0, 0, 0.06)` | Secondary buttons, hover states on light surfaces |
| Medium (md) | `0px 4px 12px rgba(0, 0, 0, 0.08)` | Elevated cards on hover, floating actions |
| Deep (lg) | `0px 10px 24px rgba(0, 0, 0, 0.12)` | Modals, dropdowns, popover menus |
**Shadow Philosophy:**
The design system uses restrained shadow treatment aligned with a flat-modern aesthetic. Shadows emerge subtly on interaction (hover, focus) rather than as default styling. The primary depth cue is border color (`#F0EEEC`), which maintains visual hierarchy without excessive z-depth. When shadows are used, they employ warm-tinted blacks (`rgba(0, 0, 0, 0.060.12)`) to harmonize with the warm neutral palette.
## 7. Do's and Don'ts
### Do
- Use the primary orange (`#FF7733`) exclusively for the most important call-to-action buttons
- Apply generous padding (`24px48px`) around sections and inside cards for breathing room
- Stack elements vertically with `2432px` gaps for clear visual rhythm
- Use warm grays (`#443732`, `#2A2827`) for all body text to maintain the warm aesthetic
- Apply `9.2px` border radius consistently to all interactive elements (buttons, inputs)
- Keep line heights at `1.4×` or greater for comfortable reading on body text
- Use semantic colors (`#0E9D6E` success, `#F7A439` warning, `#F53F2D` error) intentionally
- Test contrast ratios; maintain WCAG AA minimum (4.5:1 for body text)
- Use the `Inter` typeface exclusively for consistency
- Implement focus states with a `3px` colored outline or border
### Don't
- Don't use orange anywhere except primary CTAs and critical highlights
- Don't reduce padding below `12px` inside cards or `8px` inside compact buttons
- Don't use dark backgrounds (`#0F0D0C`) for body text on light surfaces; stick to `#2A2827` or `#443732`
- Don't apply shadows as default styling; reserve them for elevated states (hover, focus, modal)
- Don't mix border radius values on the same component type; stick to defined scale
- Don't increase line height above `1.6×` for headings; tighten for impact
- Don't use the error color (`#F53F2D`) for general emphasis; reserve for genuine errors
- Don't create new colors outside the palette; use opacity if gradation is needed
- Don't apply multiple shadows to a single element; layer a maximum of two shadow values
- Don't forget to include focus/keyboard navigation states on all interactive elements
## 8. Responsive Behavior
### Breakpoints
| Breakpoint | Width | Key Changes |
|-----------|-------|-------------|
| Mobile | `375px599px` | Single column; container padding `16px`; font sizes reduce 12 sizes; gap scale halved |
| Tablet | `600px1023px` | Two-column grid; container padding `32px`; button height `36px`; heading sizes reduce slightly |
| Desktop | `1024px+` | Full 12-column grid; container padding `48px`; full-scale typography; max-width `1440px` |
### Touch Targets
- **Minimum Interactive Size:** `44px` × `44px` for mobile; `40px` × `40px` for desktop
- **Button Padding:** `12px` vertical, `16px` horizontal (minimum)
- **Link Padding:** `6px` vertical, `8px` horizontal minimum
- **Icon Button:** `32px` × `32px` minimum (32px on mobile preferred)
- **Spacing Between Targets:** `8px` minimum to avoid accidental activation
### Collapsing Strategy
- **Navigation:** Horizontal nav on desktop collapses to hamburger menu on tablet; menu items stack vertically with `12px` gap
- **Grid:** 12-column layout on desktop → 6-column on tablet → 2-column (stacked) on mobile
- **Cards:** Three-column card layouts collapse to single column on mobile; padding reduces from `24px` to `16px`
- **Typography:** Display (60px) → 36px on tablet → 28px on mobile; body (20px) → 18px on tablet → 16px on mobile
- **Spacing:** All spacing scale values reduce by 2533% on mobile (e.g., `24px` gap → `16px` on tablet, `12px` on mobile)
- **Buttons:** Full-width on mobile (padding `0px`); inline (auto-width) on desktop
- **Inputs:** Full-width on mobile; constrained width on desktop
## 9. Agent Prompt Guide
### Quick Color Reference
- **Primary CTA:** Warm Orange (`#FF7733`) — Buttons, highlights, key interactions
- **Primary Text:** Warm Brown (`#443732`) — Headings, strong emphasis
- **Secondary Text:** Dark Neutral (`#2A2827`) — Body text, card content
- **Background:** White (`#FFFFFF`) — Cards, primary surfaces
- **Background Alt:** Cream (`#FCF7F6`) — Alternative surfaces, light containers
- **Border:** Pale Gray (`#F0EEEC`) — Card borders, divider lines
- **Success:** Green (`#0E9D6E`) — Confirmation, positive states
- **Warning:** Amber (`#F7A439`) — Cautions, attention states
- **Error:** Red-Orange (`#F53F2D`) — Errors, destructive actions
- **Disabled:** Light Gray (`#E8E3E0`) — Inactive elements, inaccessible states
### Iteration Guide
1. **Always use `#FF7733` for primary buttons** and all main call-to-action elements; secondary buttons use `#FFFFFF` with `#F0EEEC` border
2. **Typography is always `Inter`** with weights 400 (body), 500 (emphasis), and 600 (headings); size hierarchy: 14 → 16 → 20 → 36 → 60px
3. **Spacing base is `4px`**; use multiples from the scale (8, 12, 16, 24, 32, 48, 64, 80, 96, 128px); never arbitrary values
4. **Border radius:** Apply `9.2px` to all buttons and inputs, `11.2px` to cards, `15.2px 15.2px 0px 0px` to top-bordered containers
5. **Cards default to `#FFFFFF` background with `1px solid #F0EEEC` border**; add shadow only on hover (0px 4px 12px rgba(0, 0, 0, 0.08))
6. **Form inputs:** Padding `12px 16px`, border `1px solid #F0EEEC`, focus state `border: 1px solid #FF7733` + `box-shadow: 0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
7. **Navigation background is always `#FF7733`** with white text; links in content use `#443732` with underline on active
8. **Maintain 1.4× line height minimum for body text**; tighten headings to 1:1 or 1.2:1 ratio
9. **Contrast validation:** Text `#0F0D0C` or `#2A2827` on light backgrounds (WCAG AA); text `#FFFFFF` on `#FF7733` (WCAG AAA)
10. **Responsive collapse:** Reduce padding and font by 25% on mobile; stack multi-column layouts to single column; full-width buttons on mobile only

View File

@@ -1,5 +1,5 @@
<p align="center">
<img src="./public/images/logo_small.png" alt="OpenMonetis Logo" height="80" />
<img src="./public/images/logo_small.svg" alt="OpenMonetis Logo" height="80" />
</p>
<p align="center">
@@ -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.4.3-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.5.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/)
@@ -20,11 +20,7 @@
---
<p align="center">
<img src="./public/images/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" />
</p>
<p align="center">
<img src="./public/images/preview-lancamentos-light.webp" alt="Lançamentos" width="800" />
<img src="./public/images/dashboard-preview-light.png" alt="Dashboard Preview" width="800" />
</p>
---
@@ -196,13 +192,10 @@ cp .env.example .env
# 4. Suba o banco
pnpm docker:db
# 5. Habilite extensões do PostgreSQL (apenas no primeiro setup)
pnpm db:extensions
# 6. Aplique o schema no banco (apenas no primeiro setup)
# 5. Aplique o schema no banco (apenas no primeiro setup)
pnpm db:push
# 7. Inicie o app com hot-reload
# 6. Inicie o app com hot-reload
pnpm dev
```
@@ -240,7 +233,6 @@ pnpm lint:fix # Biome auto-fix
pnpm db:generate # Gerar migrations
pnpm db:migrate # Executar migrations
pnpm db:push # Push schema direto (dev)
pnpm db:extensions # Habilitar extensões PostgreSQL (rodar uma vez)
pnpm db:studio # Drizzle Studio (UI visual)
```
@@ -291,8 +283,7 @@ docker compose up -d app
docker compose exec app sh # Shell da aplicação
docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco
docker compose ps # Status
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
pnpm backup # Backup (ver seção Backup)
```
### Customizando portas
@@ -318,9 +309,9 @@ Cada execução gera **3 arquivos** em `backup/`:
| Arquivo | Conteúdo | Uso |
|---|---|---|
| `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom do PostgreSQL (binário) | Restore completo via `pg_restore` |
| `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL completo compactado | Inspeção manual, portabilidade |
| `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados da schema `public` | Migração parcial, seed de outro ambiente |
| `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom dos schemas `public` + `drizzle` | Restore completo via `pg_restore` |
| `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL compactado dos schemas `public` + `drizzle` | Inspeção manual, portabilidade |
| `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados do schema `public` (sem DDL) | Migração parcial, seed de outro ambiente |
### Modos de conexão
@@ -354,16 +345,19 @@ crontab -e
### Restore
```bash
# A partir do .dump (recomendado — mais rápido)
pg_restore --clean --no-owner --no-privileges \
-d "postgresql://user:senha@host:5432/openmonetis_db" \
backup/openmonetis_YYYY-MM-DD_HH-MM.dump
# 1. Zerar o banco
docker exec <container-db> psql -U openmonetis -d openmonetis_db \
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
# A partir do .sql.gz (banco local via Docker)
gunzip -c backup/openmonetis_YYYY-MM-DD_HH-MM.sql.gz | \
docker compose exec -T db psql -U openmonetis -d openmonetis_db
# 2. Restaurar schema + dados (um comando)
docker exec -i <container-db> pg_restore \
-U openmonetis -d openmonetis_db \
--clean --if-exists --disable-triggers --no-owner --no-privileges \
< backup/openmonetis_YYYY-MM-DD_HH-MM.dump
```
> `--disable-triggers` é necessário para evitar erros de FK durante o restore (os dados são inseridos fora de ordem). O usuário `openmonetis` tem permissão para isso.
---
## ☁️ Storage S3 Compatível

View File

@@ -1,15 +1,5 @@
#!/bin/sh
echo "Habilitando extensão pgcrypto..."
node -e "
const { Client } = require('/app/migrate/node_modules/pg');
const c = new Client({ connectionString: process.env.DATABASE_URL });
c.connect()
.then(() => c.query('CREATE EXTENSION IF NOT EXISTS pgcrypto'))
.then(() => c.end())
.catch((e) => { console.error('Aviso pgcrypto:', e.message); process.exit(0); });
"
echo "Rodando migrations..."
MIGRATED=0
for i in 1 2 3 4 5; do

View File

@@ -0,0 +1 @@
ALTER TABLE "pagadores" ALTER COLUMN "share_code" DROP DEFAULT;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "cartoes" ALTER COLUMN "limite" SET DEFAULT '0';--> statement-breakpoint
UPDATE "cartoes" SET "limite" = '0' WHERE "limite" IS NULL;--> statement-breakpoint
ALTER TABLE "cartoes" ALTER COLUMN "limite" SET NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,195 +1,209 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1762993507299,
"tag": "0000_flashy_manta",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765199006435,
"tag": "0001_young_mister_fear",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1765200545692,
"tag": "0002_slimy_flatman",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767102605526,
"tag": "0003_green_korg",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1767104066872,
"tag": "0004_acoustic_mach_iv",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1767106121811,
"tag": "0005_adorable_bruce_banner",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1767107487318,
"tag": "0006_youthful_mister_fear",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1767118780033,
"tag": "0007_sturdy_kate_bishop",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1767125796314,
"tag": "0008_fat_stick",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1768925100873,
"tag": "0009_add_dashboard_widgets",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769369834242,
"tag": "0010_lame_psynapse",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769523352777,
"tag": "0013_fancy_rick_jones",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769619226903,
"tag": "0014_yielding_jack_flag",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1770332054481,
"tag": "0015_concerned_kat_farrell",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1771166328908,
"tag": "0016_complete_randall",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1772400510326,
"tag": "0017_previous_warstar",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773020417482,
"tag": "0018_rainy_epoch",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1773699152928,
"tag": "0019_ordinary_wild_pack",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1774033320053,
"tag": "0021_careful_malcolm_colcord",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1748000000000,
"tag": "0022_import-category-mappings",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1774529878374,
"tag": "0023_sturdy_wolfpack",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1776351838548,
"tag": "0025_burly_colonel_america",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1777042423451,
"tag": "0026_bored_eternity",
"breakpoints": true
}
]
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1762993507299,
"tag": "0000_flashy_manta",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1765199006435,
"tag": "0001_young_mister_fear",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1765200545692,
"tag": "0002_slimy_flatman",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767102605526,
"tag": "0003_green_korg",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1767104066872,
"tag": "0004_acoustic_mach_iv",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1767106121811,
"tag": "0005_adorable_bruce_banner",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1767107487318,
"tag": "0006_youthful_mister_fear",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1767118780033,
"tag": "0007_sturdy_kate_bishop",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1767125796314,
"tag": "0008_fat_stick",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1768925100873,
"tag": "0009_add_dashboard_widgets",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769369834242,
"tag": "0010_lame_psynapse",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769523352777,
"tag": "0013_fancy_rick_jones",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769619226903,
"tag": "0014_yielding_jack_flag",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1770332054481,
"tag": "0015_concerned_kat_farrell",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1771166328908,
"tag": "0016_complete_randall",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1772400510326,
"tag": "0017_previous_warstar",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773020417482,
"tag": "0018_rainy_epoch",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1773699152928,
"tag": "0019_ordinary_wild_pack",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1774033320053,
"tag": "0021_careful_malcolm_colcord",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1748000000000,
"tag": "0022_import-category-mappings",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1774529878374,
"tag": "0023_sturdy_wolfpack",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1776351838548,
"tag": "0025_burly_colonel_america",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1777042423451,
"tag": "0026_bored_eternity",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1777153372633,
"tag": "0028_fancy_reaper",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1777648189399,
"tag": "0029_friendly_spitfire",
"breakpoints": true
}
]
}

View File

@@ -8,7 +8,6 @@ const nextConfig: NextConfig = {
output: "standalone",
cacheComponents: true,
reactCompiler: true,
images: {
remotePatterns: [
new URL("https://lh3.googleusercontent.com/**"),

View File

@@ -1,6 +1,6 @@
{
"name": "openmonetis",
"version": "2.4.3",
"version": "2.5.0",
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
@@ -15,7 +15,6 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"docker:up": "docker compose up -d",
@@ -32,16 +31,16 @@
"mockup": "tsx scripts/mock-data.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.71",
"@ai-sdk/google": "^3.0.64",
"@ai-sdk/openai": "^3.0.53",
"@aws-sdk/client-s3": "^3.1037.0",
"@aws-sdk/s3-request-presigner": "^3.1037.0",
"@ai-sdk/anthropic": "^3.0.74",
"@ai-sdk/google": "^3.0.67",
"@ai-sdk/openai": "^3.0.57",
"@aws-sdk/client-s3": "^3.1040.0",
"@aws-sdk/s3-request-presigner": "^3.1040.0",
"@better-auth/passkey": "^1.6.9",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@openrouter/ai-sdk-provider": "^2.8.0",
"@openrouter/ai-sdk-provider": "^2.9.0",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
@@ -64,10 +63,10 @@
"@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.3",
"@tanstack/react-query": "^5.100.7",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"ai": "^6.0.168",
"ai": "^6.0.173",
"better-auth": "1.6.9",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1",
@@ -80,7 +79,7 @@
"jspdf-autotable": "^5.0.7",
"next": "16.2.4",
"next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205",
"pdfjs-dist": "^5.7.284",
"pg": "8.20.0",
"react": "19.2.5",
"react-day-picker": "^9.14.0",
@@ -90,7 +89,7 @@
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"vaul": "1.1.2",
"zod": "4.3.6"
"zod": "4.4.1"
},
"pnpm": {
"overrides": {
@@ -107,7 +106,7 @@
"@types/react-dom": "19.2.3",
"dotenv": "^17.4.2",
"drizzle-kit": "0.31.10",
"knip": "^6.7.0",
"knip": "^6.10.0",
"tailwindcss": "4.2.4",
"tsx": "4.21.0",
"typescript": "6.0.3"

823
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" role="img" aria-label="OpenMonetis">
<path fill="#ff7733" d="M 77.66,165.64 L 37.77,141.54 L 63.30,108.72 L 27.13,97.44 L 46.81,50.77 L 77.66,63.08 L 81.91,30.26 L 126.40,29.23 L 126.06,33.85 L 122.87,67.69 L 158.51,50.77 L 178.19,90.26 L 140.96,104.62 L 162.23,127.18 L 132.98,162.56 L 103.19,131.79 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

File diff suppressed because one or more lines are too long

View File

@@ -58,21 +58,26 @@ DATA_FILE="$BACKUP_DIR/openmonetis_${TIMESTAMP}.data.sql.gz"
log "Iniciando backup (modo: $DB_MODE)..."
# Schemas relevantes do OpenMonetis (descarta lixo Supabase: auth, realtime, storage, vault, graphql, etc.)
SCHEMA_FLAGS=(--schema=public --schema=drizzle)
# --- Dump ---
if [[ "$DB_MODE" == "remote" ]]; then
# --no-owner --no-privileges: necessário no Supabase (roles gerenciados internamente)
pg_dump --format=custom --no-owner --no-privileges \
"${SCHEMA_FLAGS[@]}" \
"$REMOTE_DB_URL" > "$DUMP_FILE"
pg_dump --no-owner --no-privileges \
"${SCHEMA_FLAGS[@]}" \
"$REMOTE_DB_URL" | gzip > "$SQL_FILE"
elif [[ "$DB_MODE" == "docker" ]]; then
docker exec "$DOCKER_CONTAINER" pg_dump \
-U "$DOCKER_DB_USER" -Fc "$DOCKER_DB_NAME" > "$DUMP_FILE"
-U "$DOCKER_DB_USER" -Fc "${SCHEMA_FLAGS[@]}" "$DOCKER_DB_NAME" > "$DUMP_FILE"
docker exec "$DOCKER_CONTAINER" pg_dump \
-U "$DOCKER_DB_USER" "$DOCKER_DB_NAME" | gzip > "$SQL_FILE"
-U "$DOCKER_DB_USER" "${SCHEMA_FLAGS[@]}" "$DOCKER_DB_NAME" | gzip > "$SQL_FILE"
else
log "ERRO: DB_MODE inválido ('$DB_MODE'). Use 'remote' ou 'docker'."

View File

@@ -44,6 +44,7 @@ import {
PAYER_ROLE_THIRD_PARTY,
PAYER_STATUS_OPTIONS,
} from "@/shared/lib/payers/constants";
import { generateShareCode } from "@/shared/lib/payers/share-code";
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
import {
addMonthsToDate,
@@ -537,6 +538,7 @@ async function ensureAdminPayer(targetUser: typeof user.$inferSelect) {
note: null,
role: PAYER_ROLE_ADMIN,
isAutoSend: false,
shareCode: generateShareCode(),
userId: targetUser.id,
})
.returning({ id: payers.id, name: payers.name });
@@ -870,6 +872,7 @@ async function main() {
note: definition.note,
role: PAYER_ROLE_THIRD_PARTY,
isAutoSend: definition.isAutoSend,
shareCode: generateShareCode(),
userId: targetUser.id,
})
.returning({ id: payers.id });

View File

@@ -1,45 +0,0 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { config } from "dotenv";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
// Load environment variables from .env
config();
async function initDatabase() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error("DATABASE_URL environment variable is required");
process.exit(1);
}
const pool = new Pool({ connectionString: databaseUrl });
const db = drizzle(pool);
try {
console.log("🔧 Initializing database extensions...");
// Read and execute init.sql as a single query
const initSqlPath = path.join(
process.cwd(),
"scripts",
"postgres",
"init.sql",
);
const initSql = fs.readFileSync(initSqlPath, "utf-8");
console.log("Executing init.sql...");
await db.execute(initSql);
console.log("✅ Database initialization completed");
} catch (error) {
console.error("❌ Database initialization failed:", error);
process.exit(1);
} finally {
await pool.end();
}
}
initDatabase();

View File

@@ -1,10 +0,0 @@
-- Script de inicialização do PostgreSQL para Docker
-- Este script é executado automaticamente quando o banco é criado pela primeira vez
-- Habilitar extensão pgcrypto (necessária para gen_random_bytes usado pelo Drizzle)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Log de sucesso
DO $$
BEGIN
RAISE NOTICE '✅ Extensão pgcrypto habilitada com sucesso';
END $$;

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 { AdjustBalanceDialog } from "@/features/accounts/components/adjust-balance-dialog";
import type { Account } from "@/features/accounts/components/types";
import {
fetchAccountData,
@@ -141,6 +142,13 @@ export default async function Page({ params, searchParams }: PageProps) {
totalIncomes={totalIncomes}
totalExpenses={totalExpenses}
logo={account.logo}
balanceAdjustment={
<AdjustBalanceDialog
accountId={account.id}
period={selectedPeriod}
currentBalance={currentBalance}
/>
}
actions={
<AccountDialog
mode="update"

View File

@@ -118,6 +118,8 @@ export default async function Page({ params, searchParams }: PageProps) {
financialAccount.id === card.accountId,
)?.name ?? "Conta";
const limitAmount = Number(card.limit);
const cardDialogData: Card = {
id: card.id,
name: card.name,
@@ -127,19 +129,14 @@ export default async function Page({ params, searchParams }: PageProps) {
dueDay: card.dueDay,
note: card.note ?? null,
logo: card.logo,
limit:
card.limit !== null && card.limit !== undefined
? Number(card.limit)
: null,
limit: limitAmount,
accountId: card.accountId,
accountName,
limitInUse: 0,
limitAvailable: null,
limitAvailable: limitAmount,
};
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
const limitAmount =
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
1,
@@ -163,6 +160,12 @@ export default async function Page({ params, searchParams }: PageProps) {
limitAmount={limitAmount}
invoiceStatus={invoiceStatus}
paymentDate={paymentDate}
defaultPaymentAccountId={card.accountId}
paymentAccountOptions={accountOptions.map((option) => ({
value: option.value,
label: option.label,
logo: option.logo ?? null,
}))}
logo={card.logo}
actions={
<CardDialog

View File

@@ -51,7 +51,7 @@ export default async function Page() {
<TabsTrigger value="passkeys">Passkeys</TabsTrigger>
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive">
Deletar conta
Ações perigosas
</TabsTrigger>
</TabsList>
</div>
@@ -190,7 +190,6 @@ export default async function Page() {
ou excluir sua conta inteira de forma irreversível.
</p>
</div>
<Separator />
<DeleteAccountForm />
</div>
</Card>

View File

@@ -9,7 +9,6 @@ import Image from "next/image";
import Link from "next/link";
import { AnimateOnScroll } from "@/features/landing/components/animate-on-scroll";
import { MobileNav } from "@/features/landing/components/mobile-nav";
import { ScreenshotTabs } from "@/features/landing/components/screenshot-tabs";
import { SetupTabs } from "@/features/landing/components/setup-tabs";
import {
companionBanks,
@@ -51,19 +50,19 @@ export default async function Page() {
{/* Navigation */}
<NavbarShell>
{/* Center Navigation Links */}
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{navLinks.map(({ href, label }) => (
<a
<Link
key={href}
href={href}
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors dark:text-white/75 dark:hover:text-white dark:hover:bg-white/10"
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium leading-none text-primary-foreground/75 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
{label}
</a>
</Link>
))}
</nav>
<nav className="ml-auto flex items-center gap-2 md:gap-3">
<nav className="ml-auto flex items-center gap-1">
<AnimatedThemeToggler variant="navbar" />
{!isPublicDomain &&
(session?.user ? (
@@ -71,26 +70,27 @@ export default async function Page() {
<Button
variant="navbar"
size="sm"
className="border border-black/20 dark:border-white/20"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Dashboard
</Button>
</Link>
) : (
<div className="hidden md:flex items-center gap-2">
<div className="hidden md:flex items-center gap-1">
<Link href="/login">
<Button
variant="ghost"
size="sm"
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Entrar
</Button>
</Link>
<Link href="/signup">
<Button
variant="ghost"
size="sm"
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2 dark:bg-white/10 dark:border-white/20 dark:text-white dark:hover:bg-white/20"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Começar
</Button>
@@ -207,31 +207,6 @@ export default async function Page() {
</div>
</section>
{/* Screenshots Gallery Section */}
<section id="telas" className="py-12 md:py-24">
<div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-6xl">
<AnimateOnScroll>
<div className="text-center mb-8 md:mb-12">
<Badge variant="outline" className="mb-4">
Conheça as telas
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Veja o que você pode fazer
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
Explore as principais telas do OpenMonetis
</p>
</div>
</AnimateOnScroll>
<AnimateOnScroll>
<ScreenshotTabs />
</AnimateOnScroll>
</div>
</div>
</section>
{/* Features Section */}
<section id="funcionalidades" className="py-12 md:py-24 bg-muted/40">
<div className="max-w-8xl mx-auto px-4">

View File

@@ -8,7 +8,7 @@
}
:root {
--background: oklch(97.412% 0.00332 67.032);
--background: oklch(95.99% 0.00411 55.512);
--foreground: oklch(27% 0.008 45);
--card: oklch(100% 0 0);
--card-foreground: var(--foreground);
@@ -36,7 +36,7 @@
--destructive: oklch(55% 0.22 27);
--destructive-foreground: oklch(98% 0.005 30);
--border: oklch(92.323% 0.01276 63.703);
--border: oklch(87.356% 0.01221 67.486);
--input: var(--border);
--ring: var(--primary);
@@ -116,7 +116,7 @@
--destructive: oklch(62% 0.2 28);
--destructive-foreground: oklch(98% 0.005 30);
--border: oklch(28% 0.0035 55);
--border: oklch(24.957% 0.00355 48.274);
--input: var(--border);
--ring: var(--primary);

View File

@@ -9,7 +9,7 @@ import { inter } from "@/public/fonts/font_index";
export const metadata: Metadata = {
title: {
default: "OpenMonetis | Suas finanças, do seu jeito",
template: "%s | OpenMonetis",
template: "OpenMonetis | %s",
},
description:
"Controle suas finanças pessoais de forma simples e transparente.",
@@ -40,7 +40,7 @@ export default function RootLayout({
/>
)}
</head>
<body className="subpixel-antialiased" suppressHydrationWarning>
<body className="antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light">
<QueryProvider>
<Suspense>{children}</Suspense>

View File

@@ -236,9 +236,7 @@ export const payers = pgTable(
note: text("anotacao"),
role: text("role"),
isAutoSend: boolean("is_auto_send").notNull().default(false),
shareCode: text("share_code")
.notNull()
.default(sql`substr(encode(gen_random_bytes(24), 'base64'), 1, 24)`),
shareCode: text("share_code").notNull(),
lastMailAt: timestamp("last_mail", {
mode: "date",
withTimezone: true,
@@ -303,7 +301,9 @@ export const cards = pgTable(
closingDay: text("dt_fechamento").notNull(),
dueDay: text("dt_vencimento").notNull(),
note: text("anotacao"),
limit: numeric("limite", { precision: 10, scale: 2 }),
limit: numeric("limite", { precision: 10, scale: 2 })
.notNull()
.default("0"),
brand: text("bandeira"),
logo: text("logo"),
status: text("status").notNull(),

View File

@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { categories, financialAccounts, transactions } from "@/db/schema";
import {
ACCOUNT_BALANCE_ADJUSTMENT_NAME,
INITIAL_BALANCE_CATEGORY_NAME,
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
@@ -17,6 +18,7 @@ import {
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { PERIOD_FORMAT_REGEX } from "@/shared/lib/invoices";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import {
@@ -26,8 +28,11 @@ import {
TRANSFER_ESTABLISHMENT_SAIDA,
TRANSFER_PAYMENT_METHOD,
} from "@/shared/lib/transfers/constants";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { getTodayInfo } from "@/shared/utils/date";
import {
formatCurrency,
formatDecimalForDbRequired,
} from "@/shared/utils/currency";
import { getBusinessTodayDate, getTodayInfo } from "@/shared/utils/date";
import { normalizeFilePath } from "@/shared/utils/string";
const accountBaseSchema = z.object({
@@ -99,7 +104,7 @@ export async function createAccountAction(
if (hasInitialBalance && !adminPayerId) {
throw new Error(
"Pessoa com papel administrador não encontrado. Crie um pessoa admin antes de definir um saldo inicial.",
"Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de definir um saldo inicial.",
);
}
@@ -299,7 +304,7 @@ export async function transferBetweenAccountsAction(
if (!adminPayerId) {
throw new Error(
"Pessoa administrador não encontrado. Por favor, crie um pessoa admin.",
"Pessoa administrador não encontrada. Por favor, crie uma pessoa admin.",
);
}
@@ -391,3 +396,120 @@ export async function transferBetweenAccountsAction(
return handleActionError(error);
}
}
const adjustAccountBalanceSchema = z.object({
accountId: uuidSchema("FinancialAccount"),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
currentBalance: z.number({ message: "Saldo atual inválido." }),
targetBalance: z.number({ message: "Saldo correto inválido." }),
});
type AdjustAccountBalanceInput = z.infer<typeof adjustAccountBalanceSchema>;
export async function adjustAccountBalanceAction(
input: AdjustAccountBalanceInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = adjustAccountBalanceSchema.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 ajustar o saldo.",
);
}
let message = "Ajuste de saldo registrado.";
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 existing = await tx.query.transactions.findFirst({
columns: { id: true, amount: true },
where: and(
eq(transactions.userId, user.id),
eq(transactions.accountId, data.accountId),
eq(transactions.period, data.period),
eq(transactions.name, ACCOUNT_BALANCE_ADJUSTMENT_NAME),
),
});
const existingAmount = Number(existing?.amount ?? 0);
const baseBalance = data.currentBalance - existingAmount;
const adjustmentAmount =
Math.round((data.targetBalance - baseBalance) * 100) / 100;
if (adjustmentAmount === 0) {
if (existing) {
await tx.delete(transactions).where(eq(transactions.id, existing.id));
message = "Ajuste de saldo removido.";
} else {
message = "Nada a ajustar — o saldo já está correto.";
}
return;
}
const isExpense = adjustmentAmount < 0;
const categoryName = isExpense ? "Outras despesas" : "Outras receitas";
const category = await tx.query.categories.findFirst({
columns: { id: true },
where: and(
eq(categories.userId, user.id),
eq(categories.name, categoryName),
),
});
const amount = formatDecimalForDbRequired(adjustmentAmount);
const note = `O saldo era ${formatCurrency(baseBalance)} mas o correto é ${formatCurrency(data.targetBalance)}.`;
const payload = {
condition: INITIAL_BALANCE_CONDITION,
name: ACCOUNT_BALANCE_ADJUSTMENT_NAME,
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
note,
amount,
purchaseDate: getBusinessTodayDate(),
transactionType: isExpense
? ("Despesa" as const)
: ("Receita" as const),
period: data.period,
isSettled: true,
userId: user.id,
accountId: data.accountId,
cardId: null,
categoryId: category?.id ?? null,
payerId: adminPayerId,
};
if (existing) {
await tx
.update(transactions)
.set(payload)
.where(eq(transactions.id, existing.id));
} else {
await tx.insert(transactions).values(payload);
}
});
revalidateForEntity("accounts", user.id);
revalidateForEntity("transactions", user.id);
return { success: true, message };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -26,6 +26,7 @@ type AccountStatementCardProps = {
totalExpenses: number;
logo?: string | null;
actions?: React.ReactNode;
balanceAdjustment?: React.ReactNode;
};
const getAccountStatusBadgeVariant = (
@@ -45,6 +46,7 @@ export function AccountStatementCard({
totalExpenses,
logo,
actions,
balanceAdjustment,
}: AccountStatementCardProps) {
const logoPath = resolveLogoSrc(logo);
const resultado = totalIncomes - totalExpenses;
@@ -84,10 +86,13 @@ export function AccountStatementCard({
<p className="text-sm text-muted-foreground ">
Saldo ao final do período
</p>
<MoneyValues
amount={currentBalance}
className="text-3xl leading-none tracking-tighter sm:text-2xl"
/>
<div className="flex items-center gap-2">
<MoneyValues
amount={currentBalance}
className="text-3xl leading-none tracking-tighter sm:text-2xl"
/>
{balanceAdjustment}
</div>
<div className="flex items-center gap-2">
<Badge
variant={getAccountStatusBadgeVariant(status)}

View File

@@ -0,0 +1,135 @@
"use client";
import { RiEqualizerLine } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import { adjustAccountBalanceAction } from "@/features/accounts/actions";
import { Button } from "@/shared/components/ui/button";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { formatCurrency } from "@/shared/utils/currency";
type AdjustBalanceDialogProps = {
accountId: string;
period: string;
currentBalance: number;
};
export function AdjustBalanceDialog({
accountId,
period,
currentBalance,
}: AdjustBalanceDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [amount, setAmount] = useState<string>(currentBalance.toFixed(2));
useEffect(() => {
if (open) {
setAmount(currentBalance.toFixed(2));
}
}, [open, currentBalance]);
const targetBalance = Number(amount);
const diff = Number.isFinite(targetBalance)
? Math.round((targetBalance - currentBalance) * 100) / 100
: 0;
const diffLabel =
diff > 0
? `Será criado um lançamento de receita de ${formatCurrency(diff)}.`
: diff < 0
? `Será criado um lançamento de despesa de ${formatCurrency(Math.abs(diff))}.`
: "Nenhum ajuste será criado — o saldo já está correto.";
const handleSave = () => {
if (!Number.isFinite(targetBalance)) {
toast.error("Informe um valor válido.");
return;
}
startTransition(async () => {
const result = await adjustAccountBalanceAction({
accountId,
period,
currentBalance,
targetBalance,
});
if (result.success) {
toast.success(result.message);
setOpen(false);
router.refresh();
return;
}
toast.error(result.error);
});
};
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>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ajustar saldo</DialogTitle>
<DialogDescription>
Informe o saldo correto da conta ao final do período. A diferença em
relação ao saldo atual será lançada como um ajuste.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<p className="text-muted-foreground">Saldo atual no sistema</p>
<p className="font-medium text-foreground">
{formatCurrency(currentBalance)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="adjust-balance-target">Saldo correto</Label>
<CurrencyInput
id="adjust-balance-target"
value={amount}
onValueChange={setAmount}
autoFocus
/>
<p className="text-xs text-muted-foreground">{diffLabel}</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="button" onClick={handleSave} disabled={isPending}>
{isPending ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,7 +4,10 @@ import {
fetchTransactionsPageWithRelations,
fetchTransactionsWithRelations,
} from "@/features/transactions/queries";
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
import {
INITIAL_BALANCE_NOTE,
REFUND_NOTE_PREFIX,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -74,7 +77,9 @@ export async function fetchAccountSummary(
sum(
case
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
when ${transactions.note} ilike ${`${REFUND_NOTE_PREFIX}%`} then 0
when ${transactions.transactionType} = 'Receita' then ${transactions.amount}
when ${transactions.transactionType} = 'Transferência' and ${transactions.amount} > 0 then ${transactions.amount}
else 0
end
),
@@ -86,7 +91,9 @@ export async function fetchAccountSummary(
sum(
case
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
when ${transactions.note} ilike ${`${REFUND_NOTE_PREFIX}%`} then abs(${transactions.amount})
when ${transactions.transactionType} = 'Despesa' then ${transactions.amount}
when ${transactions.transactionType} = 'Transferência' and ${transactions.amount} < 0 then ${transactions.amount}
else 0
end
),
@@ -135,7 +142,8 @@ export async function fetchAccountSummary(
const openingBalance = initialBalance + previousMovements;
const netAmount = Number(periodSummary?.netAmount ?? 0);
const totalIncomes = Number(periodSummary?.incomes ?? 0);
const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0));
const expenseNet = Number(periodSummary?.expenses ?? 0);
const totalExpenses = Math.max(0, -expenseNet);
const currentBalance = openingBalance + netAmount;
return {

View File

@@ -16,7 +16,7 @@ export function CalendarGrid({
onCreateDay,
}: CalendarGridProps) {
return (
<div className="overflow-hidden rounded-lg border p-2">
<div className="overflow-hidden">
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{WEEK_DAYS_SHORT.map((dayName) => (
<span key={dayName} className="text-center">

View File

@@ -129,7 +129,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
onClick={() => onSelect(day)}
onKeyDown={handleKeyDown}
className={cn(
"group flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border bg-card/80 p-2 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent",
"group flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border bg-card/70 p-2 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent",
!day.isCurrentMonth && "bg-muted/20 opacity-60",
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
)}

View File

@@ -13,10 +13,10 @@ import { db } from "@/shared/lib/db";
import {
dayOfMonthSchema,
noteSchema,
optionalDecimalSchema,
requiredDecimalSchema,
uuidSchema,
} from "@/shared/lib/schemas/common";
import { formatDecimalForDb } from "@/shared/utils/currency";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { normalizeFilePath } from "@/shared/utils/string";
const cardBaseSchema = z.object({
@@ -35,7 +35,7 @@ const cardBaseSchema = z.object({
closingDay: dayOfMonthSchema,
dueDay: dayOfMonthSchema,
note: noteSchema,
limit: optionalDecimalSchema,
limit: requiredDecimalSchema("limite"),
logo: z
.string({ message: "Selecione um logo." })
.trim()
@@ -87,7 +87,7 @@ export async function createCardAction(
closingDay: data.closingDay,
dueDay: data.dueDay,
note: data.note ?? null,
limit: formatDecimalForDb(data.limit),
limit: formatDecimalForDbRequired(data.limit),
logo: logoFile,
accountId: data.accountId,
userId: user.id,
@@ -121,7 +121,7 @@ export async function updateCardAction(
closingDay: data.closingDay,
dueDay: data.dueDay,
note: data.note ?? null,
limit: formatDecimalForDb(data.limit),
limit: formatDecimalForDbRequired(data.limit),
logo: logoFile,
accountId: data.accountId,
})

View File

@@ -154,13 +154,21 @@ export function CardDialog({
}
const rawLimit = normalizeDecimalInput(formState.limit);
const limitValue = rawLimit ? Number(rawLimit) : 0;
if (!Number.isFinite(limitValue) || limitValue <= 0) {
const message = "Informe um limite maior que zero.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload: CardCreatePayload = {
name: formState.name.trim(),
brand: formState.brand,
status: formState.status,
closingDay: formState.closingDay,
dueDay: formState.dueDay,
limit: rawLimit ? Number(rawLimit) : null,
limit: limitValue,
note: formState.note.trim() || null,
logo: formState.logo,
accountId: formState.accountId,

View File

@@ -112,12 +112,13 @@ export function CardFormFields({
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="card-limit">Limite (R$)</Label>
<Label htmlFor="card-limit">Limite</Label>
<CurrencyInput
id="card-limit"
value={values.limit}
onValueChange={(value) => onChange("limit", value)}
placeholder="R$ 0,00"
required
/>
</div>

View File

@@ -30,9 +30,9 @@ interface CardItemProps {
status: string;
closingDay: string;
dueDay: string;
limit: number | null;
limitInUse?: number | null;
limitAvailable?: number | null;
limit: number;
limitInUse?: number;
limitAvailable?: number;
accountName: string;
logo?: string | null;
note?: string | null;
@@ -61,30 +61,18 @@ export function CardItem({
}: CardItemProps) {
void _accountName;
const limitTotal = limit ?? null;
const used =
limitInUse ??
(limitTotal !== null && limitAvailable != null
? Math.max(limitTotal - limitAvailable, 0)
: limitTotal !== null
? 0
: null);
(limitAvailable !== undefined ? Math.max(limit - limitAvailable, 0) : 0);
const available =
limitAvailable ??
(limitTotal !== null && used !== null
? Math.max(limitTotal - used, 0)
: null);
const available = limitAvailable ?? Math.max(limit - used, 0);
const usagePercent =
limitTotal && limitTotal > 0 && used !== null
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
: 0;
limit > 0 ? Math.min(Math.max((used / limit) * 100, 0), 100) : 0;
const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(brand);
const isInactive = status?.toLowerCase() === "inativo";
const hasMetrics = limitTotal !== null && used !== null && available !== null;
return (
<Card className="flex flex-col p-6 w-full">
@@ -174,54 +162,45 @@ export function CardItem({
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4 px-0">
{hasMetrics &&
available !== null &&
used !== null &&
limitTotal !== null ? (
<>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Disponível</span>
<MoneyValues
amount={available}
className="text-xl font-semibold text-success"
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite disponível
</span>
<MoneyValues
amount={available}
className="text-xl font-semibold text-success"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite total
</span>
<MoneyValues
amount={limitTotal}
className="text-sm font-semibold text-foreground"
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Em uso</span>
<MoneyValues
amount={used}
className="text-sm font-semibold text-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Limite total</span>
<MoneyValues
amount={limit}
className="text-sm font-semibold text-foreground"
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
Limite utilizado
</span>
<MoneyValues
amount={used}
className="text-sm font-semibold text-destructive"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Progress
value={usagePercent}
className="h-2.5"
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
/>
<span className="text-xs text-muted-foreground">
{usagePercent.toFixed(1)}% utilizado
</span>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
Ainda não limite registrado para este cartão.
</p>
)}
<div className="flex flex-col gap-2">
<Progress
value={usagePercent}
className="h-2.5"
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
/>
<span className="text-xs text-muted-foreground">
{usagePercent.toFixed(1)}% utilizado
</span>
</div>
</CardContent>
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">

View File

@@ -7,11 +7,11 @@ export type Card = {
dueDay: string;
note: string | null;
logo: string | null;
limit: number | null;
limit: number;
accountId: string;
accountName: string;
limitInUse: number;
limitAvailable: number | null;
limitAvailable: number;
};
export type CardFormValues = {

View File

@@ -12,9 +12,9 @@ type CardData = {
dueDay: string;
note: string | null;
logo: string | null;
limit: number | null;
limit: number;
limitInUse: number;
limitAvailable: number | null;
limitAvailable: number;
accountId: string;
accountName: string;
};
@@ -96,15 +96,12 @@ async function fetchCardsByStatus(
dueDay: card.dueDay,
note: card.note,
logo: card.logo,
limit: card.limit ? Number(card.limit) : null,
limit: Number(card.limit),
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
})(),
limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0);

View File

@@ -5,6 +5,13 @@ export type DashboardBill = {
dueDate: string | null;
boletoPaymentDate: string | null;
isSettled: boolean;
accountId: string | null;
};
export type BillPaymentAccountOption = {
value: string;
label: string;
logo: string | null;
};
export type DashboardBillsSnapshot = {

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
type BillDialogState,
getCurrentBillDateString,
@@ -20,12 +21,26 @@ type BillWidgetController = Omit<
> & {
selectedBill: DashboardBill | null;
modalState: BillDialogState;
paymentAccountId: string;
setPaymentAccountId: (accountId: string) => void;
paymentDate: Date;
setPaymentDate: (date: Date) => void;
};
const toIsoDate = (date: Date) => date.toISOString().split("T")[0] ?? "";
export function useBillWidgetController(
bills?: DashboardBill[],
): BillWidgetController {
const safeBills = bills ?? EMPTY_BILLS;
const [paymentAccountId, setPaymentAccountId] = useState<string>("");
const [paymentDate, setPaymentDate] = useState<Date>(() => new Date());
const paymentAccountIdRef = useRef(paymentAccountId);
const paymentDateRef = useRef(paymentDate);
paymentAccountIdRef.current = paymentAccountId;
paymentDateRef.current = paymentDate;
const controller = usePaymentDialogController({
items: safeBills,
getItemId: (bill) => bill.id,
@@ -34,13 +49,36 @@ export function useBillWidgetController(
toggleTransactionSettlementAction({
id: bill.id,
value: true,
paymentAccountId: paymentAccountIdRef.current || null,
paymentDate: toIsoDate(paymentDateRef.current),
}),
applyConfirmedState: (bill) =>
markBillAsSettled(bill, getCurrentBillDateString()),
markBillAsSettled(
{
...bill,
accountId: paymentAccountIdRef.current || bill.accountId,
},
toIsoDate(paymentDateRef.current) || getCurrentBillDateString(),
),
});
const selectedBillId = controller.selectedItem?.id ?? null;
const selectedBillAccountId = controller.selectedItem?.accountId ?? "";
useEffect(() => {
if (!selectedBillId) {
return;
}
setPaymentAccountId(selectedBillAccountId ?? "");
setPaymentDate(new Date());
}, [selectedBillId, selectedBillAccountId]);
return {
...controller,
selectedBill: controller.selectedItem,
paymentAccountId,
setPaymentAccountId,
paymentDate,
setPaymentDate,
};
}

View File

@@ -20,6 +20,7 @@ import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeRefundEntries,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
@@ -168,6 +169,7 @@ export async function fetchDashboardCategoryOverview(
eq(transactions.transactionType, "Receita"),
eq(categories.type, "receita"),
excludeAutoInvoiceEntries(),
excludeRefundEntries(),
excludeInitialBalanceWhenConfigured(),
),
),

View File

@@ -1,4 +1,5 @@
import { RiCheckboxCircleFill } from "@remixicon/react";
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
import Link from "next/link";
import {
buildBillStatusLabel,
buildBillWidgetStatusLabel,
@@ -13,14 +14,25 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { getCurrentPeriod, formatPeriodForUrl } from "@/shared/utils/period";
import { cn } from "@/shared/utils/ui";
type BillListItemProps = {
bill: DashboardBill;
period?: string;
onPay: (billId: string) => void;
};
export function BillListItem({ bill, onPay }: BillListItemProps) {
function buildTransactionsHref(name: string, period?: string): string {
const params = new URLSearchParams({ q: name });
const current = getCurrentPeriod();
if (period && period !== current) {
params.set("periodo", formatPeriodForUrl(period));
}
return `/transactions?${params.toString()}`;
}
export function BillListItem({ bill, period, onPay }: BillListItemProps) {
const statusLabel = buildBillWidgetStatusLabel(bill);
const absoluteStatusLabel = buildBillStatusLabel(bill);
const overdue = isBillOverdue(bill);
@@ -28,6 +40,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
statusLabel && statusLabel !== absoluteStatusLabel
? absoluteStatusLabel
: null;
const href = buildTransactionsHref(bill.name, period);
return (
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
@@ -35,9 +48,16 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
<EstablishmentLogo name={bill.name} size={37} />
<div className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{bill.name}
</span>
<Link
href={href}
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<span className="truncate">{bill.name}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{statusLabel ? (
statusTooltipLabel ? (

View File

@@ -1,5 +1,4 @@
import {
RiBarcodeFill,
RiCalendarLine,
RiLoader4Line,
RiMoneyDollarCircleLine,
@@ -9,11 +8,18 @@ import {
formatBillDateLabel,
getBillStatusBadgeVariant,
} from "@/features/dashboard/bills/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import type {
BillPaymentAccountOption,
DashboardBill,
} from "@/features/dashboard/bills/bills-queries";
import { AccountCardSelectContent } from "@/features/transactions/components/select-items";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { PaymentSuccess } from "@/shared/components/payment-success";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card } from "@/shared/components/ui/card";
import { DatePicker } from "@/shared/components/ui/date-picker";
import {
Dialog,
DialogContent,
@@ -22,12 +28,26 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { Separator } from "@/shared/components/ui/separator";
type BillPaymentDialogProps = {
bill: DashboardBill | null;
open: boolean;
modalState: BillDialogState;
isPending: boolean;
paymentAccountId: string;
onPaymentAccountChange: (accountId: string) => void;
paymentDate: Date;
onPaymentDateChange: (date: Date) => void;
paymentAccountOptions: BillPaymentAccountOption[];
onClose: () => void;
onConfirm: () => void;
};
@@ -37,6 +57,11 @@ export function BillPaymentDialog({
open,
modalState,
isPending,
paymentAccountId,
onPaymentAccountChange,
paymentDate,
onPaymentDateChange,
paymentAccountOptions,
onClose,
onConfirm,
}: BillPaymentDialogProps) {
@@ -44,6 +69,14 @@ export function BillPaymentDialog({
const dueLabel = bill
? formatBillDateLabel(bill.dueDate, "Vencimento:")
: null;
const paidLabel = bill
? formatBillDateLabel(bill.boletoPaymentDate, "Pago em:")
: null;
const isBillPending = bill ? !bill.isSettled : false;
const paymentDateValue = paymentDate.toISOString().split("T")[0] ?? "";
const selectedAccount = paymentAccountOptions.find(
(option) => option.value === paymentAccountId,
);
return (
<Dialog
@@ -78,13 +111,12 @@ export function BillPaymentDialog({
<>
<DialogHeader>
<div className="mb-1 flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
<RiBarcodeFill className="size-5 text-primary" />
</div>
<div>
<DialogTitle>Confirmar pagamento</DialogTitle>
<DialogDescription className="mt-0.5 text-xs">
Boleto
<DialogDescription className="mt-1 text-xs">
{isBillPending
? "Escolha a conta de origem e a data em que o boleto foi pago."
: "Boleto"}
</DialogDescription>
</div>
</div>
@@ -92,62 +124,118 @@ export function BillPaymentDialog({
{bill ? (
<div className="space-y-3">
{/* Card principal */}
<div className="rounded-xl border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Boleto
</p>
<p className="text-base font-semibold text-foreground">
{bill.name}
</p>
</div>
<Card className="flex flex-row items-start gap-2 p-4">
<EstablishmentLogo
name={bill.name}
size={36}
className="size-9 shrink-0"
/>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase">
Boleto
</p>
<p className="truncate text-base font-semibold text-foreground">
{bill.name}
</p>
</div>
</Card>
{/* Métricas */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<Card className="p-3">
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-3.5" />
<span className="text-xs font-medium uppercase tracking-wide">
<span className="text-xs font-medium uppercase">
Valor
</span>
</div>
<MoneyValues
amount={bill.amount}
className="text-lg font-semibold"
className="text-xl font-semibold"
/>
</div>
</Card>
<div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<Card className="p-3">
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCalendarLine className="size-3.5" />
<span className="text-xs font-medium uppercase tracking-wide">
Vencimento
<span className="text-xs font-medium uppercase">
{bill.isSettled ? "Pago em" : "Vencimento"}
</span>
</div>
<p className="text-sm font-medium text-foreground">
{dueLabel?.replace("Vencimento: ", "") ?? "—"}
<p className="font-semibold">
{bill.isSettled
? (paidLabel?.replace("Pago em: ", "") ?? "—")
: (dueLabel?.replace("Vencimento: ", "") ?? "—")}
</p>
</Card>
</div>
<Separator />
{isBillPending ? (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="bill-widget-payment-account">
Conta de pagamento
</Label>
<Select
value={paymentAccountId}
onValueChange={onPaymentAccountChange}
disabled={
isProcessing || paymentAccountOptions.length === 0
}
>
<SelectTrigger
id="bill-widget-payment-account"
className="w-full"
>
<SelectValue placeholder="Selecione uma conta">
{selectedAccount ? (
<AccountCardSelectContent
label={selectedAccount.label}
logo={selectedAccount.logo}
/>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{paymentAccountOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={option.label}
logo={option.logo}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="bill-widget-payment-date">
Data do pagamento
</Label>
<DatePicker
id="bill-widget-payment-date"
value={paymentDateValue}
onChange={(value) => {
if (value) {
onPaymentDateChange(new Date(`${value}T00:00:00`));
}
}}
disabled={isProcessing}
/>
</div>
</div>
</div>
{/* Status */}
<div className="flex items-center justify-between rounded-xl border p-3">
<span className="text-sm text-muted-foreground">
Status atual
</span>
<Badge
variant={getBillStatusBadgeVariant(
bill.isSettled ? "Pago" : "Pendente",
)}
>
{bill.isSettled ? "Pago" : "Pendente"}
</Badge>
</div>
{/* Aviso */}
<p className="px-1 text-xs text-muted-foreground">
Você poderá editar o lançamento depois, se necessário.
</p>
) : (
<div className="flex items-center justify-between rounded-xl border p-3">
<span className="text-sm text-muted-foreground">
Status atual
</span>
<Badge variant={getBillStatusBadgeVariant("Pago")}>
Pago
</Badge>
</div>
)}
</div>
) : null}
@@ -163,7 +251,13 @@ export function BillPaymentDialog({
<Button
type="button"
onClick={onConfirm}
disabled={isProcessing || !bill || bill.isSettled}
disabled={
isProcessing ||
!bill ||
bill.isSettled ||
(isBillPending &&
(!paymentAccountId || paymentAccountOptions.length === 0))
}
>
{isProcessing ? (
<>

View File

@@ -5,10 +5,11 @@ import { BillListItem } from "./bill-list-item";
type BillsListProps = {
bills: DashboardBill[];
period?: string;
onPay: (billId: string) => void;
};
export function BillsList({ bills, onPay }: BillsListProps) {
export function BillsList({ bills, period, onPay }: BillsListProps) {
if (bills.length === 0) {
return (
<WidgetEmptyState
@@ -22,7 +23,7 @@ export function BillsList({ bills, onPay }: BillsListProps) {
return (
<ul className="flex flex-col">
{bills.map((bill) => (
<BillListItem key={bill.id} bill={bill} onPay={onPay} />
<BillListItem key={bill.id} bill={bill} period={period} onPay={onPay} />
))}
</ul>
);

View File

@@ -1,14 +1,23 @@
import type { BillDialogState } from "@/features/dashboard/bills/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import type {
BillPaymentAccountOption,
DashboardBill,
} from "@/features/dashboard/bills/bills-queries";
import { BillPaymentDialog } from "./bill-payment-dialog";
import { BillsList } from "./bills-list";
type BillsWidgetViewProps = {
bills: DashboardBill[];
period?: string;
selectedBill: DashboardBill | null;
isModalOpen: boolean;
modalState: BillDialogState;
isPending: boolean;
paymentAccountId: string;
onPaymentAccountChange: (accountId: string) => void;
paymentDate: Date;
onPaymentDateChange: (date: Date) => void;
paymentAccountOptions: BillPaymentAccountOption[];
onOpenPaymentDialog: (billId: string) => void;
onClosePaymentDialog: () => void;
onConfirmPayment: () => void;
@@ -16,10 +25,16 @@ type BillsWidgetViewProps = {
export function BillsWidgetView({
bills,
period,
selectedBill,
isModalOpen,
modalState,
isPending,
paymentAccountId,
onPaymentAccountChange,
paymentDate,
onPaymentDateChange,
paymentAccountOptions,
onOpenPaymentDialog,
onClosePaymentDialog,
onConfirmPayment,
@@ -27,7 +42,7 @@ export function BillsWidgetView({
return (
<>
<div className="flex flex-col gap-4">
<BillsList bills={bills} onPay={onOpenPaymentDialog} />
<BillsList bills={bills} period={period} onPay={onOpenPaymentDialog} />
</div>
<BillPaymentDialog
@@ -35,6 +50,11 @@ export function BillsWidgetView({
open={isModalOpen}
modalState={modalState}
isPending={isPending}
paymentAccountId={paymentAccountId}
onPaymentAccountChange={onPaymentAccountChange}
paymentDate={paymentDate}
onPaymentDateChange={onPaymentDateChange}
paymentAccountOptions={paymentAccountOptions}
onClose={onClosePaymentDialog}
onConfirm={onConfirmPayment}
/>

View File

@@ -330,7 +330,7 @@ export function DashboardGridEditable({
>
<div className="relative">
{isEditing && (
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-xs rounded-lg border border-dashed border-primary flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-medium">

View File

@@ -41,6 +41,7 @@ const CARDS = [
"Consideramos lançamentos efetivados e não efetivados da pessoa principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.",
"Reembolsos não entram como receita; eles abatem despesas e afetam o balanço líquido.",
"Saldo inicial também fica fora quando a conta está marcada para desconsiderá-lo das receitas.",
],
},
@@ -57,6 +58,7 @@ const CARDS = [
"Consideramos lançamentos efetivados e não efetivados da pessoa principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.",
"Reembolsos do período reduzem o total de despesas, sem deixar o card negativo.",
"O valor mostrado é a saída efetiva do período, sempre em número positivo no card.",
],
},
@@ -70,6 +72,7 @@ const CARDS = [
helpTitle: "Como calculamos o balanço",
helpLines: [
"Partimos de receitas menos despesas do período.",
"Reembolsos entram no resultado líquido, mas não inflam receitas nem despesas.",
"Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora do cálculo base.",
"Depois aplicamos ajustes de transferências entre contas consideradas e não consideradas no saldo total.",
"Se a transferência entra em conta considerada, soma. Se sai de conta considerada para conta não considerada, subtrai.",

View File

@@ -1,5 +1,4 @@
import {
RiBankCardLine,
RiCalendarLine,
RiLoader4Line,
RiMoneyDollarCircleLine,
@@ -10,11 +9,17 @@ import {
type InvoiceDialogState,
parseInvoiceDueDate,
} from "@/features/dashboard/invoices/invoices-helpers";
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
import type {
DashboardInvoice,
InvoicePaymentAccountOption,
} from "@/features/dashboard/invoices/invoices-queries";
import { AccountCardSelectContent } from "@/features/transactions/components/select-items";
import MoneyValues from "@/shared/components/money-values";
import { PaymentSuccess } from "@/shared/components/payment-success";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card } from "@/shared/components/ui/card";
import { DatePicker } from "@/shared/components/ui/date-picker";
import {
Dialog,
DialogContent,
@@ -23,6 +28,15 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { Separator } from "@/shared/components/ui/separator";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_LABEL,
@@ -34,6 +48,11 @@ type InvoicePaymentDialogProps = {
open: boolean;
modalState: InvoiceDialogState;
isPending: boolean;
paymentAccountId: string;
onPaymentAccountChange: (accountId: string) => void;
paymentDate: Date;
onPaymentDateChange: (date: Date) => void;
paymentAccountOptions: InvoicePaymentAccountOption[];
onClose: () => void;
onConfirm: () => void;
};
@@ -43,6 +62,11 @@ export function InvoicePaymentDialog({
open,
modalState,
isPending,
paymentAccountId,
onPaymentAccountChange,
paymentDate,
onPaymentDateChange,
paymentAccountOptions,
onClose,
onConfirm,
}: InvoicePaymentDialogProps) {
@@ -51,6 +75,12 @@ export function InvoicePaymentDialog({
const dueInfo = invoice
? parseInvoiceDueDate(invoice.period, invoice.dueDay)
: null;
const isInvoicePending =
invoice?.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING;
const paymentDateValue = paymentDate.toISOString().split("T")[0] ?? "";
const selectedAccount = paymentAccountOptions.find(
(option) => option.value === paymentAccountId,
);
return (
<Dialog
@@ -85,13 +115,12 @@ export function InvoicePaymentDialog({
<>
<DialogHeader>
<div className="mb-1 flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
<RiBankCardLine className="size-5 text-primary" />
</div>
<div>
<DialogTitle>Confirmar pagamento</DialogTitle>
<DialogDescription className="mt-0.5 text-xs">
Fatura do cartão
<DialogDescription className="mt-1 text-xs">
{isInvoicePending
? "Escolha a conta de origem e a data em que a fatura foi paga."
: "Fatura do cartão"}
</DialogDescription>
</div>
</div>
@@ -99,8 +128,7 @@ export function InvoicePaymentDialog({
{invoice ? (
<div className="space-y-3">
{/* Card principal */}
<div className="flex items-center gap-3 rounded-xl border p-4">
<Card className="flex flex-row items-start gap-2 p-4">
<InvoiceLogo
cardName={invoice.cardName}
logo={invoice.logo}
@@ -110,66 +138,119 @@ export function InvoicePaymentDialog({
fallbackClassName="text-xs"
/>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
<p className="text-xs font-medium text-muted-foreground uppercase">
Cartão
</p>
<p className="truncate text-base font-semibold text-foreground">
{invoice.cardName}
</p>
</div>
</div>
</Card>
{/* Métricas */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<Card className="p-3">
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-3.5" />
<span className="text-xs font-medium uppercase tracking-wide">
<span className="text-xs font-medium uppercase">
Total da fatura
</span>
</div>
<MoneyValues
amount={Math.abs(invoice.totalAmount)}
className="text-lg font-semibold"
className="text-xl font-semibold"
/>
</div>
</Card>
<div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<Card className="p-3">
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCalendarLine className="size-3.5" />
<span className="text-xs font-medium uppercase tracking-wide">
<span className="text-xs font-medium uppercase">
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
? "Pago em"
: "Vencimento"}
</span>
</div>
<p className="text-sm font-medium text-foreground">
<div className="font-semibold">
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
? (paymentInfo?.label ?? "—")
: (dueInfo?.label ?? "—")}
</p>
? (paymentInfo?.label?.replace(/^Pago em\s*/u, "") ??
"—")
: (dueInfo?.label?.replace(/^Vence (em|dia)\s*/u, "") ??
"—")}
</div>
</Card>
</div>
<Separator />
{isInvoicePending ? (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="invoice-widget-payment-account">
Conta de pagamento
</Label>
<Select
value={paymentAccountId}
onValueChange={onPaymentAccountChange}
disabled={
isProcessing || paymentAccountOptions.length === 0
}
>
<SelectTrigger
id="invoice-widget-payment-account"
className="w-full"
>
<SelectValue placeholder="Selecione uma conta">
{selectedAccount ? (
<AccountCardSelectContent
label={selectedAccount.label}
logo={selectedAccount.logo}
/>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{paymentAccountOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={option.label}
logo={option.logo}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="invoice-widget-payment-date">
Data do pagamento
</Label>
<DatePicker
id="invoice-widget-payment-date"
value={paymentDateValue}
onChange={(value) => {
if (value) {
onPaymentDateChange(new Date(`${value}T00:00:00`));
}
}}
disabled={isProcessing}
/>
</div>
</div>
</div>
{/* Status */}
<div className="flex items-center justify-between rounded-xl border p-3">
<span className="text-sm text-muted-foreground">
Status atual
</span>
<Badge
variant={getInvoiceStatusBadgeVariant(
INVOICE_STATUS_LABEL[invoice.paymentStatus],
)}
>
{INVOICE_STATUS_LABEL[invoice.paymentStatus]}
</Badge>
</div>
{/* Aviso */}
<p className="px-1 text-xs text-muted-foreground">
Vamos registrar a fatura como paga. Você poderá editar depois
se necessário.
</p>
) : (
<div className="flex items-center justify-between rounded-xl border p-3">
<span className="text-sm text-muted-foreground">
Status atual
</span>
<Badge
variant={getInvoiceStatusBadgeVariant(
INVOICE_STATUS_LABEL[invoice.paymentStatus],
)}
>
{INVOICE_STATUS_LABEL[invoice.paymentStatus]}
</Badge>
</div>
)}
</div>
) : null}
@@ -185,7 +266,12 @@ export function InvoicePaymentDialog({
<Button
type="button"
onClick={onConfirm}
disabled={isProcessing || !invoice}
disabled={
isProcessing ||
!invoice ||
(isInvoicePending &&
(!paymentAccountId || paymentAccountOptions.length === 0))
}
>
{isProcessing ? (
<>

View File

@@ -1,5 +1,8 @@
import type { InvoiceDialogState } from "@/features/dashboard/invoices/invoices-helpers";
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
import type {
DashboardInvoice,
InvoicePaymentAccountOption,
} from "@/features/dashboard/invoices/invoices-queries";
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
import { InvoicesList } from "./invoices-list";
@@ -9,6 +12,11 @@ type InvoicesWidgetViewProps = {
isModalOpen: boolean;
modalState: InvoiceDialogState;
isPending: boolean;
paymentAccountId: string;
onPaymentAccountChange: (accountId: string) => void;
paymentDate: Date;
onPaymentDateChange: (date: Date) => void;
paymentAccountOptions: InvoicePaymentAccountOption[];
onOpenPaymentDialog: (invoiceId: string) => void;
onClosePaymentDialog: () => void;
onConfirmPayment: () => void;
@@ -20,6 +28,11 @@ export function InvoicesWidgetView({
isModalOpen,
modalState,
isPending,
paymentAccountId,
onPaymentAccountChange,
paymentDate,
onPaymentDateChange,
paymentAccountOptions,
onOpenPaymentDialog,
onClosePaymentDialog,
onConfirmPayment,
@@ -35,6 +48,11 @@ export function InvoicesWidgetView({
open={isModalOpen}
modalState={modalState}
isPending={isPending}
paymentAccountId={paymentAccountId}
onPaymentAccountChange={onPaymentAccountChange}
paymentDate={paymentDate}
onPaymentDateChange={onPaymentDateChange}
paymentAccountOptions={paymentAccountOptions}
onClose={onClosePaymentDialog}
onConfirm={onConfirmPayment}
/>

View File

@@ -1,20 +1,35 @@
"use client";
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import type {
BillPaymentAccountOption,
DashboardBill,
} from "@/features/dashboard/bills/bills-queries";
import { useBillWidgetController } from "@/features/dashboard/bills/use-bill-widget-controller";
import { BillsWidgetView } from "../bills/bills-widget-view";
type BillWidgetProps = {
bills?: DashboardBill[];
paymentAccountOptions?: BillPaymentAccountOption[];
period?: string;
};
export function BillWidget({ bills }: BillWidgetProps) {
const EMPTY_OPTIONS: BillPaymentAccountOption[] = [];
export function BillWidget({
bills,
paymentAccountOptions = EMPTY_OPTIONS,
period,
}: BillWidgetProps) {
const {
items,
selectedBill,
isModalOpen,
modalState,
isPending,
paymentAccountId,
setPaymentAccountId,
paymentDate,
setPaymentDate,
openPaymentDialog,
closePaymentDialog,
confirmPayment,
@@ -23,10 +38,16 @@ export function BillWidget({ bills }: BillWidgetProps) {
return (
<BillsWidgetView
bills={items}
period={period}
selectedBill={selectedBill}
isModalOpen={isModalOpen}
modalState={modalState}
isPending={isPending}
paymentAccountId={paymentAccountId}
onPaymentAccountChange={setPaymentAccountId}
paymentDate={paymentDate}
onPaymentDateChange={setPaymentDate}
paymentAccountOptions={paymentAccountOptions}
onOpenPaymentDialog={openPaymentDialog}
onClosePaymentDialog={closePaymentDialog}
onConfirmPayment={confirmPayment}

View File

@@ -1,20 +1,31 @@
"use client";
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
import type {
DashboardInvoice,
InvoicePaymentAccountOption,
} from "@/features/dashboard/invoices/invoices-queries";
import { useInvoicesWidgetController } from "@/features/dashboard/invoices/use-invoices-widget-controller";
import { InvoicesWidgetView } from "../invoices/invoices-widget-view";
type InvoicesWidgetProps = {
invoices: DashboardInvoice[];
paymentAccountOptions: InvoicePaymentAccountOption[];
};
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
export function InvoicesWidget({
invoices,
paymentAccountOptions,
}: InvoicesWidgetProps) {
const {
items,
selectedInvoice,
isModalOpen,
modalState,
isPending,
paymentAccountId,
setPaymentAccountId,
paymentDate,
setPaymentDate,
openPaymentDialog,
closePaymentDialog,
confirmPayment,
@@ -27,6 +38,11 @@ export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
isModalOpen={isModalOpen}
modalState={modalState}
isPending={isPending}
paymentAccountId={paymentAccountId}
onPaymentAccountChange={setPaymentAccountId}
paymentDate={paymentDate}
onPaymentDateChange={setPaymentDate}
paymentAccountOptions={paymentAccountOptions}
onOpenPaymentDialog={openPaymentDialog}
onClosePaymentDialog={closePaymentDialog}
onConfirmPayment={confirmPayment}

View File

@@ -1,5 +1,11 @@
import { and, eq, ilike, inArray, isNotNull, sql } from "drizzle-orm";
import { cards, invoices, payers, transactions } from "@/db/schema";
import {
cards,
financialAccounts,
invoices,
payers,
transactions,
} from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import {
@@ -31,6 +37,13 @@ type RawDashboardInvoice = {
totalAmount: string | number | null;
transactionCount: string | number | null;
invoiceCreatedAt: Date | null;
cardAccountId: string | null;
};
export type InvoicePaymentAccountOption = {
value: string;
label: string;
logo: string | null;
};
type RawInvoiceBreakdownRow = {
@@ -63,11 +76,13 @@ export type DashboardInvoice = {
totalAmount: number;
paidAt: string | null;
pagadorBreakdown: InvoicePagadorBreakdown[];
defaultPaymentAccountId: string | null;
};
type DashboardInvoicesSnapshot = {
invoices: DashboardInvoice[];
totalPending: number;
paymentAccountOptions: InvoicePaymentAccountOption[];
};
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
@@ -148,7 +163,7 @@ export async function fetchDashboardInvoices(
}
}
const [rows, breakdownRows] = (await Promise.all([
const [rows, breakdownRows, accountRows] = (await Promise.all([
db
.select({
invoiceId: invoices.id,
@@ -159,6 +174,7 @@ export async function fetchDashboardInvoices(
period: invoices.period,
paymentStatus: invoices.paymentStatus,
invoiceCreatedAt: invoices.createdAt,
cardAccountId: cards.accountId,
totalAmount: sql<number | null>`
COALESCE(SUM(${transactions.amount}), 0)
`,
@@ -190,6 +206,7 @@ export async function fetchDashboardInvoices(
cards.status,
cards.logo,
cards.dueDay,
cards.accountId,
invoices.period,
invoices.paymentStatus,
),
@@ -218,7 +235,29 @@ export async function fetchDashboardInvoices(
payers.name,
payers.avatarUrl,
),
])) as [RawDashboardInvoice[], RawInvoiceBreakdownRow[]];
db
.select({
id: financialAccounts.id,
name: financialAccounts.name,
logo: financialAccounts.logo,
})
.from(financialAccounts)
.where(eq(financialAccounts.userId, userId)),
])) as [
RawDashboardInvoice[],
RawInvoiceBreakdownRow[],
{ id: string; name: string; logo: string | null }[],
];
const paymentAccountOptions: InvoicePaymentAccountOption[] = accountRows
.map((account) => ({
value: account.id,
label: account.name,
logo: account.logo,
}))
.sort((a, b) =>
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" }),
);
const groupedBreakdown = new Map<
string,
@@ -336,6 +375,7 @@ export async function fetchDashboardInvoices(
pagadorBreakdown: (
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
).sort((a, b) => b.amount - a.amount),
defaultPaymentAccountId: row.cardAccountId ?? null,
});
}
@@ -399,5 +439,6 @@ export async function fetchDashboardInvoices(
return {
invoices: invoiceList,
totalPending,
paymentAccountOptions,
};
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
getCurrentDateString,
type InvoiceDialogState,
@@ -20,27 +21,62 @@ type InvoicesWidgetController = Omit<
> & {
selectedInvoice: DashboardInvoice | null;
modalState: InvoiceDialogState;
paymentAccountId: string;
setPaymentAccountId: (accountId: string) => void;
paymentDate: Date;
setPaymentDate: (date: Date) => void;
};
export function useInvoicesWidgetController(
invoices: DashboardInvoice[],
): InvoicesWidgetController {
const [paymentAccountId, setPaymentAccountId] = useState<string>("");
const [paymentDate, setPaymentDate] = useState<Date>(() => new Date());
const paymentAccountIdRef = useRef(paymentAccountId);
const paymentDateRef = useRef(paymentDate);
paymentAccountIdRef.current = paymentAccountId;
paymentDateRef.current = paymentDate;
const controller = usePaymentDialogController({
items: invoices,
getItemId: (invoice) => invoice.id,
isItemConfirmed: (invoice) => isInvoicePaid(invoice.paymentStatus),
executeConfirm: (invoice) =>
updateInvoicePaymentStatusAction({
executeConfirm: (invoice) => {
const accountId = paymentAccountIdRef.current || undefined;
const date = paymentDateRef.current;
const isoDate = date.toISOString().split("T")[0];
return updateInvoicePaymentStatusAction({
cardId: invoice.cardId,
period: invoice.period,
status: INVOICE_PAYMENT_STATUS.PAID,
}),
paymentAccountId: accountId,
paymentDate: isoDate,
});
},
applyConfirmedState: (invoice) =>
markInvoiceAsPaid(invoice, getCurrentDateString()),
});
const selectedInvoiceId = controller.selectedItem?.id ?? null;
const selectedDefaultAccountId =
controller.selectedItem?.defaultPaymentAccountId ?? "";
useEffect(() => {
if (!selectedInvoiceId) {
return;
}
setPaymentAccountId(selectedDefaultAccountId);
setPaymentDate(new Date());
}, [selectedInvoiceId, selectedDefaultAccountId]);
return {
...controller,
selectedInvoice: controller.selectedItem,
paymentAccountId,
setPaymentAccountId,
paymentDate,
setPaymentDate,
};
}

View File

@@ -21,9 +21,11 @@ import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/tr
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
isRefundNote,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { TRANSFER_CATEGORY_NAME } from "@/shared/lib/transfers/constants";
import {
compareDateOnly,
getBusinessDateString,
@@ -58,6 +60,7 @@ type CurrentPeriodTransactionRow = {
categoryId: string | null;
categoryName: string | null;
categoryType: string | null;
accountId: string | null;
cardLogo: string | null;
accountLogo: string | null;
accountExcludeInitialBalanceFromIncome: boolean | null;
@@ -119,6 +122,9 @@ const shouldIncludeWithoutAutoInvoice = (note: string | null | undefined) =>
const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) =>
!isInitialBalanceNote(note) && !isAutoInvoiceNote(note);
const shouldIncludeWithoutRefund = (note: string | null | undefined) =>
!isRefundNote(note);
const shouldIncludeInPaymentStatus = (row: CurrentPeriodTransactionRow) => {
if (!shouldIncludeWithoutAutoInvoice(row.note)) {
return false;
@@ -183,6 +189,7 @@ const buildBillsSnapshot = (
? row.boletoPaymentDate.toISOString().slice(0, 10)
: null,
isSettled: Boolean(row.isSettled),
accountId: row.accountId ?? null,
}))
.sort((a, b) => {
if (a.isSettled !== b.isSettled) {
@@ -259,6 +266,14 @@ const buildPaymentStatusData = (
}
const amount = toNumber(row.amount);
const isRefund = isRefundNote(row.note);
if (isRefund) {
const targetKey = row.isSettled === true ? "confirmed" : "pending";
result.expenses[targetKey] -= Math.abs(amount);
continue;
}
const target =
row.transactionType === TRANSACTION_TYPE_INCOME
? result.income
@@ -271,6 +286,8 @@ const buildPaymentStatusData = (
}
}
result.expenses.confirmed = Math.max(0, result.expenses.confirmed);
result.expenses.pending = Math.max(0, result.expenses.pending);
result.income.total = result.income.confirmed + result.income.pending;
result.expenses.total = result.expenses.confirmed + result.expenses.pending;
@@ -495,7 +512,9 @@ const buildPurchasesByCategoryData = (
!row.categoryName ||
!row.categoryType ||
!["despesa", "receita"].includes(row.categoryType) ||
row.categoryName === TRANSFER_CATEGORY_NAME ||
!shouldIncludeWithoutAutoGenerated(row.note) ||
!shouldIncludeWithoutRefund(row.note) ||
!shouldIncludeNamedItem(row.name)
) {
continue;
@@ -564,6 +583,7 @@ export async function fetchDashboardCurrentPeriodOverview(
categoryId: transactions.categoryId,
categoryName: categories.name,
categoryType: categories.type,
accountId: transactions.accountId,
cardLogo: cards.logo,
accountLogo: financialAccounts.logo,
accountExcludeInitialBalanceFromIncome:

View File

@@ -1,4 +1,4 @@
import { and, asc, eq, gte, inArray, lte, sum } from "drizzle-orm";
import { and, asc, eq, gte, inArray, lte, sql } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema";
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
import type {
@@ -11,6 +11,7 @@ import {
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber } from "@/shared/utils/number";
@@ -31,6 +32,7 @@ const TRANSACTION_TYPE_TRANSFER = "Transferência";
type PeriodTotals = {
receitas: number;
despesas: number;
reembolsos: number;
transferAdjustment: number;
balanco: number;
};
@@ -39,6 +41,7 @@ type PeriodSummaryRow = {
period: string | null;
transactionType: string;
totalAmount: string | number | null;
refundAmount: string | number | null;
accountExcludeFromBalance: boolean | null;
};
@@ -50,6 +53,7 @@ type DashboardPeriodOverview = {
const createEmptyTotals = (): PeriodTotals => ({
receitas: 0,
despesas: 0,
reembolsos: 0,
transferAdjustment: 0,
balanco: 0,
});
@@ -105,11 +109,17 @@ export async function fetchDashboardPeriodOverview(
const chartPeriods = generateLast6Months(period);
const startPeriod = addMonthsToPeriod(period, -24);
const refundPattern = `${REFUND_NOTE_PREFIX}%`;
const rows = (await db
.select({
period: transactions.period,
transactionType: transactions.transactionType,
totalAmount: sum(transactions.amount).as("total"),
totalAmount: sql<number>`coalesce(sum(case when ${transactions.note} ilike ${refundPattern} then 0 else ${transactions.amount} end), 0)`.as(
"total",
),
refundAmount: sql<number>`coalesce(sum(case when ${transactions.note} ilike ${refundPattern} then ${transactions.amount} else 0 end), 0)`.as(
"refund",
),
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
})
.from(transactions)
@@ -151,6 +161,9 @@ export async function fetchDashboardPeriodOverview(
const totals = ensurePeriodTotals(periodTotals, row.period);
const total = safeToNumber(row.totalAmount);
const refund = safeToNumber(row.refundAmount);
totals.reembolsos += Math.abs(refund);
if (row.transactionType === TRANSACTION_TYPE_INCOME) {
totals.receitas += total;
@@ -179,9 +192,14 @@ export async function fetchDashboardPeriodOverview(
for (const key of periodRange) {
const totals = ensurePeriodTotals(periodTotals, key);
const netExpenses = Math.max(0, totals.despesas - totals.reembolsos);
totals.balanco =
totals.receitas - totals.despesas + totals.transferAdjustment;
totals.receitas -
totals.despesas +
totals.reembolsos +
totals.transferAdjustment;
runningForecast += totals.balanco;
totals.despesas = netExpenses;
forecastByPeriod.set(key, runningForecast);
}

View File

@@ -3,6 +3,7 @@ import { financialAccounts, transactions } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
REFUND_NOTE_PREFIX,
} from "@/shared/lib/accounts/constants";
export { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
@@ -27,6 +28,12 @@ export const excludeAutoInvoiceEntries = () =>
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
);
export const excludeRefundEntries = () =>
or(
isNull(transactions.note),
not(ilike(transactions.note, `${REFUND_NOTE_PREFIX}%`)),
);
export const excludeInitialBalanceWhenConfigured = () =>
or(
isNull(transactions.note),

View File

@@ -93,7 +93,10 @@ export const widgetsConfig: WidgetConfig[] = [
subtitle: "Resumo das faturas do período",
icon: <RiBillLine className="size-4" />,
component: ({ data }) => (
<InvoicesWidget invoices={data.invoicesSnapshot.invoices} />
<InvoicesWidget
invoices={data.invoicesSnapshot.invoices}
paymentAccountOptions={data.invoicesSnapshot.paymentAccountOptions}
/>
),
},
{
@@ -101,7 +104,13 @@ export const widgetsConfig: WidgetConfig[] = [
title: "Boletos",
subtitle: "Controle de boletos do período",
icon: <RiBarcodeLine className="size-4" />,
component: ({ data }) => <BillWidget bills={data.billsSnapshot.bills} />,
component: ({ data, period }) => (
<BillWidget
bills={data.billsSnapshot.bills}
paymentAccountOptions={data.invoicesSnapshot.paymentAccountOptions}
period={period}
/>
),
},
{
id: "payment-status",

View File

@@ -34,14 +34,16 @@ export const PROVIDERS = {
*/
export const AVAILABLE_MODELS = [
// OpenAI
{ id: "gpt-5.5-pro", name: "GPT-5.5 Pro", provider: "openai" as const },
{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" as const },
{ id: "gpt-5.4", name: "GPT-5.4", provider: "openai" as const },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", provider: "openai" as const },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano", provider: "openai" as const },
// Anthropic
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
provider: "anthropic" as const,
},
{
@@ -73,7 +75,7 @@ export const AVAILABLE_MODELS = [
},
] as const;
export const DEFAULT_MODEL = "gpt-5.4";
export const DEFAULT_MODEL = "gpt-5.5";
export const DEFAULT_PROVIDER = "openai";
/**

View File

@@ -2,8 +2,17 @@
import { and, eq, sql } from "drizzle-orm";
import { z } from "zod";
import { cards, categories, invoices, transactions } from "@/db/schema";
import { buildInvoicePaymentNote } from "@/shared/lib/accounts/constants";
import {
cards,
categories,
financialAccounts,
invoices,
transactions,
} from "@/db/schema";
import {
buildInvoicePaymentNote,
INVOICE_ADJUSTMENT_NAME,
} from "@/shared/lib/accounts/constants";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
@@ -14,6 +23,10 @@ import {
PERIOD_FORMAT_REGEX,
} from "@/shared/lib/invoices";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import {
formatCurrency,
formatDecimalForDbRequired,
} from "@/shared/utils/currency";
import {
getBusinessTodayDate,
parseLocalDateString,
@@ -36,6 +49,11 @@ const updateInvoicePaymentStatusSchema = z.object({
.refine((value) => !value || isValidPaymentDate(value), {
message: "Data de pagamento inválida.",
}),
paymentAccountId: z
.string({ message: "Conta inválida." })
.uuid("Conta inválida.")
.nullable()
.optional(),
});
type UpdateInvoicePaymentStatusInput = z.infer<
@@ -51,9 +69,6 @@ const successMessageByStatus: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
};
const formatDecimal = (value: number) =>
(Math.round(value * 100) / 100).toFixed(2);
export async function updateInvoicePaymentStatusAction(
input: UpdateInvoicePaymentStatusInput,
): Promise<ActionResult> {
@@ -121,8 +136,25 @@ export async function updateInvoicePaymentStatusAction(
const adminShare = Number(adminShareRow?.total ?? 0);
const adminPayableAmount = Math.abs(Math.min(adminShare, 0));
const paymentAccountId = data.paymentAccountId ?? card.accountId;
if (adminPayerId) {
if (!paymentAccountId) {
throw new Error("Selecione uma conta para pagar a fatura.");
}
const paymentAccount = await tx.query.financialAccounts.findFirst({
columns: { id: true },
where: and(
eq(financialAccounts.id, paymentAccountId),
eq(financialAccounts.userId, user.id),
),
});
if (!paymentAccount) {
throw new Error("Conta de pagamento não encontrada.");
}
if (card.accountId && adminPayerId) {
const paymentCategory = await tx.query.categories.findFirst({
columns: { id: true },
where: and(
@@ -131,12 +163,11 @@ export async function updateInvoicePaymentStatusAction(
),
});
// Usar a data customizada ou a data atual como data de pagamento
const invoiceDate = data.paymentDate
? parseLocalDateString(data.paymentDate)
: getBusinessTodayDate();
const amount = `-${formatDecimal(adminPayableAmount)}`;
const amount = `-${formatDecimalForDbRequired(adminPayableAmount)}`;
const payload = {
condition: "À vista",
name: `Pagamento fatura - ${card.name}`,
@@ -148,7 +179,7 @@ export async function updateInvoicePaymentStatusAction(
period: data.period,
isSettled: true,
userId: user.id,
accountId: card.accountId,
accountId: paymentAccountId,
categoryId: paymentCategory?.id ?? null,
payerId: adminPayerId,
};
@@ -270,3 +301,123 @@ export async function updatePaymentDateAction(
};
}
}
const adjustInvoiceSchema = z.object({
cardId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
currentTotal: z.number({ message: "Total atual inválido." }),
targetAmount: z
.number({ message: "Valor inválido." })
.nonnegative("O valor deve ser positivo."),
});
type AdjustInvoiceInput = z.infer<typeof adjustInvoiceSchema>;
export async function adjustInvoiceAction(
input: AdjustInvoiceInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = adjustInvoiceSchema.parse(input);
const adminPayerId = await getAdminPayerId(user.id);
let message = "Ajuste de fatura registrado.";
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cards.findFirst({
columns: { id: true },
where: and(eq(cards.id, data.cardId), eq(cards.userId, user.id)),
});
if (!card) {
throw new Error("Cartão não encontrado.");
}
const existing = await tx.query.transactions.findFirst({
columns: { id: true, amount: true },
where: and(
eq(transactions.userId, user.id),
eq(transactions.cardId, data.cardId),
eq(transactions.period, data.period),
eq(transactions.name, INVOICE_ADJUSTMENT_NAME),
),
});
const existingAmount = Number(existing?.amount ?? 0);
const baseTotal = data.currentTotal - existingAmount;
const targetTotal = -data.targetAmount;
const adjustmentAmount =
Math.round((targetTotal - baseTotal) * 100) / 100;
if (adjustmentAmount === 0) {
if (existing) {
await tx.delete(transactions).where(eq(transactions.id, existing.id));
message = "Ajuste de fatura removido.";
} else {
message = "Nada a ajustar — o valor já está correto.";
}
return;
}
const isExpense = adjustmentAmount < 0;
const categoryName = isExpense ? "Outras despesas" : "Outras receitas";
const category = await tx.query.categories.findFirst({
columns: { id: true },
where: and(
eq(categories.userId, user.id),
eq(categories.name, categoryName),
),
});
const amount = formatDecimalForDbRequired(adjustmentAmount);
const note = `O valor era ${formatCurrency(Math.abs(baseTotal))} mas o correto é ${formatCurrency(data.targetAmount)}.`;
const payload = {
condition: "À vista",
name: INVOICE_ADJUSTMENT_NAME,
paymentMethod: "Cartão de crédito",
note,
amount,
purchaseDate: getBusinessTodayDate(),
transactionType: isExpense
? ("Despesa" as const)
: ("Receita" as const),
period: data.period,
userId: user.id,
cardId: data.cardId,
accountId: null,
categoryId: category?.id ?? null,
payerId: adminPayerId,
};
if (existing) {
await tx
.update(transactions)
.set(payload)
.where(eq(transactions.id, existing.id));
} else {
await tx.insert(transactions).values(payload);
}
});
revalidateForEntity("cards", user.id);
return { success: true, message };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import { adjustInvoiceAction } from "@/features/invoices/actions";
import { Button } from "@/shared/components/ui/button";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { formatCurrency } from "@/shared/utils/currency";
type AdjustInvoiceDialogProps = {
trigger: React.ReactNode;
cardId: string;
period: string;
currentTotal: number;
};
export function AdjustInvoiceDialog({
trigger,
cardId,
period,
currentTotal,
}: AdjustInvoiceDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const currentAbs = Math.abs(currentTotal);
const [amount, setAmount] = useState<string>(currentAbs.toFixed(2));
useEffect(() => {
if (open) {
setAmount(currentAbs.toFixed(2));
}
}, [open, currentAbs]);
const targetAmount = Number(amount);
const diff = Number.isFinite(targetAmount)
? Math.round((targetAmount - currentAbs) * 100) / 100
: 0;
const diffLabel =
diff > 0
? `Será criado um lançamento de despesa de ${formatCurrency(diff)}.`
: diff < 0
? `Será criado um lançamento de receita de ${formatCurrency(Math.abs(diff))}.`
: "Nenhum ajuste será criado — o valor já está correto.";
const handleSave = () => {
if (!Number.isFinite(targetAmount) || targetAmount < 0) {
toast.error("Informe um valor válido.");
return;
}
startTransition(async () => {
const result = await adjustInvoiceAction({
cardId,
period,
currentTotal,
targetAmount,
});
if (result.success) {
toast.success(result.message);
setOpen(false);
router.refresh();
return;
}
toast.error(result.error);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ajustar fatura</DialogTitle>
<DialogDescription>
Informe o valor real da fatura. A diferença em relação ao total
atual será lançada como um ajuste no período.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<p className="text-muted-foreground">Total atual no sistema</p>
<p className="font-medium text-foreground">
{formatCurrency(currentAbs)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="adjust-target">Valor correto da fatura</Label>
<CurrencyInput
id="adjust-target"
value={amount}
onValueChange={setAmount}
autoFocus
/>
<p className="text-xs text-muted-foreground">{diffLabel}</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="button" onClick={handleSave} disabled={isPending}>
{isPending ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { RiEditLine } from "@remixicon/react";
import { RiEditLine, RiEqualizerLine } from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
@@ -10,11 +10,30 @@ import {
updateInvoicePaymentStatusAction,
updatePaymentDateAction,
} from "@/features/invoices/actions";
import { AccountCardSelectContent } from "@/features/transactions/components/select-items";
import MoneyValues from "@/shared/components/money-values";
import StatusDot from "@/shared/components/status-dot";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets";
import {
INVOICE_PAYMENT_STATUS,
@@ -27,8 +46,15 @@ import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatCurrency } from "@/shared/utils/currency";
import { formatDateOnly } from "@/shared/utils/date";
import { cn } from "@/shared/utils/ui";
import { AdjustInvoiceDialog } from "./adjust-invoice-dialog";
import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
type PaymentAccountOption = {
value: string;
label: string;
logo?: string | null;
};
type InvoiceSummaryCardProps = {
cardId: string;
period: string;
@@ -42,6 +68,8 @@ type InvoiceSummaryCardProps = {
limitAmount: number | null;
invoiceStatus: InvoicePaymentStatus;
paymentDate: Date | null;
defaultPaymentAccountId: string | null;
paymentAccountOptions: PaymentAccountOption[];
logo?: string | null;
actions?: React.ReactNode;
};
@@ -87,6 +115,8 @@ export function InvoiceSummaryCard({
limitAmount,
invoiceStatus,
paymentDate: initialPaymentDate,
defaultPaymentAccountId,
paymentAccountOptions,
logo,
actions,
}: InvoiceSummaryCardProps) {
@@ -95,11 +125,21 @@ export function InvoiceSummaryCard({
const [paymentDate, setPaymentDate] = useState<Date>(
initialPaymentDate ?? new Date(),
);
const [paymentAccountId, setPaymentAccountId] = useState<string>(
defaultPaymentAccountId ?? paymentAccountOptions[0]?.value ?? "",
);
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
useEffect(() => {
setPaymentDate(initialPaymentDate ?? new Date());
}, [initialPaymentDate]);
useEffect(() => {
setPaymentAccountId(
defaultPaymentAccountId ?? paymentAccountOptions[0]?.value ?? "",
);
}, [defaultPaymentAccountId, paymentAccountOptions]);
const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(cardBrand);
const isPaid = invoiceStatus === INVOICE_PAYMENT_STATUS.PAID;
@@ -112,7 +152,7 @@ export function InvoiceSummaryCard({
? INVOICE_PAYMENT_STATUS.PENDING
: INVOICE_PAYMENT_STATUS.PAID;
const handleAction = () => {
const handleAction = (accountId?: string) => {
startTransition(async () => {
const result = await updateInvoicePaymentStatusAction({
cardId,
@@ -122,10 +162,13 @@ export function InvoiceSummaryCard({
targetStatus === INVOICE_PAYMENT_STATUS.PAID
? paymentDate.toISOString().split("T")[0]
: undefined,
paymentAccountId:
targetStatus === INVOICE_PAYMENT_STATUS.PAID ? accountId : undefined,
});
if (result.success) {
toast.success(result.message);
setPaymentDialogOpen(false);
router.refresh();
return;
}
@@ -134,6 +177,15 @@ export function InvoiceSummaryCard({
});
};
const handlePaymentConfirm = () => {
if (!paymentAccountId) {
toast.error("Selecione uma conta para pagar a fatura.");
return;
}
handleAction(paymentAccountId);
};
const handleDateChange = (newDate: Date) => {
setPaymentDate(newDate);
startTransition(async () => {
@@ -190,13 +242,31 @@ export function InvoiceSummaryCard({
{/* Linha 2 — valor da fatura (hero) */}
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Valor da fatura</p>
<MoneyValues
amount={Math.abs(totalAmount)}
className={cn(
"text-3xl tracking-tighter font-semibold",
isPaid ? "text-success" : "text-foreground",
)}
/>
<div className="flex items-center gap-2">
<MoneyValues
amount={Math.abs(totalAmount)}
className={cn(
"text-3xl tracking-tighter font-semibold",
isPaid ? "text-success" : "text-foreground",
)}
/>
<AdjustInvoiceDialog
cardId={cardId}
period={period}
currentTotal={totalAmount}
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Ajustar fatura"
>
<RiEqualizerLine className="size-4" />
</Button>
}
/>
</div>
<div className="flex items-center gap-2">
<Badge
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
@@ -228,7 +298,7 @@ export function InvoiceSummaryCard({
</MetaItem>
{typeof limitAmount === "number" ? (
<MetaItem label="Limite">
<MetaItem label="Limite total">
<span className="text-sm font-medium text-foreground">
{formatCurrency(limitAmount)}
</span>
@@ -263,16 +333,45 @@ export function InvoiceSummaryCard({
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
type="button"
size="sm"
variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending}
onClick={handleAction}
className="min-w-32"
>
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]}
</Button>
{isPaid ? (
<Button
type="button"
size="sm"
variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending}
onClick={() => handleAction()}
className="min-w-32"
>
{isPending
? "Salvando..."
: actionLabelByStatus[invoiceStatus]}
</Button>
) : (
<PayInvoiceDialog
open={paymentDialogOpen}
onOpenChange={setPaymentDialogOpen}
isPending={isPending}
paymentDate={paymentDate}
onPaymentDateChange={setPaymentDate}
accountId={paymentAccountId}
onAccountChange={setPaymentAccountId}
accountOptions={paymentAccountOptions}
onConfirm={handlePaymentConfirm}
trigger={
<Button
type="button"
size="sm"
variant={actionVariantByStatus[invoiceStatus]}
disabled={isPending}
className="min-w-32"
>
{isPending
? "Salvando..."
: actionLabelByStatus[invoiceStatus]}
</Button>
}
/>
)}
{isPaid ? (
<EditPaymentDateDialog
trigger={
@@ -308,3 +407,112 @@ function MetaItem({ label, children }: { label: string; children: ReactNode }) {
</div>
);
}
type PayInvoiceDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
isPending: boolean;
paymentDate: Date;
onPaymentDateChange: (date: Date) => void;
accountId: string;
onAccountChange: (accountId: string) => void;
accountOptions: PaymentAccountOption[];
onConfirm: () => void;
trigger: ReactNode;
};
function PayInvoiceDialog({
open,
onOpenChange,
isPending,
paymentDate,
onPaymentDateChange,
accountId,
onAccountChange,
accountOptions,
onConfirm,
trigger,
}: PayInvoiceDialogProps) {
const paymentDateValue = paymentDate.toISOString().split("T")[0] ?? "";
const selectedAccount = accountOptions.find(
(option) => option.value === accountId,
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Confirmar pagamento</DialogTitle>
<DialogDescription>
Escolha a conta de origem e a data em que a fatura foi paga.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="invoice-payment-account">Conta de pagamento</Label>
<Select
value={accountId}
onValueChange={onAccountChange}
disabled={isPending || accountOptions.length === 0}
>
<SelectTrigger id="invoice-payment-account" className="w-full">
<SelectValue placeholder="Selecione uma conta">
{selectedAccount ? (
<AccountCardSelectContent
label={selectedAccount.label}
logo={selectedAccount.logo}
/>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{accountOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={option.label}
logo={option.logo}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="invoice-payment-date">Data do pagamento</Label>
<DatePicker
id="invoice-payment-date"
value={paymentDateValue}
onChange={(value) => {
if (value) {
onPaymentDateChange(new Date(`${value}T00:00:00`));
}
}}
disabled={isPending}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="button"
onClick={onConfirm}
disabled={isPending || accountOptions.length === 0}
>
{isPending ? "Confirmando..." : "Confirmar pagamento"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -13,7 +13,6 @@ import {
} from "@/shared/components/ui/sheet";
const navLinks = [
{ href: "#telas", label: "Conheça as telas" },
{ href: "#funcionalidades", label: "Funcionalidades" },
{ href: "#mobile", label: "Mobile" },
{ href: "#stack", label: "Stack" },

View File

@@ -1,114 +0,0 @@
"use client";
import {
RiArrowLeftRightLine,
RiAtLine,
RiBankCard2Line,
RiBarChart2Line,
RiCalendarEventLine,
RiFileDownloadLine,
RiSecurePaymentLine,
} from "@remixicon/react";
import Image from "next/image";
import { landingImages } from "@/features/landing/images";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
const { screenshots } = landingImages;
const sections = [
{
value: "lancamentos",
label: "Lançamentos",
icon: RiArrowLeftRightLine,
...screenshots.lancamentos,
},
{
value: "pre-lancamentos",
label: "Pré-lançamentos",
icon: RiAtLine,
...screenshots.preLancamentos,
},
{
value: "importacao",
label: "Importação",
icon: RiFileDownloadLine,
...screenshots.importacao,
},
{
value: "orcamentos",
label: "Orçamentos",
icon: RiBarChart2Line,
...screenshots.orcamentos,
},
{
value: "parcelas",
label: "Análise de Parcelas",
icon: RiSecurePaymentLine,
...screenshots.parcelas,
},
{
value: "calendario",
label: "Calendário",
icon: RiCalendarEventLine,
...screenshots.calendario,
},
{
value: "cartoes",
label: "Cartões",
icon: RiBankCard2Line,
...screenshots.cartoes,
},
];
export function ScreenshotTabs() {
return (
<Tabs defaultValue="lancamentos" className="w-full">
<div className="flex flex-col gap-6">
<TabsList className="w-full h-auto flex-wrap gap-1">
{sections.map((s) => (
<TabsTrigger
key={s.value}
value={s.value}
className="flex-1 gap-1.5 lowercase"
>
<s.icon className="size-4" />
{s.label}
</TabsTrigger>
))}
</TabsList>
{sections.map((s) => (
<TabsContent key={s.value} value={s.value} className="w-full mt-0">
<div className="rounded-lg overflow-hidden border bg-card">
<div className="flex items-center gap-1.5 px-3 h-8 border-b bg-muted/50">
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
<div className="ml-2 flex-1 max-w-52 h-4 rounded bg-muted-foreground/10" />
</div>
<Image
src={s.light}
alt={`Preview ${s.label}`}
width={1920}
height={1080}
className="w-full h-auto dark:hidden"
/>
<Image
src={s.dark}
alt={`Preview ${s.label}`}
width={1920}
height={1080}
className="w-full h-auto hidden dark:block"
/>
</div>
</TabsContent>
))}
</div>
</Tabs>
);
}

View File

@@ -36,7 +36,6 @@ export type FeatureItem = {
};
export const navLinks = [
{ href: "#telas", label: "Conheça as telas" },
{ href: "#funcionalidades", label: "Funcionalidades" },
{ href: "#mobile", label: "Mobile" },
{ href: "#stack", label: "Stack" },
@@ -160,22 +159,6 @@ export const pwaHighlights: FeatureItem[] = [
},
];
export const pwaCompatList = [
{
label: "Android",
description:
"Chrome e Edge — instale pelo banner ou pelo menu do navegador",
},
{
label: "iOS / iPadOS",
description: "Safari — adicione à tela inicial pelo menu compartilhar",
},
{
label: "Desktop",
description: "Chrome, Edge e outros — instale pela barra de endereço",
},
] as const;
export const companionSteps: FeatureItem[] = [
{
icon: RiNotification3Line,

View File

@@ -1,63 +1,14 @@
/**
* Centraliza todos os assets de imagem da landing page.
* Para adicionar ou renomear uma imagem, altere apenas aqui.
*
* Convenção:
* - { light, dark } → imagem com variante de tema
* - string → imagem única (sem variante dark)
*/
export const landingImages = {
/** Preview do dashboard no hero da página */
hero: {
light: "/images/dashboard-preview-light.webp",
dark: "/images/dashboard-preview-dark.webp",
light: "/images/dashboard-preview-light.png",
dark: "/images/dashboard-preview-dark.png",
},
/** Mockup do app instalado como PWA */
pwa: {
light: "/images/pwa-preview-light.webp",
dark: "/images/pwa-preview-dark.webp",
},
/** Mockup do Companion Android */
companion: {
light: "/images/companion-preview-light.webp",
dark: "/images/companion-preview-dark.webp",
},
/** Screenshots usados nas abas da seção "Conheça as telas" */
screenshots: {
lancamentos: {
light: "/images/preview-lancamentos-light.webp",
dark: "/images/preview-lancamentos-dark.webp",
},
/** Ainda sem print próprio — usando lançamentos como placeholder */
preLancamentos: {
light: "/images/preview-pre-lancamentos-light.webp",
dark: "/images/preview-pre-lancamentos-dark.webp",
},
importacao: {
light: "/images/preview-importacao-light.webp",
dark: "/images/preview-importacao-dark.webp",
},
/** Ainda sem print próprio — usando lançamentos como placeholder */
orcamentos: {
light: "/images/preview-orcamentos-light.webp",
dark: "/images/preview-orcamentos-dark.webp",
},
/** Ainda sem print próprio — usando lançamentos como placeholder */
parcelas: {
light: "/images/preview-parcelas-light.webp",
dark: "/images/preview-parcelas-dark.webp",
},
calendario: {
light: "/images/preview-calendario-light.webp",
dark: "/images/preview-calendario-dark.webp",
},
cartoes: {
light: "/images/preview-cartao-light.webp",
dark: "/images/preview-cartao-dark.webp",
},
},
} as const;

View File

@@ -5,11 +5,13 @@ type NoteTasksSummaryInput = {
const NOTE_CREATED_AT_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "medium",
timeZone: "America/Sao_Paulo",
});
const NOTE_CREATED_AT_LONG_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
timeZone: "America/Sao_Paulo",
});
const parseNoteDate = (value: string | Date | null | undefined) => {

View File

@@ -1,6 +1,5 @@
"use server";
import { randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
@@ -17,6 +16,7 @@ import {
PAYER_ROLE_THIRD_PARTY,
PAYER_STATUS_OPTIONS,
} from "@/shared/lib/payers/constants";
import { generateShareCode } from "@/shared/lib/payers/share-code";
import { normalizeAvatarPath } from "@/shared/lib/payers/utils";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import type { ActionResult } from "@/shared/lib/types/actions";
@@ -83,12 +83,6 @@ type ShareCodeRegenerateInput = z.infer<typeof shareCodeRegenerateSchema>;
const revalidate = (userId: string) => revalidateForEntity("payers", userId);
const generateShareCode = () => {
// base64url já retorna apenas [a-zA-Z0-9_-]
// 18 bytes = 24 caracteres em base64
return randomBytes(18).toString("base64url").slice(0, 24);
};
export async function createPayerAction(
input: CreateInput,
): Promise<ActionResult> {

View File

@@ -224,8 +224,8 @@ export function CategoryReportExport({
const doc = new jsPDF({ orientation: "landscape" });
const primaryColor = getPrimaryPdfColor();
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
loadExportLogoDataUrl("/images/logo_small.png"),
loadExportLogoDataUrl("/images/logo_text.png"),
loadExportLogoDataUrl("/images/logo_small.svg"),
loadExportLogoDataUrl("/images/logo_text.svg"),
]);
let brandingEndX = 14;

View File

@@ -17,6 +17,7 @@ import {
PAYER_STATUS_OPTIONS,
} from "@/shared/lib/payers/constants";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { generateShareCode } from "@/shared/lib/payers/share-code";
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
import { deleteS3Object } from "@/shared/lib/storage/presign";
@@ -153,6 +154,7 @@ async function resetUserAppData(
note: null,
role: PAYER_ROLE_ADMIN,
isAutoSend: false,
shareCode: generateShareCode(),
userId,
});
});

View File

@@ -98,7 +98,7 @@ export function DeleteAccountForm() {
<li>
Preferências do app, insights salvos e tokens do Companion
</li>
<li className="font-medium text-foreground">
<li>
Categorias padrão e pessoa admin serão recriadas automaticamente
</li>
</ul>
@@ -128,6 +128,7 @@ export function DeleteAccountForm() {
<ul className="list-disc list-inside text-sm text-destructive space-y-1">
<li>Lançamentos, orçamentos e anotações</li>
<li>Faturas, antecipações e pré-lançamentos</li>
<li>Contas, cartões e categorias</li>
<li>Pessoas, credenciais e configurações</li>
<li className="font-medium">

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, isNull, ne, not, or, sql } from "drizzle-orm";
import { z } from "zod";
import {
cards,
@@ -7,7 +7,7 @@ import {
financialAccounts,
invoices,
payers,
type transactions,
transactions,
} from "@/db/schema";
import {
PAYMENT_METHODS,
@@ -203,6 +203,82 @@ export async function validateAllOwnership(
return null;
}
// ============================================================================
// Card Limit Validation
// ============================================================================
const formatBRL = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export async function validateCardLimit({
userId,
cardId,
addAmount,
excludeTransactionIds = [],
}: {
userId: string;
cardId: string;
addAmount: number;
excludeTransactionIds?: string[];
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (addAmount <= 0) {
return { ok: true };
}
const card = await db.query.cards.findFirst({
columns: { limit: true },
where: and(eq(cards.id, cardId), eq(cards.userId, userId)),
});
if (!card) {
return { ok: false, error: "Cartão não encontrado." };
}
const limit = Number(card.limit);
if (!Number.isFinite(limit) || limit <= 0) {
return { ok: true };
}
const conditions = [
eq(transactions.userId, userId),
eq(transactions.cardId, cardId),
or(isNull(transactions.isSettled), eq(transactions.isSettled, false)),
or(
ne(transactions.condition, "Recorrente"),
sql`${transactions.purchaseDate} <= current_date`,
),
];
if (excludeTransactionIds.length > 0) {
conditions.push(not(inArray(transactions.id, excludeTransactionIds)));
}
const [row] = await db
.select({
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(and(...conditions));
const sumAmount = Number(row?.total ?? 0);
const inUse = sumAmount < 0 ? Math.abs(sumAmount) : 0;
const available = Math.max(limit - inUse, 0);
if (addAmount > available + 0.005) {
return {
ok: false,
error: `Lançamento de ${formatBRL(addAmount)} excede o limite disponível do cartão (${formatBRL(
available,
)}).`,
};
}
return { ok: true };
}
// ============================================================================
// Utility Functions
// ============================================================================
@@ -415,6 +491,11 @@ export const toggleSettlementSchema = z.object({
value: z.boolean({
message: "Informe o status de pagamento.",
}),
paymentAccountId: uuidSchema("Conta de pagamento").nullable().optional(),
paymentDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/u, "Data de pagamento inválida.")
.optional(),
});
export type BaseInput = z.infer<typeof baseFields>;

View File

@@ -0,0 +1,178 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { cards, categories, transactions } from "@/db/schema";
import {
buildRefundNote,
isRefundNote,
} from "@/shared/lib/accounts/constants";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { PERIOD_FORMAT_REGEX } from "@/shared/lib/invoices";
import type { ActionResult } from "@/shared/lib/types/actions";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { parseLocalDateString } from "@/shared/utils/date";
import {
formatPaidInvoicePeriods,
getPaidInvoicePeriods,
revalidate,
} from "./core";
const refundSchema = z.object({
originalTransactionId: z
.string({ message: "Lançamento inválido." })
.uuid("Lançamento inválido."),
refundDate: z
.string({ message: "Data inválida." })
.refine(
(value) => !Number.isNaN(parseLocalDateString(value).getTime()),
"Data inválida.",
),
refundPeriod: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
});
type RefundInput = z.infer<typeof refundSchema>;
export async function refundTransactionAction(
input: RefundInput,
): Promise<ActionResult<{ refundId: string }>> {
try {
const user = await getUser();
const data = refundSchema.parse(input);
const original = await db.query.transactions.findFirst({
where: and(
eq(transactions.id, data.originalTransactionId),
eq(transactions.userId, user.id),
),
});
if (!original) {
return { success: false, error: "Lançamento não encontrado." };
}
if (original.transactionType !== "Despesa") {
return {
success: false,
error: "Apenas despesas podem ser estornadas.",
};
}
if (original.condition !== "À vista") {
return {
success: false,
error: "Apenas lançamentos à vista podem ser estornados.",
};
}
if (original.splitGroupId) {
return {
success: false,
error: "Lançamentos divididos não podem ser estornados.",
};
}
if (isRefundNote(original.note)) {
return {
success: false,
error: "Este lançamento já é um reembolso.",
};
}
const [existingRefund, card, paidPeriods, refundCategory] =
await Promise.all([
db.query.transactions.findFirst({
columns: { id: true },
where: and(
eq(transactions.userId, user.id),
eq(transactions.note, buildRefundNote(original.id)),
),
}),
original.cardId
? db.query.cards.findFirst({
columns: { id: true },
where: and(
eq(cards.id, original.cardId),
eq(cards.userId, user.id),
),
})
: Promise.resolve(null),
original.cardId
? getPaidInvoicePeriods(user.id, original.cardId, [data.refundPeriod])
: Promise.resolve([] as string[]),
db.query.categories.findFirst({
columns: { id: true },
where: and(
eq(categories.userId, user.id),
eq(categories.name, "Reembolso"),
),
}),
]);
if (existingRefund) {
return {
success: false,
error: "Este lançamento já foi estornado.",
};
}
if (original.cardId && !card) {
return { success: false, error: "Cartão não encontrado." };
}
if (paidPeriods.length > 0) {
return {
success: false,
error: `A fatura de ${formatPaidInvoicePeriods(
paidPeriods,
)} já está paga. Desfaça o pagamento antes de lançar o reembolso.`,
};
}
const amountAbs = Math.abs(Number(original.amount));
const refundDate = parseLocalDateString(data.refundDate);
const [inserted] = await db
.insert(transactions)
.values({
name: `Reembolso de: ${original.name}`,
condition: "À vista",
paymentMethod: original.paymentMethod,
note: buildRefundNote(original.id),
amount: formatDecimalForDbRequired(amountAbs),
purchaseDate: refundDate,
transactionType: "Receita",
period: data.refundPeriod,
isSettled: false,
userId: user.id,
cardId: original.cardId,
accountId: original.accountId,
categoryId: refundCategory?.id ?? null,
payerId: original.payerId,
})
.returning({ id: transactions.id });
revalidate(user.id);
return {
success: true,
message: "Reembolso registrado.",
data: { refundId: inserted?.id ?? "" },
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}

View File

@@ -42,6 +42,7 @@ import {
type UpdateInput,
updateSchema,
validateAllOwnership,
validateCardLimit,
} from "./core";
export async function createTransactionAction(
@@ -132,6 +133,20 @@ export async function createTransactionAction(
)} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`,
} as ActionResult<{ ids: string[] }>;
}
if (data.transactionType === "Despesa") {
const limitCheck = await validateCardLimit({
userId: user.id,
cardId: data.cardId,
addAmount: Math.abs(data.amount),
});
if (!limitCheck.ok) {
return {
success: false,
error: limitCheck.error,
} as ActionResult<{ ids: string[] }>;
}
}
}
const inserted = await db
@@ -287,6 +302,22 @@ export async function updateTransactionAction(
}
}
if (
data.paymentMethod === "Cartão de crédito" &&
data.cardId &&
data.transactionType === "Despesa"
) {
const limitCheck = await validateCardLimit({
userId: user.id,
cardId: data.cardId,
addAmount: Math.abs(data.amount),
excludeTransactionIds: [data.id],
});
if (!limitCheck.ok) {
return { success: false, error: limitCheck.error };
}
}
await db
.update(transactions)
.set({
@@ -582,7 +613,7 @@ export async function toggleTransactionSettlementAction(
const data = toggleSettlementSchema.parse(input);
const existing = await db.query.transactions.findFirst({
columns: { id: true, paymentMethod: true },
columns: { id: true, paymentMethod: true, accountId: true },
where: and(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
@@ -601,18 +632,52 @@ export async function toggleTransactionSettlementAction(
}
const isBoleto = existing.paymentMethod === "Boleto";
const customPaymentDate =
isBoleto && data.value && data.paymentDate
? parseLocalDateString(data.paymentDate)
: null;
const boletoPaymentDate = isBoleto
? data.value
? getBusinessTodayDate()
? (customPaymentDate ?? getBusinessTodayDate())
: null
: null;
const shouldUpdateAccount =
isBoleto && data.value && data.paymentAccountId !== undefined;
if (shouldUpdateAccount && data.paymentAccountId) {
const paymentAccount = await db.query.financialAccounts.findFirst({
columns: { id: true },
where: and(
eq(financialAccounts.id, data.paymentAccountId),
eq(financialAccounts.userId, user.id),
),
});
if (!paymentAccount) {
return {
success: false,
error: "Conta de pagamento não encontrada.",
};
}
}
const updatePayload: {
isSettled: boolean;
boletoPaymentDate: Date | null;
accountId?: string | null;
} = {
isSettled: data.value,
boletoPaymentDate,
};
if (shouldUpdateAccount) {
updatePayload.accountId = data.paymentAccountId ?? null;
}
await db
.update(transactions)
.set({
isSettled: data.value,
boletoPaymentDate,
})
.set(updatePayload)
.where(
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
);

View File

@@ -0,0 +1,182 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { refundTransactionAction } from "@/features/transactions/actions/refund-action";
import { deriveCreditCardPeriod } from "@/features/transactions/form-helpers";
import { formatDate } from "@/features/transactions/formatting-helpers";
import { PeriodPicker } from "@/shared/components/period-picker";
import { Button } from "@/shared/components/ui/button";
import { DatePicker } from "@/shared/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { formatCurrency } from "@/shared/utils/currency";
import { derivePeriodFromDate, displayPeriod } from "@/shared/utils/period";
import type { SelectOption, TransactionItem } from "../types";
type RefundTransactionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
transaction: TransactionItem | null;
cardOptions: SelectOption[];
};
const todayIso = () => new Date().toISOString().split("T")[0] ?? "";
function deriveDefaultRefundPeriod(
refundDate: string,
transaction: TransactionItem | null,
card: SelectOption | null,
) {
if (transaction?.cardId) {
return deriveCreditCardPeriod(
refundDate,
card?.closingDay ?? null,
card?.dueDay ?? null,
);
}
return derivePeriodFromDate(refundDate);
}
export function RefundTransactionDialog({
open,
onOpenChange,
transaction,
cardOptions,
}: RefundTransactionDialogProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [refundDate, setRefundDate] = useState<string>(todayIso());
const [refundPeriod, setRefundPeriod] = useState<string>("");
const card = useMemo(() => {
if (!transaction?.cardId) return null;
return cardOptions.find((opt) => opt.value === transaction.cardId) ?? null;
}, [transaction?.cardId, cardOptions]);
useEffect(() => {
if (open) {
const today = todayIso();
setRefundDate(today);
setRefundPeriod(deriveDefaultRefundPeriod(today, transaction, card));
}
}, [open, transaction, card]);
const defaultPeriod = useMemo(
() => deriveDefaultRefundPeriod(refundDate, transaction, card),
[refundDate, transaction, card],
);
if (!transaction) return null;
const amountAbs = Math.abs(transaction.amount);
const periodLabel = refundPeriod ? displayPeriod(refundPeriod) : "—";
const destinationLabel = transaction.cardId
? `na fatura de ${periodLabel}`
: `no extrato de ${periodLabel}`;
const handleSubmit = () => {
if (!refundDate) {
toast.error("Informe a data do reembolso.");
return;
}
if (!refundPeriod) {
toast.error("Informe o período do reembolso.");
return;
}
startTransition(async () => {
const result = await refundTransactionAction({
originalTransactionId: transaction.id,
refundDate,
refundPeriod,
});
if (result.success) {
toast.success(result.message);
onOpenChange(false);
router.refresh();
return;
}
toast.error(result.error);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Registrar reembolso</DialogTitle>
<DialogDescription>
Será criado um lançamento de reembolso espelhando esta despesa. O
lançamento original será mantido.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<p className="font-medium text-foreground">{transaction.name}</p>
<p className="text-muted-foreground">
{formatCurrency(amountAbs)} {" "}
{formatDate(transaction.purchaseDate)} {" "}
{transaction.paymentMethod}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="refund-date">Data do reembolso</Label>
<DatePicker
id="refund-date"
value={refundDate}
onChange={(value) => {
if (!value) return;
setRefundDate(value);
setRefundPeriod(
deriveDefaultRefundPeriod(value, transaction, card),
);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="refund-period">
{transaction.cardId
? "Fatura do reembolso"
: "Período do reembolso"}
</Label>
<PeriodPicker
value={refundPeriod || defaultPeriod}
onChange={setRefundPeriod}
disabled={isPending}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
O reembolso será lançado {destinationLabel}.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="button" onClick={handleSubmit} disabled={isPending}>
{isPending ? "Registrando..." : "Registrar reembolso"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -64,6 +64,9 @@ export function TransactionDetailsDialog({
: 0;
const isBoleto = transaction.paymentMethod === "Boleto";
const shortTransactionId = `${
transaction.id.split("-").at(-1) ?? transaction.id
}`;
const handleEdit = () => {
onOpenChange(false);
@@ -120,6 +123,12 @@ export function TransactionDetailsDialog({
Detalhes
</h3>
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
<DetailRow
label="ID"
value={shortTransactionId}
valueClassName="font-mono"
/>
<DetailRow
label="Período"
value={formatPeriod(transaction.period)}
@@ -253,13 +262,16 @@ export function TransactionDetailsDialog({
interface DetailRowProps {
label: string;
value: string;
valueClassName?: string;
}
function DetailRow({ label, value }: DetailRowProps) {
function DetailRow({ label, value, valueClassName }: DetailRowProps) {
return (
<li className="min-w-0 flex items-center justify-between gap-3">
<span className="text-muted-foreground">{label}</span>
<span className="min-w-0 truncate">{value}</span>
<span className={`min-w-0 truncate ${valueClassName ?? ""}`}>
{value}
</span>
</li>
);
}

View File

@@ -33,6 +33,7 @@ import {
MassAddDialog,
type MassAddFormData,
} from "../dialogs/mass-add-dialog";
import { RefundTransactionDialog } from "../dialogs/refund-transaction-dialog";
import {
SplitPairDialog,
type SplitPairScope,
@@ -183,6 +184,9 @@ export function TransactionsPage({
const [transactionsToImport, setTransactionsToImport] = useState<
TransactionItem[]
>([]);
const [refundOpen, setRefundOpen] = useState(false);
const [transactionToRefund, setTransactionToRefund] =
useState<TransactionItem | null>(null);
const handleToggleSettlement = async (item: TransactionItem) => {
if (item.paymentMethod === "Cartão de crédito") {
@@ -539,6 +543,11 @@ export function TransactionsPage({
setDetailsOpen(true);
};
const handleRefund = (item: TransactionItem) => {
setTransactionToRefund(item);
setRefundOpen(true);
};
const handleAnticipate = (item: TransactionItem) => {
setSelectedForAnticipation(item);
setAnticipateOpen(true);
@@ -571,6 +580,7 @@ export function TransactionsPage({
onBulkDelete={handleMultipleBulkDelete}
onBulkImport={handleBulkImport}
onViewDetails={handleViewDetails}
onRefund={handleRefund}
onToggleSettlement={handleToggleSettlement}
onAnticipate={handleAnticipate}
onViewAnticipationHistory={handleViewAnticipationHistory}
@@ -683,6 +693,18 @@ export function TransactionsPage({
onEdit={handleEdit}
/>
<RefundTransactionDialog
open={refundOpen && !!transactionToRefund}
onOpenChange={(open) => {
setRefundOpen(open);
if (!open) {
setTransactionToRefund(null);
}
}}
transaction={transactionToRefund}
cardOptions={cardOptions}
/>
<ConfirmActionDialog
open={deleteOpen && !!transactionToDelete}
onOpenChange={setDeleteOpen}

View File

@@ -12,6 +12,7 @@ import {
RiHistoryLine,
RiMoreFill,
RiPencilLine,
RiRefund2Line,
RiTimeLine,
} from "@remixicon/react";
import type { ColumnDef } from "@tanstack/react-table";
@@ -45,6 +46,7 @@ import {
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";
@@ -60,6 +62,7 @@ export type BuildColumnsArgs = {
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;
@@ -121,6 +124,7 @@ function buildColumns({
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
@@ -133,6 +137,7 @@ function buildColumns({
const handleImport = onImport ?? noop;
const handleConfirmDelete = onConfirmDelete ?? noop;
const handleViewDetails = onViewDetails ?? noop;
const handleRefund = onRefund ?? noop;
const handleToggleSettlement = onToggleSettlement ?? noop;
const handleAnticipate = onAnticipate ?? noop;
const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop;
@@ -682,6 +687,25 @@ function buildColumns({
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)}>
<RiRefund2Line className="size-4" />
Reembolso
</DropdownMenuItem>
);
})()}
{row.original.userId === currentUserId && (
<DropdownMenuItem
variant="destructive"

View File

@@ -70,6 +70,7 @@ type LancamentosTableProps = {
onBulkDelete?: (items: TransactionItem[]) => void;
onBulkImport?: (items: TransactionItem[]) => void;
onViewDetails?: (item: TransactionItem) => void;
onRefund?: (item: TransactionItem) => void;
onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
@@ -98,6 +99,7 @@ export function TransactionsTable({
onBulkDelete,
onBulkImport,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
@@ -131,6 +133,7 @@ export function TransactionsTable({
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
@@ -147,6 +150,7 @@ export function TransactionsTable({
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,

View File

@@ -229,8 +229,8 @@ export function TransactionsExport({
const doc = new jsPDF({ orientation: "landscape" });
const primaryColor = getPrimaryPdfColor();
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
loadExportLogoDataUrl("/images/logo_small.png"),
loadExportLogoDataUrl("/images/logo_text.png"),
loadExportLogoDataUrl("/images/logo_small.svg"),
loadExportLogoDataUrl("/images/logo_text.svg"),
]);
let brandingEndX = 14;

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