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>
This commit is contained in:
Felipe Coutinho
2026-05-02 22:08:53 +00:00
parent d55173e8c1
commit 94bf93194f
40 changed files with 4699 additions and 477 deletions

View File

@@ -5,7 +5,44 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [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 ## [2.4.4] - 2026-04-27
@@ -97,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: 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) - 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 - 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) - 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 - 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 - 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

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.4.4", "version": "2.5.0",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
"scripts": { "scripts": {
@@ -31,16 +31,16 @@
"mockup": "tsx scripts/mock-data.ts" "mockup": "tsx scripts/mock-data.ts"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.71", "@ai-sdk/anthropic": "^3.0.74",
"@ai-sdk/google": "^3.0.64", "@ai-sdk/google": "^3.0.67",
"@ai-sdk/openai": "^3.0.53", "@ai-sdk/openai": "^3.0.57",
"@aws-sdk/client-s3": "^3.1037.0", "@aws-sdk/client-s3": "^3.1040.0",
"@aws-sdk/s3-request-presigner": "^3.1037.0", "@aws-sdk/s3-request-presigner": "^3.1040.0",
"@better-auth/passkey": "^1.6.9", "@better-auth/passkey": "^1.6.9",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@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-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-checkbox": "1.3.3",
@@ -63,10 +63,10 @@
"@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.100.3", "@tanstack/react-query": "^5.100.7",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.24",
"ai": "^6.0.168", "ai": "^6.0.173",
"better-auth": "1.6.9", "better-auth": "1.6.9",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
@@ -79,7 +79,7 @@
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"next": "16.2.4", "next": "16.2.4",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205", "pdfjs-dist": "^5.7.284",
"pg": "8.20.0", "pg": "8.20.0",
"react": "19.2.5", "react": "19.2.5",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
@@ -89,7 +89,7 @@
"sonner": "2.0.7", "sonner": "2.0.7",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"zod": "4.3.6" "zod": "4.4.1"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
@@ -106,7 +106,7 @@
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"knip": "^6.7.0", "knip": "^6.10.0",
"tailwindcss": "4.2.4", "tailwindcss": "4.2.4",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "6.0.3" "typescript": "6.0.3"

823
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

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

@@ -16,7 +16,7 @@ export function CalendarGrid({
onCreateDay, onCreateDay,
}: CalendarGridProps) { }: CalendarGridProps) {
return ( 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"> <div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{WEEK_DAYS_SHORT.map((dayName) => ( {WEEK_DAYS_SHORT.map((dayName) => (
<span key={dayName} className="text-center"> <span key={dayName} className="text-center">

View File

@@ -129,7 +129,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
onClick={() => onSelect(day)} onClick={() => onSelect(day)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={cn( 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.isCurrentMonth && "bg-muted/20 opacity-60",
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary", day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
)} )}

View File

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

View File

@@ -330,7 +330,7 @@ export function DashboardGridEditable({
> >
<div className="relative"> <div className="relative">
{isEditing && ( {isEditing && (
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[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"> <div className="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" /> <RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-medium"> <span className="text-xs font-medium">

View File

@@ -41,6 +41,7 @@ const CARDS = [
"Consideramos lançamentos efetivados e não efetivados da pessoa principal (admin).", "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.", "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.", "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.", "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).", "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.", "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.", "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.", "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", helpTitle: "Como calculamos o balanço",
helpLines: [ helpLines: [
"Partimos de receitas menos despesas do período.", "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.", "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.", "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.", "Se a transferência entra em conta considerada, soma. Se sai de conta considerada para conta não considerada, subtrai.",

View File

@@ -21,9 +21,11 @@ import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/tr
import { import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
isRefundNote,
} from "@/shared/lib/accounts/constants"; } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { TRANSFER_CATEGORY_NAME } from "@/shared/lib/transfers/constants";
import { import {
compareDateOnly, compareDateOnly,
getBusinessDateString, getBusinessDateString,
@@ -58,6 +60,7 @@ type CurrentPeriodTransactionRow = {
categoryId: string | null; categoryId: string | null;
categoryName: string | null; categoryName: string | null;
categoryType: string | null; categoryType: string | null;
accountId: string | null;
cardLogo: string | null; cardLogo: string | null;
accountLogo: string | null; accountLogo: string | null;
accountExcludeInitialBalanceFromIncome: boolean | null; accountExcludeInitialBalanceFromIncome: boolean | null;
@@ -119,6 +122,9 @@ const shouldIncludeWithoutAutoInvoice = (note: string | null | undefined) =>
const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) => const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) =>
!isInitialBalanceNote(note) && !isAutoInvoiceNote(note); !isInitialBalanceNote(note) && !isAutoInvoiceNote(note);
const shouldIncludeWithoutRefund = (note: string | null | undefined) =>
!isRefundNote(note);
const shouldIncludeInPaymentStatus = (row: CurrentPeriodTransactionRow) => { const shouldIncludeInPaymentStatus = (row: CurrentPeriodTransactionRow) => {
if (!shouldIncludeWithoutAutoInvoice(row.note)) { if (!shouldIncludeWithoutAutoInvoice(row.note)) {
return false; return false;
@@ -183,6 +189,7 @@ const buildBillsSnapshot = (
? row.boletoPaymentDate.toISOString().slice(0, 10) ? row.boletoPaymentDate.toISOString().slice(0, 10)
: null, : null,
isSettled: Boolean(row.isSettled), isSettled: Boolean(row.isSettled),
accountId: row.accountId ?? null,
})) }))
.sort((a, b) => { .sort((a, b) => {
if (a.isSettled !== b.isSettled) { if (a.isSettled !== b.isSettled) {
@@ -259,6 +266,14 @@ const buildPaymentStatusData = (
} }
const amount = toNumber(row.amount); 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 = const target =
row.transactionType === TRANSACTION_TYPE_INCOME row.transactionType === TRANSACTION_TYPE_INCOME
? result.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.income.total = result.income.confirmed + result.income.pending;
result.expenses.total = result.expenses.confirmed + result.expenses.pending; result.expenses.total = result.expenses.confirmed + result.expenses.pending;
@@ -495,7 +512,9 @@ const buildPurchasesByCategoryData = (
!row.categoryName || !row.categoryName ||
!row.categoryType || !row.categoryType ||
!["despesa", "receita"].includes(row.categoryType) || !["despesa", "receita"].includes(row.categoryType) ||
row.categoryName === TRANSFER_CATEGORY_NAME ||
!shouldIncludeWithoutAutoGenerated(row.note) || !shouldIncludeWithoutAutoGenerated(row.note) ||
!shouldIncludeWithoutRefund(row.note) ||
!shouldIncludeNamedItem(row.name) !shouldIncludeNamedItem(row.name)
) { ) {
continue; continue;
@@ -564,6 +583,7 @@ export async function fetchDashboardCurrentPeriodOverview(
categoryId: transactions.categoryId, categoryId: transactions.categoryId,
categoryName: categories.name, categoryName: categories.name,
categoryType: categories.type, categoryType: categories.type,
accountId: transactions.accountId,
cardLogo: cards.logo, cardLogo: cards.logo,
accountLogo: financialAccounts.logo, accountLogo: financialAccounts.logo,
accountExcludeInitialBalanceFromIncome: 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 { financialAccounts, transactions } from "@/db/schema";
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries"; import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
import type { import type {
@@ -11,6 +11,7 @@ import {
excludeInitialBalanceWhenConfigured, excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts, excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters"; } from "@/features/dashboard/transaction-filters";
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber } from "@/shared/utils/number"; import { safeToNumber } from "@/shared/utils/number";
@@ -31,6 +32,7 @@ const TRANSACTION_TYPE_TRANSFER = "Transferência";
type PeriodTotals = { type PeriodTotals = {
receitas: number; receitas: number;
despesas: number; despesas: number;
reembolsos: number;
transferAdjustment: number; transferAdjustment: number;
balanco: number; balanco: number;
}; };
@@ -39,6 +41,7 @@ type PeriodSummaryRow = {
period: string | null; period: string | null;
transactionType: string; transactionType: string;
totalAmount: string | number | null; totalAmount: string | number | null;
refundAmount: string | number | null;
accountExcludeFromBalance: boolean | null; accountExcludeFromBalance: boolean | null;
}; };
@@ -50,6 +53,7 @@ type DashboardPeriodOverview = {
const createEmptyTotals = (): PeriodTotals => ({ const createEmptyTotals = (): PeriodTotals => ({
receitas: 0, receitas: 0,
despesas: 0, despesas: 0,
reembolsos: 0,
transferAdjustment: 0, transferAdjustment: 0,
balanco: 0, balanco: 0,
}); });
@@ -105,11 +109,17 @@ export async function fetchDashboardPeriodOverview(
const chartPeriods = generateLast6Months(period); const chartPeriods = generateLast6Months(period);
const startPeriod = addMonthsToPeriod(period, -24); const startPeriod = addMonthsToPeriod(period, -24);
const refundPattern = `${REFUND_NOTE_PREFIX}%`;
const rows = (await db const rows = (await db
.select({ .select({
period: transactions.period, period: transactions.period,
transactionType: transactions.transactionType, 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, accountExcludeFromBalance: financialAccounts.excludeFromBalance,
}) })
.from(transactions) .from(transactions)
@@ -151,6 +161,9 @@ export async function fetchDashboardPeriodOverview(
const totals = ensurePeriodTotals(periodTotals, row.period); const totals = ensurePeriodTotals(periodTotals, row.period);
const total = safeToNumber(row.totalAmount); const total = safeToNumber(row.totalAmount);
const refund = safeToNumber(row.refundAmount);
totals.reembolsos += Math.abs(refund);
if (row.transactionType === TRANSACTION_TYPE_INCOME) { if (row.transactionType === TRANSACTION_TYPE_INCOME) {
totals.receitas += total; totals.receitas += total;
@@ -179,9 +192,14 @@ export async function fetchDashboardPeriodOverview(
for (const key of periodRange) { for (const key of periodRange) {
const totals = ensurePeriodTotals(periodTotals, key); const totals = ensurePeriodTotals(periodTotals, key);
const netExpenses = Math.max(0, totals.despesas - totals.reembolsos);
totals.balanco = totals.balanco =
totals.receitas - totals.despesas + totals.transferAdjustment; totals.receitas -
totals.despesas +
totals.reembolsos +
totals.transferAdjustment;
runningForecast += totals.balanco; runningForecast += totals.balanco;
totals.despesas = netExpenses;
forecastByPeriod.set(key, runningForecast); forecastByPeriod.set(key, runningForecast);
} }

View File

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

View File

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

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

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

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

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

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

View File

@@ -12,6 +12,7 @@ import {
RiHistoryLine, RiHistoryLine,
RiMoreFill, RiMoreFill,
RiPencilLine, RiPencilLine,
RiRefund2Line,
RiTimeLine, RiTimeLine,
} from "@remixicon/react"; } from "@remixicon/react";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
@@ -45,6 +46,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/shared/components/ui/tooltip"; } from "@/shared/components/ui/tooltip";
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { resolveLogoSrc } from "@/shared/lib/logo"; import { resolveLogoSrc } from "@/shared/lib/logo";
import { getAvatarSrc } from "@/shared/lib/payers/utils"; import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { formatDate } from "@/shared/utils/date"; import { formatDate } from "@/shared/utils/date";
@@ -60,6 +62,7 @@ export type BuildColumnsArgs = {
onImport?: (item: TransactionItem) => void; onImport?: (item: TransactionItem) => void;
onConfirmDelete?: (item: TransactionItem) => void; onConfirmDelete?: (item: TransactionItem) => void;
onViewDetails?: (item: TransactionItem) => void; onViewDetails?: (item: TransactionItem) => void;
onRefund?: (item: TransactionItem) => void;
onToggleSettlement?: (item: TransactionItem) => void; onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void; onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void; onViewAnticipationHistory?: (item: TransactionItem) => void;
@@ -121,6 +124,7 @@ function buildColumns({
onImport, onImport,
onConfirmDelete, onConfirmDelete,
onViewDetails, onViewDetails,
onRefund,
onToggleSettlement, onToggleSettlement,
onAnticipate, onAnticipate,
onViewAnticipationHistory, onViewAnticipationHistory,
@@ -133,6 +137,7 @@ function buildColumns({
const handleImport = onImport ?? noop; const handleImport = onImport ?? noop;
const handleConfirmDelete = onConfirmDelete ?? noop; const handleConfirmDelete = onConfirmDelete ?? noop;
const handleViewDetails = onViewDetails ?? noop; const handleViewDetails = onViewDetails ?? noop;
const handleRefund = onRefund ?? noop;
const handleToggleSettlement = onToggleSettlement ?? noop; const handleToggleSettlement = onToggleSettlement ?? noop;
const handleAnticipate = onAnticipate ?? noop; const handleAnticipate = onAnticipate ?? noop;
const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop; const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop;
@@ -682,6 +687,25 @@ function buildColumns({
Importar para Minha Conta Importar para Minha Conta
</DropdownMenuItem> </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 && ( {row.original.userId === currentUserId && (
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"

View File

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

View File

@@ -88,9 +88,9 @@ export const AnimatedThemeToggler = ({
data-state={isDark ? "dark" : "light"} data-state={isDark ? "dark" : "light"}
className={cn( className={cn(
buttonVariants({ variant, size: "icon-sm" }), buttonVariants({ variant, size: "icon-sm" }),
"group relative transition-all duration-200", "group relative transition-all duration-200 h-9",
variant === "ghost" && variant === "ghost" &&
"text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 data-[state=open]:bg-accent/60 data-[state=open]:text-foreground", "h-9 text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 data-[state=open]:bg-accent/60 data-[state=open]:text-foreground",
className, className,
)} )}
{...props} {...props}

View File

@@ -34,7 +34,7 @@ export function CalculatorDisplay({
<div className="mt-auto flex items-end justify-end gap-2"> <div className="mt-auto flex items-end justify-end gap-2">
<div <div
className={cn( className={cn(
"truncate text-right font-medium tracking-tight leading-none transition-all", "truncate text-right font-semibold transition-all",
isResultView ? "text-2xl" : "text-3xl", isResultView ? "text-2xl" : "text-3xl",
)} )}
> >

View File

@@ -32,11 +32,11 @@ export function CalculatorKeypad({
variant={isActive ? "default" : (btn.variant ?? "outline")} variant={isActive ? "default" : (btn.variant ?? "outline")}
onClick={btn.onClick} onClick={btn.onClick}
className={cn( className={cn(
"h-12 text-base font-medium", "h-14 text-lg font-medium",
btn.colSpan === 2 && "col-span-2", btn.colSpan === 2 && "col-span-2",
btn.colSpan === 3 && "col-span-3", btn.colSpan === 3 && "col-span-3",
isActive && isActive &&
"bg-primary text-primary-foreground hover:bg-primary/90 ring-2 ring-primary/30", "bg-primary text-primary-foreground hover:bg-primary/90",
btn.className, btn.className,
)} )}
> >

View File

@@ -49,14 +49,14 @@ export function LogoPickerTrigger({
className, className,
)} )}
> >
<span className="relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/40 bg-background shadow-xs"> <span className="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/40 bg-background shadow-xs">
{selectedLogoPath ? ( {selectedLogoPath ? (
<Image <Image
src={selectedLogoPath} src={selectedLogoPath}
alt={selectedLogoLabel || "Logo selecionado"} alt={selectedLogoLabel || "Logo selecionado"}
fill fill
sizes="32px" sizes="32px"
className="object-contain p-0.5" className="object-contain"
/> />
) : ( ) : (
<span className="text-xs text-muted-foreground">Logo</span> <span className="text-xs text-muted-foreground">Logo</span>

View File

@@ -47,7 +47,7 @@ export default function MonthNavigation() {
<div className="flex items-center"> <div className="flex items-center">
<div <div
className="mx-1 space-x-1 capitalize font-medium" className="mx-1 space-x-1 capitalize font-semibold"
aria-current={!isDifferentFromCurrent ? "date" : undefined} aria-current={!isDifferentFromCurrent ? "date" : undefined}
aria-label={`Período selecionado: ${currentMonthLabel}`} aria-label={`Período selecionado: ${currentMonthLabel}`}
> >

View File

@@ -29,10 +29,10 @@ import { NavPill } from "./nav-pill";
import { MobileTools, NavToolsDropdown } from "./nav-tools"; import { MobileTools, NavToolsDropdown } from "./nav-tools";
const triggerClass = const triggerClass =
"h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! capitalize! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10! dark:text-white/75! dark:hover:text-white! dark:hover:bg-white/10! dark:focus:text-white! dark:focus:bg-white/10! dark:focus-visible:ring-white/20! dark:data-[state=open]:text-white! dark:data-[state=open]:bg-white/10!"; "h-9! px-2! py-0! bg-transparent! capitalize! [&_svg]:text-current! text-primary-foreground/75! hover:text-primary-foreground! hover:bg-primary-foreground/10! focus:text-primary-foreground! focus:bg-primary-foreground/10! focus-visible:ring-primary-foreground/20! data-[state=open]:text-primary-foreground! data-[state=open]:bg-primary-foreground/10! dark:text-foreground/75! dark:hover:text-foreground! dark:hover:bg-foreground/10! dark:focus:text-foreground! dark:focus:bg-foreground/10! dark:focus-visible:ring-foreground/20! dark:data-[state=open]:text-foreground! dark:data-[state=open]:bg-foreground/10!";
const triggerActiveClass = const triggerActiveClass =
"bg-black/15! text-black! dark:bg-white/15! dark:text-white!"; "bg-primary-foreground/15! text-primary-foreground! dark:bg-foreground/15! dark:text-foreground!";
export function NavMenu() { export function NavMenu() {
const pathname = usePathname(); const pathname = usePathname();

View File

@@ -25,8 +25,9 @@ export function NavPill({ href, preservePeriod, children }: NavPillProps) {
preservePeriod={preservePeriod} preservePeriod={preservePeriod}
className={cn( className={cn(
buttonVariants({ variant: "navbar", size: "sm" }), buttonVariants({ variant: "navbar", size: "sm" }),
"capitalize", "h-9 capitalize text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground focus-visible:ring-primary-foreground/20 dark:text-foreground/75 dark:hover:bg-foreground/10 dark:hover:text-foreground dark:focus-visible:ring-foreground/20",
isActive && "bg-black/15 text-black dark:bg-white/15 dark:text-white", isActive &&
"bg-primary-foreground/15 text-primary-foreground dark:bg-foreground/15 dark:text-foreground",
)} )}
> >
{children} {children}

View File

@@ -16,7 +16,7 @@ export function NavbarShell({
return ( return (
<header <header
className={`${positionClass} z-50 flex h-16 shrink-0 items-center bg-primary border-b dark:bg-card dark:border-b-border/60`} className={`${positionClass} z-50 flex h-16 shrink-0 items-center bg-primary border-b border-b-primary dark:bg-card dark:border-b-border`}
> >
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4"> <div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4">
{logoHref ? ( {logoHref ? (

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-4 border border-border/70 dark:border-border/40 py-6 rounded-lg hover:border-primary/60 transition-colors duration-200", "bg-card text-card-foreground flex flex-col gap-4 border border-transparent shadow-sm dark:border-border py-6 rounded-lg hover:border-primary/50 transition-colors duration-200",
className, className,
)} )}
{...props} {...props}

View File

@@ -111,7 +111,7 @@ function NavigationMenuViewport({
<NavigationMenuPrimitive.Viewport <NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport" data-slot="navigation-menu-viewport"
className={cn( className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]", "origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded-md border shadow md:w-(--radix-navigation-menu-viewport-width)",
className, className,
)} )}
{...props} {...props}
@@ -144,7 +144,7 @@ function NavigationMenuIndicator({
<NavigationMenuPrimitive.Indicator <NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator" data-slot="navigation-menu-indicator"
className={cn( className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden", "data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden",
className, className,
)} )}
{...props} {...props}

View File

@@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
<tr <tr
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b border-border/50 transition-colors",
className, className,
)} )}
{...props} {...props}

View File

@@ -243,8 +243,8 @@ export function useCalculatorState() {
const buttons: CalculatorButtonConfig[][] = [ const buttons: CalculatorButtonConfig[][] = [
[ [
{ label: "C", onClick: reset, variant: "destructive" }, { label: "C", onClick: reset, variant: "destructive" },
{ label: "⌫", onClick: deleteLastDigit, variant: "secondary" }, { label: "⌫", onClick: deleteLastDigit },
{ label: "%", onClick: applyPercent, variant: "secondary" }, { label: "%", onClick: applyPercent },
{ {
label: "÷", label: "÷",
onClick: makeOperatorHandler("divide"), onClick: makeOperatorHandler("divide"),
@@ -278,7 +278,7 @@ export function useCalculatorState() {
{ label: "+", onClick: makeOperatorHandler("add"), variant: "outline" }, { label: "+", onClick: makeOperatorHandler("add"), variant: "outline" },
], ],
[ [
{ label: "±", onClick: toggleSign, variant: "secondary" }, { label: "±", onClick: toggleSign },
{ label: "0", onClick: () => inputDigit("0") }, { label: "0", onClick: () => inputDigit("0") },
{ label: ",", onClick: inputDecimal }, { label: ",", onClick: inputDecimal },
{ label: "=", onClick: evaluate, variant: "default" }, { label: "=", onClick: evaluate, variant: "default" },

View File

@@ -1,4 +1,3 @@
import "server-only";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
export const generateShareCode = (): string => { export const generateShareCode = (): string => {