mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
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:
41
CHANGELOG.md
41
CHANGELOG.md
@@ -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
389
DESIGN.md
Normal 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:**
|
||||||
|
- **4–8px:** Tight spacing within compact components (icon-text pairs, inline elements)
|
||||||
|
- **12–16px:** Standard padding inside cards, inputs, and buttons
|
||||||
|
- **24–32px:** Section gaps, spacing between components on a page
|
||||||
|
- **48–64px:** Large section separations, hero spacing
|
||||||
|
- **80–128px:** 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.06–0.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 (`24px–48px`) around sections and inside cards for breathing room
|
||||||
|
- Stack elements vertically with `24–32px` 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 | `375px–599px` | Single column; container padding `16px`; font sizes reduce 1–2 sizes; gap scale halved |
|
||||||
|
| Tablet | `600px–1023px` | 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 25–33% 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
|
||||||
3
drizzle/0029_friendly_spitfire.sql
Normal file
3
drizzle/0029_friendly_spitfire.sql
Normal 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;
|
||||||
3085
drizzle/meta/0029_snapshot.json
Normal file
3085
drizzle/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -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
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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
135
src/features/accounts/components/adjust-balance-dialog.tsx
Normal file
135
src/features/accounts/components/adjust-balance-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
127
src/features/invoices/components/adjust-invoice-dialog.tsx
Normal file
127
src/features/invoices/components/adjust-invoice-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
178
src/features/transactions/actions/refund-action.ts
Normal file
178
src/features/transactions/actions/refund-action.ts
Normal 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.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user