Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94bf93194f | ||
|
|
d55173e8c1 | ||
|
|
4a73088c09 | ||
|
|
eaa20448a8 | ||
|
|
367d78d43d | ||
|
|
2fc6d11d78 | ||
|
|
0f5c735be0 | ||
|
|
4bea6330bf | ||
|
|
8389752172 | ||
|
|
19b5aa00ee | ||
|
|
863ccc0fd2 | ||
|
|
29d99cbedb | ||
|
|
dbeb98bbe4 | ||
|
|
c0436dc2ac | ||
|
|
e1e76fadc0 | ||
|
|
9b2c15ef7d | ||
|
|
fbe3fceb9f | ||
|
|
39f3cd8b20 | ||
|
|
791fec7751 | ||
|
|
114e2b4011 | ||
|
|
f15a003cef | ||
|
|
7f07a9cbf6 | ||
|
|
5fa234884e | ||
|
|
b453b432ed | ||
|
|
7f05d2a681 | ||
|
|
b14f487824 | ||
|
|
5b03824a72 | ||
|
|
74dda549f5 | ||
|
|
137b63f256 |
1
.gitignore
vendored
@@ -106,6 +106,7 @@ docker-compose.override.yml
|
|||||||
.cursor/
|
.cursor/
|
||||||
QWEN.md
|
QWEN.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
.codex
|
||||||
# === Backups locais ===
|
# === Backups locais ===
|
||||||
/backup/
|
/backup/
|
||||||
|
|
||||||
|
|||||||
111
CHANGELOG.md
@@ -5,7 +5,114 @@ 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
|
||||||
|
|
||||||
|
Esta versão remove a dependência da extensão `pgcrypto` do PostgreSQL para a geração do `share_code` em pagadores. O default a nível de banco (`gen_random_bytes`) foi removido — agora a aplicação gera o código sempre via `crypto.randomBytes` do Node.js, num utilitário compartilhado. A consequência prática é que o setup inicial fica mais simples: não há mais script de habilitação de extensão, nem etapa extra no primeiro `db:push`, e bancos restaurados de dumps externos não precisam ter `pgcrypto` instalada. O script de backup também foi enxugado para gerar dumps focados nos schemas relevantes (`public` e `drizzle`), descartando os schemas internos do Supabase e eliminando os ~148 erros de restore em PostgreSQL padrão. Por fim, os logos da marca (ícone laranja e wordmark) foram vetorizados: as PNGs antigas foram substituídas por SVGs inline em componentes próprios e por arquivos `.svg` no `public/`, escalando perfeitamente em qualquer tamanho — inclusive nos PDFs exportados, que agora rasterizam o SVG em alta resolução.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Schema: coluna `share_code` em `pagadores` perdeu o default `substr(encode(gen_random_bytes(24), 'base64'), 1, 24)` — campo continua `NOT NULL` e a aplicação passa a fornecer o valor explicitamente em todas as inserções
|
||||||
|
- Pagadores: nova função utilitária `generateShareCode()` em `src/shared/lib/payers/share-code.ts` (server-only) — usa `crypto.randomBytes(18).toString("base64url").slice(0, 24)`
|
||||||
|
- Pagadores: `createPayerAction`, `ensureDefaultPagadorForUser`, `resetUserAppData` (settings) e `mock-data.ts` agora chamam `generateShareCode()` ao inserir um pagador
|
||||||
|
- Backup: `scripts/backup.sh` agora dumpa apenas os schemas `public` e `drizzle` — schemas internos do Supabase (`auth`, `realtime`, `storage`, `vault`, `graphql`, `graphql_public`, `extensions`, `pgbouncer`) e suas extensions/roles deixam de poluir os dumps. Restaurações em PostgreSQL padrão passam a executar sem os ~148 erros de `role/extension does not exist`
|
||||||
|
- Logo: `Logo` foi quebrado em três arquivos — `src/shared/components/logo.tsx` (orquestrador), `logo-icon.tsx` (ícone laranja em SVG inline, viewBox `0 0 200 200`) e `logo-text.tsx` (wordmark em SVG inline, viewBox `0 0 574.201 89.6`). API pública (`variant`, `invertTextOnDark`, `colorIcon`, `iconClassName`, `textClassName`) preservada
|
||||||
|
- Assets: `public/images/logo_small.png` e `logo_text.png` substituídos por `logo_small.svg` e `logo_text.svg` (com `width`/`height` explícitos para compatibilidade com `<img>` em canvas)
|
||||||
|
- Exports: `loadExportLogoDataUrl` agora carrega SVG e rasteriza no canvas a 4× a resolução natural antes de gerar o data URL — mantém nitidez quando o PDF amplia a imagem
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- Pasta `scripts/postgres/` (continha `init.sql` e `enable-extensions.ts`)
|
||||||
|
- Script `pnpm db:extensions` no `package.json`
|
||||||
|
- Referências ao `pnpm db:extensions` no README
|
||||||
|
- `public/images/logo_small.png` e `public/images/logo_text.png` (substituídos pelos `.svg`)
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Migrations: conflito de numeração resolvido — `0027_fancy_reaper` renomeado para `0028_fancy_reaper` (o número 0027 já estava ocupado pelo arquivo órfão `0027_glorious_mindworm`); journal e snapshot atualizados
|
||||||
|
- TS: removido `baseUrl` do `tsconfig.json` para evitar erro `TS5101` (deprecação no TS 7) — `moduleResolution: bundler` resolve os `paths` relativos ao próprio `tsconfig`, dispensando `baseUrl`
|
||||||
|
|
||||||
|
### Documentação
|
||||||
|
|
||||||
|
- README: seção Backup atualizada — arquivos gerados agora especificam que apenas os schemas `public` e `drizzle` são dumpados
|
||||||
|
- README: seção Restore reescrita com o fluxo correto para banco Docker (`DROP SCHEMA public CASCADE` + `pg_restore --clean --if-exists --disable-triggers`)
|
||||||
|
- README: comando rápido de Docker Compose de backup/restore substituído por `pnpm backup`
|
||||||
|
- README: header passa a apontar para `logo_small.svg`
|
||||||
|
|
||||||
|
## [2.4.3] - 2026-04-25
|
||||||
|
|
||||||
|
Esta versão amplia o trabalho com lançamentos divididos: anexos passam a ser visíveis para pessoas com acesso compartilhado, a importação para conta própria copia os arquivos de forma independente e a edição ganha a opção de aplicar a alteração nos dois lados do par. Três caminhos de deleção foram corrigidos para não deixar arquivos órfãos no storage. Também traz refresh visual nos badges de tipo e radio buttons, prefetch server-side de logos para reduzir chamadas de API no dashboard, e ajustes pontuais no healthcheck do container e em rótulos da UI.
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Schema: coluna `split_group_id` (uuid, nullable) em `lancamentos` com índice `(user_id, split_group_id)` — liga as shares do mesmo evento de divisão
|
||||||
|
- Split: `buildLancamentoRecords` atribui um `splitGroupId` único por cycle (parcelado, recorrente ou único) para ambas as shares
|
||||||
|
- Split: edição cooperativa via `updateTransactionSplitPairAction` — ao editar um lançamento dividido, novo dialog `SplitPairDialog` permite escolher entre aplicar somente neste lado ou nos dois lados (nome, data, categoria e demais campos compartilhados; valor e payer permanecem por share)
|
||||||
|
- Importação: "Importar para Minha Conta" agora copia os anexos do lançamento-fonte para a conta de quem está importando (novo arquivo, novo `userId`, novo `fileKey` — cópia independente via S3 CopyObject). `createSchema` ganhou campo opcional `importFromTransactionId`; helper `copyAttachmentsForImport` valida acesso à fonte via ownership direto ou `payerShares`
|
||||||
|
- Importação: dialog "Importar para Minha Conta" exibe seção read-only "Anexos que serão copiados" listando os anexos do lançamento-fonte antes da confirmação
|
||||||
|
- Filtros: nova chave `isDivided` na tabela de lançamentos — toggle "Somente divididos" no drawer de filtros mantém o estado na URL
|
||||||
|
- Performance: prefetch server-side de mapeamentos Logo.dev no `/dashboard`, `/transactions` e `/payers/[payerId]` — uma única query SQL em batch (`fetchEstablishmentLogoMap`) semeia o cache do React Query antes do primeiro render, eliminando os N requests para `/api/logo/mapping`
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Anexos: `fetchTransactionAttachments` e `fetchTransactionAttachmentsAction` passam a autorizar leitura por acesso à transação (direto ou via `payerShares`), permitindo que pessoas com pagador compartilhado visualizem anexos de lançamentos divididos
|
||||||
|
- Anexos: upload (`confirmAttachmentUploadAction`) e detach em massa (`detachAttachmentBulkAction`) agora expandem `transactionIds` para incluir shares irmãs via `splitGroupId` — o vínculo em `transaction_attachments` é replicado para manter simetria
|
||||||
|
- Anexos: delete/detach continuam restritos ao criador (sem alteração de escrita); dashboard (`fetchAttachmentsForPeriod`) permanece listando apenas os anexos do próprio usuário
|
||||||
|
- Migração: lançamentos divididos criados antes desta versão ficam com `split_group_id` NULL e mantêm o comportamento antigo (anexos não visíveis para a contraparte); apenas splits novos são afetados
|
||||||
|
- Storage: `deleteS3Object` passa a ignorar `NoSuchKey` silenciosamente — providers S3-compatíveis (ex.: Cloudflare R2) lançam esse erro ao deletar objeto inexistente, ao contrário do comportamento idempotente do S3 padrão
|
||||||
|
- UI/Badges: `TransactionTypeBadge` redesenhado — substitui o `StatusDot` por ícones direcionais (`RiArrowRightDownLine` receita, `RiArrowRightUpLine` despesa, `RiArrowLeftRightLine` transferência), com borda visível, shadow sutil e variantes dark mode dessaturadas; rótulo "Transferência" abreviado para "Transf."
|
||||||
|
- UI/Forms: indicador do `RadioGroup` trocado de círculo (`RiCircleLine`) por check (`RiCheckLine`) com fundo sólido `primary` no estado selecionado
|
||||||
|
- UI/Antecipação: tabela de seleção de parcelas reduzida de quatro para três colunas (estabelecimento + fatura + valor) — informações de parcela e vencimento absorvidas pela coluna do estabelecimento
|
||||||
|
- Tipografia: fonte Inter agora carrega explicitamente os pesos 500, 600 e 700 (antes derivava de 400)
|
||||||
|
- Deps: better-auth 1.6.5 → 1.6.9, @aws-sdk/client-s3 3.1032 → 3.1037, @tanstack/react-query 5.99.2 → 5.100.3, @biomejs/biome 2.4.12 → 2.4.13, tailwindcss 4.2.2 → 4.2.4, resend 6.12.0 → 6.12.2
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Anexos: deleção em massa por série (`deleteTransactionBulkAction`) não chamava cleanup de storage — arquivos ficavam órfãos no S3 após apagar "este e futuros" ou "todos" de uma série parcelada/recorrente com anexo
|
||||||
|
- Anexos: deleção múltipla por seleção (`deleteMultipleTransactionsAction`) não chamava cleanup de storage — mesmo problema ao selecionar vários lançamentos com anexo e deletar em lote
|
||||||
|
- Anexos: reset de conta em Ajustes (`resetUserAppData`) não limpava o storage — todos os arquivos do usuário ficavam órfãos no S3 após a operação de zeragem
|
||||||
|
- Página da pessoa (`/payers/[payerId]`): `fetchPagadorLancamentos` agora calcula `hasAttachments` via `EXISTS`, fazendo o ícone de clipe aparecer na tabela de lançamentos (antes só aparecia em `/transactions`)
|
||||||
|
- Categorias: mensagem de sucesso ao atualizar exibia "Category atualizada com sucesso." — corrigido para "Categoria atualizada com sucesso."
|
||||||
|
- Antecipação: rótulos "Category" e "Período" no dialog corrigidos para "Categoria" e "Fatura"
|
||||||
|
- Docker: healthcheck do container `app` agora usa `127.0.0.1:3000` em vez de `localhost:3000`, evitando connection timeout em hosts com IPv6 (resolvendo [#44](https://github.com/felipegcoutinho/openmonetis/issues/44))
|
||||||
|
|
||||||
## [2.4.2] - 2026-04-20
|
## [2.4.2] - 2026-04-20
|
||||||
|
|
||||||
@@ -27,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
@@ -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
|
||||||
@@ -109,7 +109,7 @@ USER nextjs
|
|||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
CMD wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1
|
CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
|
||||||
|
|
||||||
# Entrypoint: roda migrations e depois executa o CMD
|
# Entrypoint: roda migrations e depois executa o CMD
|
||||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
|
|||||||
44
README.md
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./public/images/logo_small.png" alt="OpenMonetis Logo" height="80" />
|
<img src="./public/images/logo_small.svg" alt="OpenMonetis Logo" height="80" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
@@ -20,11 +20,7 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./public/images/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" />
|
<img src="./public/images/dashboard-preview-light.png" alt="Dashboard Preview" width="800" />
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="./public/images/preview-lancamentos-light.webp" alt="Lançamentos" width="800" />
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -196,13 +192,10 @@ cp .env.example .env
|
|||||||
# 4. Suba o banco
|
# 4. Suba o banco
|
||||||
pnpm docker:db
|
pnpm docker:db
|
||||||
|
|
||||||
# 5. Habilite extensões do PostgreSQL (apenas no primeiro setup)
|
# 5. Aplique o schema no banco (apenas no primeiro setup)
|
||||||
pnpm db:extensions
|
|
||||||
|
|
||||||
# 6. Aplique o schema no banco (apenas no primeiro setup)
|
|
||||||
pnpm db:push
|
pnpm db:push
|
||||||
|
|
||||||
# 7. Inicie o app com hot-reload
|
# 6. Inicie o app com hot-reload
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -240,7 +233,6 @@ pnpm lint:fix # Biome auto-fix
|
|||||||
pnpm db:generate # Gerar migrations
|
pnpm db:generate # Gerar migrations
|
||||||
pnpm db:migrate # Executar migrations
|
pnpm db:migrate # Executar migrations
|
||||||
pnpm db:push # Push schema direto (dev)
|
pnpm db:push # Push schema direto (dev)
|
||||||
pnpm db:extensions # Habilitar extensões PostgreSQL (rodar uma vez)
|
|
||||||
pnpm db:studio # Drizzle Studio (UI visual)
|
pnpm db:studio # Drizzle Studio (UI visual)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -291,8 +283,7 @@ docker compose up -d app
|
|||||||
docker compose exec app sh # Shell da aplicação
|
docker compose exec app sh # Shell da aplicação
|
||||||
docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco
|
docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco
|
||||||
docker compose ps # Status
|
docker compose ps # Status
|
||||||
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup
|
pnpm backup # Backup (ver seção Backup)
|
||||||
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Customizando portas
|
### Customizando portas
|
||||||
@@ -318,9 +309,9 @@ Cada execução gera **3 arquivos** em `backup/`:
|
|||||||
|
|
||||||
| Arquivo | Conteúdo | Uso |
|
| Arquivo | Conteúdo | Uso |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom do PostgreSQL (binário) | Restore completo via `pg_restore` |
|
| `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom dos schemas `public` + `drizzle` | Restore completo via `pg_restore` |
|
||||||
| `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL completo compactado | Inspeção manual, portabilidade |
|
| `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL compactado dos schemas `public` + `drizzle` | Inspeção manual, portabilidade |
|
||||||
| `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados da schema `public` | Migração parcial, seed de outro ambiente |
|
| `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados do schema `public` (sem DDL) | Migração parcial, seed de outro ambiente |
|
||||||
|
|
||||||
### Modos de conexão
|
### Modos de conexão
|
||||||
|
|
||||||
@@ -354,16 +345,19 @@ crontab -e
|
|||||||
### Restore
|
### Restore
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# A partir do .dump (recomendado — mais rápido)
|
# 1. Zerar o banco
|
||||||
pg_restore --clean --no-owner --no-privileges \
|
docker exec <container-db> psql -U openmonetis -d openmonetis_db \
|
||||||
-d "postgresql://user:senha@host:5432/openmonetis_db" \
|
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||||
backup/openmonetis_YYYY-MM-DD_HH-MM.dump
|
|
||||||
|
|
||||||
# A partir do .sql.gz (banco local via Docker)
|
# 2. Restaurar schema + dados (um comando)
|
||||||
gunzip -c backup/openmonetis_YYYY-MM-DD_HH-MM.sql.gz | \
|
docker exec -i <container-db> pg_restore \
|
||||||
docker compose exec -T db psql -U openmonetis -d openmonetis_db
|
-U openmonetis -d openmonetis_db \
|
||||||
|
--clean --if-exists --disable-triggers --no-owner --no-privileges \
|
||||||
|
< backup/openmonetis_YYYY-MM-DD_HH-MM.dump
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `--disable-triggers` é necessário para evitar erros de FK durante o restore (os dados são inseridos fora de ordem). O usuário `openmonetis` tem permissão para isso.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ☁️ Storage S3 Compatível
|
## ☁️ Storage S3 Compatível
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
required: false
|
required: false
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
echo "Habilitando extensão pgcrypto..."
|
|
||||||
node -e "
|
|
||||||
const { Client } = require('/app/migrate/node_modules/pg');
|
|
||||||
const c = new Client({ connectionString: process.env.DATABASE_URL });
|
|
||||||
c.connect()
|
|
||||||
.then(() => c.query('CREATE EXTENSION IF NOT EXISTS pgcrypto'))
|
|
||||||
.then(() => c.end())
|
|
||||||
.catch((e) => { console.error('Aviso pgcrypto:', e.message); process.exit(0); });
|
|
||||||
"
|
|
||||||
|
|
||||||
echo "Rodando migrations..."
|
echo "Rodando migrations..."
|
||||||
MIGRATED=0
|
MIGRATED=0
|
||||||
for i in 1 2 3 4 5; do
|
for i in 1 2 3 4 5; do
|
||||||
|
|||||||
2
drizzle/0026_bored_eternity.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "lancamentos" ADD COLUMN "split_group_id" uuid;--> statement-breakpoint
|
||||||
|
CREATE INDEX "lancamentos_user_id_split_group_id_idx" ON "lancamentos" USING btree ("user_id","split_group_id");
|
||||||
1
drizzle/0028_fancy_reaper.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "pagadores" ALTER COLUMN "share_code" DROP DEFAULT;
|
||||||
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;
|
||||||
2916
drizzle/meta/0026_snapshot.json
Normal file
2915
drizzle/meta/0028_snapshot.json
Normal file
3085
drizzle/meta/0029_snapshot.json
Normal file
@@ -1,188 +1,209 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1762993507299,
|
"when": 1762993507299,
|
||||||
"tag": "0000_flashy_manta",
|
"tag": "0000_flashy_manta",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1765199006435,
|
"when": 1765199006435,
|
||||||
"tag": "0001_young_mister_fear",
|
"tag": "0001_young_mister_fear",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1765200545692,
|
"when": 1765200545692,
|
||||||
"tag": "0002_slimy_flatman",
|
"tag": "0002_slimy_flatman",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767102605526,
|
"when": 1767102605526,
|
||||||
"tag": "0003_green_korg",
|
"tag": "0003_green_korg",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 4,
|
"idx": 4,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767104066872,
|
"when": 1767104066872,
|
||||||
"tag": "0004_acoustic_mach_iv",
|
"tag": "0004_acoustic_mach_iv",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 5,
|
"idx": 5,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767106121811,
|
"when": 1767106121811,
|
||||||
"tag": "0005_adorable_bruce_banner",
|
"tag": "0005_adorable_bruce_banner",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 6,
|
"idx": 6,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767107487318,
|
"when": 1767107487318,
|
||||||
"tag": "0006_youthful_mister_fear",
|
"tag": "0006_youthful_mister_fear",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 7,
|
"idx": 7,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767118780033,
|
"when": 1767118780033,
|
||||||
"tag": "0007_sturdy_kate_bishop",
|
"tag": "0007_sturdy_kate_bishop",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 8,
|
"idx": 8,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1767125796314,
|
"when": 1767125796314,
|
||||||
"tag": "0008_fat_stick",
|
"tag": "0008_fat_stick",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 9,
|
"idx": 9,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1768925100873,
|
"when": 1768925100873,
|
||||||
"tag": "0009_add_dashboard_widgets",
|
"tag": "0009_add_dashboard_widgets",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 10,
|
"idx": 10,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1769369834242,
|
"when": 1769369834242,
|
||||||
"tag": "0010_lame_psynapse",
|
"tag": "0010_lame_psynapse",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 11,
|
"idx": 11,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1769447087678,
|
"when": 1769447087678,
|
||||||
"tag": "0011_remove_unused_inbox_columns",
|
"tag": "0011_remove_unused_inbox_columns",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 12,
|
"idx": 12,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1769533200000,
|
"when": 1769533200000,
|
||||||
"tag": "0012_rename_tables_to_portuguese",
|
"tag": "0012_rename_tables_to_portuguese",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 13,
|
"idx": 13,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1769523352777,
|
"when": 1769523352777,
|
||||||
"tag": "0013_fancy_rick_jones",
|
"tag": "0013_fancy_rick_jones",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 14,
|
"idx": 14,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1769619226903,
|
"when": 1769619226903,
|
||||||
"tag": "0014_yielding_jack_flag",
|
"tag": "0014_yielding_jack_flag",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 15,
|
"idx": 15,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1770332054481,
|
"when": 1770332054481,
|
||||||
"tag": "0015_concerned_kat_farrell",
|
"tag": "0015_concerned_kat_farrell",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 16,
|
"idx": 16,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1771166328908,
|
"when": 1771166328908,
|
||||||
"tag": "0016_complete_randall",
|
"tag": "0016_complete_randall",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 17,
|
"idx": 17,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1772400510326,
|
"when": 1772400510326,
|
||||||
"tag": "0017_previous_warstar",
|
"tag": "0017_previous_warstar",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 18,
|
"idx": 18,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1773020417482,
|
"when": 1773020417482,
|
||||||
"tag": "0018_rainy_epoch",
|
"tag": "0018_rainy_epoch",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 19,
|
"idx": 19,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1773699152928,
|
"when": 1773699152928,
|
||||||
"tag": "0019_ordinary_wild_pack",
|
"tag": "0019_ordinary_wild_pack",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 20,
|
"idx": 20,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1773841892114,
|
"when": 1773841892114,
|
||||||
"tag": "0020_add-budget-invoice-unique-constraints",
|
"tag": "0020_add-budget-invoice-unique-constraints",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 21,
|
"idx": 21,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1774033320053,
|
"when": 1774033320053,
|
||||||
"tag": "0021_careful_malcolm_colcord",
|
"tag": "0021_careful_malcolm_colcord",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 22,
|
"idx": 22,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1748000000000,
|
"when": 1748000000000,
|
||||||
"tag": "0022_import-category-mappings",
|
"tag": "0022_import-category-mappings",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 23,
|
"idx": 23,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1774529878374,
|
"when": 1774529878374,
|
||||||
"tag": "0023_sturdy_wolfpack",
|
"tag": "0023_sturdy_wolfpack",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 24,
|
"idx": 24,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1774891206703,
|
"when": 1774891206703,
|
||||||
"tag": "0024_petite_lucky_pierre",
|
"tag": "0024_petite_lucky_pierre",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 25,
|
"idx": 25,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1776351838548,
|
"when": 1776351838548,
|
||||||
"tag": "0025_burly_colonel_america",
|
"tag": "0025_burly_colonel_america",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777042423451,
|
||||||
|
"tag": "0026_bored_eternity",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777153372633,
|
||||||
|
"tag": "0028_fancy_reaper",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777648189399,
|
||||||
|
"tag": "0029_friendly_spitfire",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ const nextConfig: NextConfig = {
|
|||||||
output: "standalone",
|
output: "standalone",
|
||||||
cacheComponents: true,
|
cacheComponents: true,
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
new URL("https://lh3.googleusercontent.com/**"),
|
new URL("https://lh3.googleusercontent.com/**"),
|
||||||
|
|||||||
37
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.4.2",
|
"version": "2.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
||||||
"docker:up": "docker compose up -d",
|
"docker:up": "docker compose up -d",
|
||||||
@@ -32,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.1032.0",
|
"@aws-sdk/client-s3": "^3.1040.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1032.0",
|
"@aws-sdk/s3-request-presigner": "^3.1040.0",
|
||||||
"@better-auth/passkey": "^1.6.5",
|
"@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",
|
||||||
@@ -64,11 +63,11 @@
|
|||||||
"@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.99.2",
|
"@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.5",
|
"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",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -80,17 +79,17 @@
|
|||||||
"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",
|
||||||
"react-dom": "19.2.5",
|
"react-dom": "19.2.5",
|
||||||
"recharts": "3.8.1",
|
"recharts": "3.8.1",
|
||||||
"resend": "^6.12.0",
|
"resend": "^6.12.2",
|
||||||
"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": {
|
||||||
@@ -98,8 +97,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.12",
|
"@biomejs/biome": "2.4.13",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.2.4",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "25.6.0",
|
"@types/node": "25.6.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
@@ -107,8 +106,8 @@
|
|||||||
"@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.4.1",
|
"knip": "^6.10.0",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.2.4",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
}
|
}
|
||||||
|
|||||||
1484
pnpm-lock.yaml
generated
@@ -5,5 +5,6 @@ export const inter = Inter({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
fallback: ["ui-sans-serif", "system-ui"],
|
fallback: ["ui-sans-serif", "system-ui"],
|
||||||
|
weight: ["500", "600", "700"],
|
||||||
preload: true,
|
preload: true,
|
||||||
});
|
});
|
||||||
|
|||||||
BIN
public/images/dashboard-preview-dark.png
Normal file
|
After Width: | Height: | Size: 571 KiB |
|
Before Width: | Height: | Size: 183 KiB |
BIN
public/images/dashboard-preview-light.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
3
public/images/logo_small.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" role="img" aria-label="OpenMonetis">
|
||||||
|
<path fill="#ff7733" d="M 77.66,165.64 L 37.77,141.54 L 63.30,108.72 L 27.13,97.44 L 46.81,50.77 L 77.66,63.08 L 81.91,30.26 L 126.40,29.23 L 126.06,33.85 L 122.87,67.69 L 158.51,50.77 L 178.19,90.26 L 140.96,104.62 L 162.23,127.18 L 132.98,162.56 L 103.19,131.79 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 402 B |
|
Before Width: | Height: | Size: 21 KiB |
3
public/images/logo_text.svg
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 176 KiB |
@@ -58,21 +58,26 @@ DATA_FILE="$BACKUP_DIR/openmonetis_${TIMESTAMP}.data.sql.gz"
|
|||||||
|
|
||||||
log "Iniciando backup (modo: $DB_MODE)..."
|
log "Iniciando backup (modo: $DB_MODE)..."
|
||||||
|
|
||||||
|
# Schemas relevantes do OpenMonetis (descarta lixo Supabase: auth, realtime, storage, vault, graphql, etc.)
|
||||||
|
SCHEMA_FLAGS=(--schema=public --schema=drizzle)
|
||||||
|
|
||||||
# --- Dump ---
|
# --- Dump ---
|
||||||
if [[ "$DB_MODE" == "remote" ]]; then
|
if [[ "$DB_MODE" == "remote" ]]; then
|
||||||
# --no-owner --no-privileges: necessário no Supabase (roles gerenciados internamente)
|
# --no-owner --no-privileges: necessário no Supabase (roles gerenciados internamente)
|
||||||
pg_dump --format=custom --no-owner --no-privileges \
|
pg_dump --format=custom --no-owner --no-privileges \
|
||||||
|
"${SCHEMA_FLAGS[@]}" \
|
||||||
"$REMOTE_DB_URL" > "$DUMP_FILE"
|
"$REMOTE_DB_URL" > "$DUMP_FILE"
|
||||||
|
|
||||||
pg_dump --no-owner --no-privileges \
|
pg_dump --no-owner --no-privileges \
|
||||||
|
"${SCHEMA_FLAGS[@]}" \
|
||||||
"$REMOTE_DB_URL" | gzip > "$SQL_FILE"
|
"$REMOTE_DB_URL" | gzip > "$SQL_FILE"
|
||||||
|
|
||||||
elif [[ "$DB_MODE" == "docker" ]]; then
|
elif [[ "$DB_MODE" == "docker" ]]; then
|
||||||
docker exec "$DOCKER_CONTAINER" pg_dump \
|
docker exec "$DOCKER_CONTAINER" pg_dump \
|
||||||
-U "$DOCKER_DB_USER" -Fc "$DOCKER_DB_NAME" > "$DUMP_FILE"
|
-U "$DOCKER_DB_USER" -Fc "${SCHEMA_FLAGS[@]}" "$DOCKER_DB_NAME" > "$DUMP_FILE"
|
||||||
|
|
||||||
docker exec "$DOCKER_CONTAINER" pg_dump \
|
docker exec "$DOCKER_CONTAINER" pg_dump \
|
||||||
-U "$DOCKER_DB_USER" "$DOCKER_DB_NAME" | gzip > "$SQL_FILE"
|
-U "$DOCKER_DB_USER" "${SCHEMA_FLAGS[@]}" "$DOCKER_DB_NAME" | gzip > "$SQL_FILE"
|
||||||
|
|
||||||
else
|
else
|
||||||
log "ERRO: DB_MODE inválido ('$DB_MODE'). Use 'remote' ou 'docker'."
|
log "ERRO: DB_MODE inválido ('$DB_MODE'). Use 'remote' ou 'docker'."
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
PAYER_ROLE_THIRD_PARTY,
|
PAYER_ROLE_THIRD_PARTY,
|
||||||
PAYER_STATUS_OPTIONS,
|
PAYER_STATUS_OPTIONS,
|
||||||
} from "@/shared/lib/payers/constants";
|
} from "@/shared/lib/payers/constants";
|
||||||
|
import { generateShareCode } from "@/shared/lib/payers/share-code";
|
||||||
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
|
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
|
||||||
import {
|
import {
|
||||||
addMonthsToDate,
|
addMonthsToDate,
|
||||||
@@ -537,6 +538,7 @@ async function ensureAdminPayer(targetUser: typeof user.$inferSelect) {
|
|||||||
note: null,
|
note: null,
|
||||||
role: PAYER_ROLE_ADMIN,
|
role: PAYER_ROLE_ADMIN,
|
||||||
isAutoSend: false,
|
isAutoSend: false,
|
||||||
|
shareCode: generateShareCode(),
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
})
|
})
|
||||||
.returning({ id: payers.id, name: payers.name });
|
.returning({ id: payers.id, name: payers.name });
|
||||||
@@ -870,6 +872,7 @@ async function main() {
|
|||||||
note: definition.note,
|
note: definition.note,
|
||||||
role: PAYER_ROLE_THIRD_PARTY,
|
role: PAYER_ROLE_THIRD_PARTY,
|
||||||
isAutoSend: definition.isAutoSend,
|
isAutoSend: definition.isAutoSend,
|
||||||
|
shareCode: generateShareCode(),
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
})
|
})
|
||||||
.returning({ id: payers.id });
|
.returning({ id: payers.id });
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import { config } from "dotenv";
|
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
|
||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
// Load environment variables from .env
|
|
||||||
config();
|
|
||||||
|
|
||||||
async function initDatabase() {
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
|
||||||
|
|
||||||
if (!databaseUrl) {
|
|
||||||
console.error("DATABASE_URL environment variable is required");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pool = new Pool({ connectionString: databaseUrl });
|
|
||||||
const db = drizzle(pool);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("🔧 Initializing database extensions...");
|
|
||||||
|
|
||||||
// Read and execute init.sql as a single query
|
|
||||||
const initSqlPath = path.join(
|
|
||||||
process.cwd(),
|
|
||||||
"scripts",
|
|
||||||
"postgres",
|
|
||||||
"init.sql",
|
|
||||||
);
|
|
||||||
const initSql = fs.readFileSync(initSqlPath, "utf-8");
|
|
||||||
|
|
||||||
console.log("Executing init.sql...");
|
|
||||||
await db.execute(initSql);
|
|
||||||
|
|
||||||
console.log("✅ Database initialization completed");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Database initialization failed:", error);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initDatabase();
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
-- Script de inicialização do PostgreSQL para Docker
|
|
||||||
-- Este script é executado automaticamente quando o banco é criado pela primeira vez
|
|
||||||
|
|
||||||
-- Habilitar extensão pgcrypto (necessária para gen_random_bytes usado pelo Drizzle)
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
-- Log de sucesso
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE '✅ Extensão pgcrypto habilitada com sucesso';
|
|
||||||
END $$;
|
|
||||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { AccountDialog } from "@/features/accounts/components/account-dialog";
|
import { AccountDialog } from "@/features/accounts/components/account-dialog";
|
||||||
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
|
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
|
||||||
|
import { AdjustBalanceDialog } from "@/features/accounts/components/adjust-balance-dialog";
|
||||||
import type { Account } from "@/features/accounts/components/types";
|
import type { Account } from "@/features/accounts/components/types";
|
||||||
import {
|
import {
|
||||||
fetchAccountData,
|
fetchAccountData,
|
||||||
@@ -141,6 +142,13 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
totalIncomes={totalIncomes}
|
totalIncomes={totalIncomes}
|
||||||
totalExpenses={totalExpenses}
|
totalExpenses={totalExpenses}
|
||||||
logo={account.logo}
|
logo={account.logo}
|
||||||
|
balanceAdjustment={
|
||||||
|
<AdjustBalanceDialog
|
||||||
|
accountId={account.id}
|
||||||
|
period={selectedPeriod}
|
||||||
|
currentBalance={currentBalance}
|
||||||
|
/>
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
<AccountDialog
|
<AccountDialog
|
||||||
mode="update"
|
mode="update"
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
financialAccount.id === card.accountId,
|
financialAccount.id === card.accountId,
|
||||||
)?.name ?? "Conta";
|
)?.name ?? "Conta";
|
||||||
|
|
||||||
|
const limitAmount = Number(card.limit);
|
||||||
|
|
||||||
const cardDialogData: Card = {
|
const cardDialogData: Card = {
|
||||||
id: card.id,
|
id: card.id,
|
||||||
name: card.name,
|
name: card.name,
|
||||||
@@ -127,19 +129,14 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
dueDay: card.dueDay,
|
dueDay: card.dueDay,
|
||||||
note: card.note ?? null,
|
note: card.note ?? null,
|
||||||
logo: card.logo,
|
logo: card.logo,
|
||||||
limit:
|
limit: limitAmount,
|
||||||
card.limit !== null && card.limit !== undefined
|
|
||||||
? Number(card.limit)
|
|
||||||
: null,
|
|
||||||
accountId: card.accountId,
|
accountId: card.accountId,
|
||||||
accountName,
|
accountName,
|
||||||
limitInUse: 0,
|
limitInUse: 0,
|
||||||
limitAvailable: null,
|
limitAvailable: limitAmount,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
|
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
|
||||||
const limitAmount =
|
|
||||||
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
|
|
||||||
|
|
||||||
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
|
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
|
||||||
1,
|
1,
|
||||||
@@ -163,6 +160,12 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
limitAmount={limitAmount}
|
limitAmount={limitAmount}
|
||||||
invoiceStatus={invoiceStatus}
|
invoiceStatus={invoiceStatus}
|
||||||
paymentDate={paymentDate}
|
paymentDate={paymentDate}
|
||||||
|
defaultPaymentAccountId={card.accountId}
|
||||||
|
paymentAccountOptions={accountOptions.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
logo: option.logo ?? null,
|
||||||
|
}))}
|
||||||
logo={card.logo}
|
logo={card.logo}
|
||||||
actions={
|
actions={
|
||||||
<CardDialog
|
<CardDialog
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { connection } from "next/server";
|
|||||||
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
|
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
|
||||||
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
|
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
|
||||||
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
||||||
|
import { extractDashboardLogoNames } from "@/features/dashboard/extract-logo-names";
|
||||||
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
|
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
|
||||||
import { getSingleParam } from "@/features/transactions/page-helpers";
|
import { getSingleParam } from "@/features/transactions/page-helpers";
|
||||||
|
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||||
import { parsePeriodParam } from "@/shared/utils/period";
|
import { parsePeriodParam } from "@/shared/utils/period";
|
||||||
|
|
||||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||||
@@ -25,17 +28,24 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
await fetchDashboardPageData(user.id, selectedPeriod);
|
await fetchDashboardPageData(user.id, selectedPeriod);
|
||||||
const { dashboardWidgets } = preferences;
|
const { dashboardWidgets } = preferences;
|
||||||
|
|
||||||
|
const logoMappings = await prefetchLogoMappings(
|
||||||
|
user.id,
|
||||||
|
extractDashboardLogoNames(dashboardData),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4">
|
<main className="flex flex-col gap-4">
|
||||||
<DashboardWelcome name={user.name} />
|
<DashboardWelcome name={user.name} />
|
||||||
<MonthNavigation />
|
<MonthNavigation />
|
||||||
<DashboardMetricsCards metrics={dashboardData.metrics} />
|
<DashboardMetricsCards metrics={dashboardData.metrics} />
|
||||||
<DashboardGridEditable
|
<LogoPrefetchProvider mappings={logoMappings}>
|
||||||
data={dashboardData}
|
<DashboardGridEditable
|
||||||
period={selectedPeriod}
|
data={dashboardData}
|
||||||
initialPreferences={dashboardWidgets}
|
period={selectedPeriod}
|
||||||
quickActionOptions={quickActionOptions}
|
initialPreferences={dashboardWidgets}
|
||||||
/>
|
quickActionOptions={quickActionOptions}
|
||||||
|
/>
|
||||||
|
</LogoPrefetchProvider>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
fetchRecentEstablishments,
|
fetchRecentEstablishments,
|
||||||
fetchTransactionFilterSources,
|
fetchTransactionFilterSources,
|
||||||
} from "@/features/transactions/queries";
|
} from "@/features/transactions/queries";
|
||||||
|
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||||
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
||||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@/shared/components/ui/tabs";
|
} from "@/shared/components/ui/tabs";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||||
import { getPayerAccess } from "@/shared/lib/payers/access";
|
import { getPayerAccess } from "@/shared/lib/payers/access";
|
||||||
import {
|
import {
|
||||||
fetchPagadorBoletoItems,
|
fetchPagadorBoletoItems,
|
||||||
@@ -82,6 +84,7 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
|||||||
searchFilter: null,
|
searchFilter: null,
|
||||||
settledFilter: null,
|
settledFilter: null,
|
||||||
attachmentFilter: null,
|
attachmentFilter: null,
|
||||||
|
dividedFilter: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEmptySlugMaps = (): SlugMaps => ({
|
const createEmptySlugMaps = (): SlugMaps => ({
|
||||||
@@ -307,104 +310,113 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
lancamentoCount: transactionData.length,
|
lancamentoCount: transactionData.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const logoMappings = await prefetchLogoMappings(dataOwnerId, [
|
||||||
|
...transactionData.map((t) => t.name),
|
||||||
|
...boletoItems.map((b) => b.name),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-6">
|
<main className="flex flex-col gap-6">
|
||||||
<MonthNavigation />
|
<MonthNavigation />
|
||||||
|
|
||||||
<Tabs defaultValue="profile" className="w-full">
|
<LogoPrefetchProvider mappings={logoMappings}>
|
||||||
<TabsList className="mb-2">
|
<Tabs defaultValue="profile" className="w-full">
|
||||||
<TabsTrigger value="profile">Perfil</TabsTrigger>
|
<TabsList className="mb-2">
|
||||||
<TabsTrigger value="painel">Painel</TabsTrigger>
|
<TabsTrigger value="profile">Perfil</TabsTrigger>
|
||||||
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
|
<TabsTrigger value="painel">Painel</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
|
||||||
<PayerHeaderCard
|
</TabsList>
|
||||||
payer={payerData}
|
<PayerHeaderCard
|
||||||
selectedPeriod={selectedPeriod}
|
payer={payerData}
|
||||||
summary={summaryPreview}
|
selectedPeriod={selectedPeriod}
|
||||||
/>
|
summary={summaryPreview}
|
||||||
|
/>
|
||||||
|
|
||||||
<TabsContent value="profile" className="space-y-4">
|
<TabsContent value="profile" className="space-y-4">
|
||||||
<PagadorInfoCard payer={payerData} />
|
<PagadorInfoCard payer={payerData} />
|
||||||
{canEdit && payerData.shareCode ? (
|
{canEdit && payerData.shareCode ? (
|
||||||
<PayerSharingCard
|
<PayerSharingCard
|
||||||
payerId={pagador.id}
|
payerId={pagador.id}
|
||||||
shareCode={payerData.shareCode}
|
shareCode={payerData.shareCode}
|
||||||
shares={payerSharesData}
|
shares={payerSharesData}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!canEdit && currentUserShare ? (
|
{!canEdit && currentUserShare ? (
|
||||||
<PayerLeaveShareCard
|
<PayerLeaveShareCard
|
||||||
shareId={currentUserShare.id}
|
shareId={currentUserShare.id}
|
||||||
pagadorName={payerData.name}
|
pagadorName={payerData.name}
|
||||||
createdAt={currentUserShare.createdAt}
|
createdAt={currentUserShare.createdAt}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="painel" className="space-y-4">
|
<TabsContent value="painel" className="space-y-4">
|
||||||
<section className="grid gap-3 lg:grid-cols-2">
|
<section className="grid gap-3 lg:grid-cols-2">
|
||||||
<PayerMonthlySummaryCard
|
<PayerMonthlySummaryCard
|
||||||
periodLabel={periodLabel}
|
periodLabel={periodLabel}
|
||||||
breakdown={monthlyBreakdown}
|
breakdown={monthlyBreakdown}
|
||||||
/>
|
/>
|
||||||
<PayerHistoryCard data={historyData} />
|
<PayerHistoryCard data={historyData} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-3 lg:grid-cols-3">
|
<section className="grid gap-3 lg:grid-cols-3">
|
||||||
<ExpandableWidgetCard
|
<ExpandableWidgetCard
|
||||||
title="Minhas Faturas"
|
title="Minhas Faturas"
|
||||||
subtitle="Valores por cartão neste período"
|
subtitle="Valores por cartão neste período"
|
||||||
icon={<RiBankCard2Line className="size-4" />}
|
icon={<RiBankCard2Line className="size-4" />}
|
||||||
>
|
>
|
||||||
<PayerCardUsageCard items={cardUsage} />
|
<PayerCardUsageCard items={cardUsage} />
|
||||||
</ExpandableWidgetCard>
|
</ExpandableWidgetCard>
|
||||||
<ExpandableWidgetCard
|
<ExpandableWidgetCard
|
||||||
title="Boletos"
|
title="Boletos"
|
||||||
subtitle="Boletos registrados neste período"
|
subtitle="Boletos registrados neste período"
|
||||||
icon={<RiBarcodeLine className="size-4" />}
|
icon={<RiBarcodeLine className="size-4" />}
|
||||||
>
|
>
|
||||||
<PayerBoletoCard items={boletoItems} />
|
<PayerBoletoCard items={boletoItems} />
|
||||||
</ExpandableWidgetCard>
|
</ExpandableWidgetCard>
|
||||||
<ExpandableWidgetCard
|
<ExpandableWidgetCard
|
||||||
title="Status de Pagamento"
|
title="Status de Pagamento"
|
||||||
subtitle="Situação das despesas no período"
|
subtitle="Situação das despesas no período"
|
||||||
icon={<RiWallet3Line className="size-4" />}
|
icon={<RiWallet3Line className="size-4" />}
|
||||||
>
|
>
|
||||||
<PayerPaymentStatusCard data={paymentStatus} />
|
<PayerPaymentStatusCard data={paymentStatus} />
|
||||||
</ExpandableWidgetCard>
|
</ExpandableWidgetCard>
|
||||||
</section>
|
</section>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="lancamentos">
|
<TabsContent value="lancamentos">
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<LancamentosSection
|
<LancamentosSection
|
||||||
currentUserId={userId}
|
currentUserId={userId}
|
||||||
transactions={transactionData}
|
transactions={transactionData}
|
||||||
payerOptions={optionSets.payerOptions}
|
payerOptions={optionSets.payerOptions}
|
||||||
splitPayerOptions={optionSets.splitPayerOptions}
|
splitPayerOptions={optionSets.splitPayerOptions}
|
||||||
defaultPayerId={pagador.id}
|
defaultPayerId={pagador.id}
|
||||||
accountOptions={optionSets.accountOptions}
|
accountOptions={optionSets.accountOptions}
|
||||||
cardOptions={optionSets.cardOptions}
|
cardOptions={optionSets.cardOptions}
|
||||||
categoryOptions={optionSets.categoryOptions}
|
categoryOptions={optionSets.categoryOptions}
|
||||||
payerFilterOptions={payerFilterOptions}
|
payerFilterOptions={payerFilterOptions}
|
||||||
categoryFilterOptions={optionSets.categoryFilterOptions}
|
categoryFilterOptions={optionSets.categoryFilterOptions}
|
||||||
accountCardFilterOptions={optionSets.accountCardFilterOptions}
|
accountCardFilterOptions={optionSets.accountCardFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
allowCreate={canEdit}
|
allowCreate={canEdit}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
||||||
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
|
importSplitPayerOptions={
|
||||||
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
loggedUserOptionSets?.splitPayerOptions
|
||||||
importAccountOptions={loggedUserOptionSets?.accountOptions}
|
}
|
||||||
importCardOptions={loggedUserOptionSets?.cardOptions}
|
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
||||||
importCategoryOptions={loggedUserOptionSets?.categoryOptions}
|
importAccountOptions={loggedUserOptionSets?.accountOptions}
|
||||||
/>
|
importCardOptions={loggedUserOptionSets?.cardOptions}
|
||||||
</section>
|
importCategoryOptions={loggedUserOptionSets?.categoryOptions}
|
||||||
</TabsContent>
|
/>
|
||||||
</Tabs>
|
</section>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</LogoPrefetchProvider>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
fetchTransactionFilterSources,
|
fetchTransactionFilterSources,
|
||||||
fetchTransactionsPage,
|
fetchTransactionsPage,
|
||||||
} from "@/features/transactions/queries";
|
} from "@/features/transactions/queries";
|
||||||
|
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||||
import { parsePeriodParam } from "@/shared/utils/period";
|
import { parsePeriodParam } from "@/shared/utils/period";
|
||||||
|
|
||||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||||
@@ -74,38 +76,45 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
payerRows: filterSources.payerRows,
|
payerRows: filterSources.payerRows,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const logoMappings = await prefetchLogoMappings(
|
||||||
|
userId,
|
||||||
|
transactionData.map((t) => t.name),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-6">
|
<main className="flex flex-col gap-6">
|
||||||
<MonthNavigation />
|
<MonthNavigation />
|
||||||
<TransactionsPage
|
<LogoPrefetchProvider mappings={logoMappings}>
|
||||||
currentUserId={userId}
|
<TransactionsPage
|
||||||
transactions={transactionData}
|
currentUserId={userId}
|
||||||
payerOptions={payerOptions}
|
transactions={transactionData}
|
||||||
splitPayerOptions={splitPayerOptions}
|
payerOptions={payerOptions}
|
||||||
defaultPayerId={defaultPayerId}
|
splitPayerOptions={splitPayerOptions}
|
||||||
accountOptions={accountOptions}
|
defaultPayerId={defaultPayerId}
|
||||||
cardOptions={cardOptions}
|
accountOptions={accountOptions}
|
||||||
categoryOptions={categoryOptions}
|
cardOptions={cardOptions}
|
||||||
payerFilterOptions={payerFilterOptions}
|
categoryOptions={categoryOptions}
|
||||||
categoryFilterOptions={categoryFilterOptions}
|
payerFilterOptions={payerFilterOptions}
|
||||||
accountCardFilterOptions={accountCardFilterOptions}
|
categoryFilterOptions={categoryFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
accountCardFilterOptions={accountCardFilterOptions}
|
||||||
estabelecimentos={estabelecimentos}
|
selectedPeriod={selectedPeriod}
|
||||||
pagination={{
|
estabelecimentos={estabelecimentos}
|
||||||
page: transactionsPage.page,
|
pagination={{
|
||||||
pageSize: transactionsPage.pageSize,
|
page: transactionsPage.page,
|
||||||
totalItems: transactionsPage.totalItems,
|
pageSize: transactionsPage.pageSize,
|
||||||
totalPages: transactionsPage.totalPages,
|
totalItems: transactionsPage.totalItems,
|
||||||
}}
|
totalPages: transactionsPage.totalPages,
|
||||||
exportContext={{
|
}}
|
||||||
source: "transactions",
|
exportContext={{
|
||||||
period: selectedPeriod,
|
source: "transactions",
|
||||||
filters: searchFilters,
|
period: selectedPeriod,
|
||||||
}}
|
filters: searchFilters,
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
}}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
/>
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
|
/>
|
||||||
|
</LogoPrefetchProvider>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AnimateOnScroll } from "@/features/landing/components/animate-on-scroll";
|
import { AnimateOnScroll } from "@/features/landing/components/animate-on-scroll";
|
||||||
import { MobileNav } from "@/features/landing/components/mobile-nav";
|
import { MobileNav } from "@/features/landing/components/mobile-nav";
|
||||||
import { ScreenshotTabs } from "@/features/landing/components/screenshot-tabs";
|
|
||||||
import { SetupTabs } from "@/features/landing/components/setup-tabs";
|
import { SetupTabs } from "@/features/landing/components/setup-tabs";
|
||||||
import {
|
import {
|
||||||
companionBanks,
|
companionBanks,
|
||||||
@@ -51,19 +50,19 @@ export default async function Page() {
|
|||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<NavbarShell>
|
<NavbarShell>
|
||||||
{/* Center Navigation Links */}
|
{/* Center Navigation Links */}
|
||||||
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
|
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
{navLinks.map(({ href, label }) => (
|
{navLinks.map(({ href, label }) => (
|
||||||
<a
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors dark:text-white/75 dark:hover:text-white dark:hover:bg-white/10"
|
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium leading-none text-primary-foreground/75 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<nav className="ml-auto flex items-center gap-2 md:gap-3">
|
<nav className="ml-auto flex items-center gap-1">
|
||||||
<AnimatedThemeToggler variant="navbar" />
|
<AnimatedThemeToggler variant="navbar" />
|
||||||
{!isPublicDomain &&
|
{!isPublicDomain &&
|
||||||
(session?.user ? (
|
(session?.user ? (
|
||||||
@@ -71,26 +70,27 @@ export default async function Page() {
|
|||||||
<Button
|
<Button
|
||||||
variant="navbar"
|
variant="navbar"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border border-black/20 dark:border-white/20"
|
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
>
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-1">
|
||||||
<Link href="/login">
|
<Link href="/login">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
>
|
>
|
||||||
Entrar
|
Entrar
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/signup">
|
<Link href="/signup">
|
||||||
<Button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2 dark:bg-white/10 dark:border-white/20 dark:text-white dark:hover:bg-white/20"
|
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
>
|
>
|
||||||
Começar
|
Começar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -207,31 +207,6 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Screenshots Gallery Section */}
|
|
||||||
<section id="telas" className="py-12 md:py-24">
|
|
||||||
<div className="max-w-8xl mx-auto px-4">
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<AnimateOnScroll>
|
|
||||||
<div className="text-center mb-8 md:mb-12">
|
|
||||||
<Badge variant="outline" className="mb-4">
|
|
||||||
Conheça as telas
|
|
||||||
</Badge>
|
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
|
|
||||||
Veja o que você pode fazer
|
|
||||||
</h2>
|
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
|
||||||
Explore as principais telas do OpenMonetis
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</AnimateOnScroll>
|
|
||||||
|
|
||||||
<AnimateOnScroll>
|
|
||||||
<ScreenshotTabs />
|
|
||||||
</AnimateOnScroll>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<section id="funcionalidades" className="py-12 md:py-24 bg-muted/40">
|
<section id="funcionalidades" className="py-12 md:py-24 bg-muted/40">
|
||||||
<div className="max-w-8xl mx-auto px-4">
|
<div className="max-w-8xl mx-auto px-4">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -236,9 +236,7 @@ export const payers = pgTable(
|
|||||||
note: text("anotacao"),
|
note: text("anotacao"),
|
||||||
role: text("role"),
|
role: text("role"),
|
||||||
isAutoSend: boolean("is_auto_send").notNull().default(false),
|
isAutoSend: boolean("is_auto_send").notNull().default(false),
|
||||||
shareCode: text("share_code")
|
shareCode: text("share_code").notNull(),
|
||||||
.notNull()
|
|
||||||
.default(sql`substr(encode(gen_random_bytes(24), 'base64'), 1, 24)`),
|
|
||||||
lastMailAt: timestamp("last_mail", {
|
lastMailAt: timestamp("last_mail", {
|
||||||
mode: "date",
|
mode: "date",
|
||||||
withTimezone: true,
|
withTimezone: true,
|
||||||
@@ -303,7 +301,9 @@ export const cards = pgTable(
|
|||||||
closingDay: text("dt_fechamento").notNull(),
|
closingDay: text("dt_fechamento").notNull(),
|
||||||
dueDay: text("dt_vencimento").notNull(),
|
dueDay: text("dt_vencimento").notNull(),
|
||||||
note: text("anotacao"),
|
note: text("anotacao"),
|
||||||
limit: numeric("limite", { precision: 10, scale: 2 }),
|
limit: numeric("limite", { precision: 10, scale: 2 })
|
||||||
|
.notNull()
|
||||||
|
.default("0"),
|
||||||
brand: text("bandeira"),
|
brand: text("bandeira"),
|
||||||
logo: text("logo"),
|
logo: text("logo"),
|
||||||
status: text("status").notNull(),
|
status: text("status").notNull(),
|
||||||
@@ -670,6 +670,7 @@ export const transactions = pgTable(
|
|||||||
onUpdate: "cascade",
|
onUpdate: "cascade",
|
||||||
}),
|
}),
|
||||||
seriesId: uuid("series_id"),
|
seriesId: uuid("series_id"),
|
||||||
|
splitGroupId: uuid("split_group_id"),
|
||||||
transferId: uuid("transfer_id"),
|
transferId: uuid("transfer_id"),
|
||||||
ofxFitId: text("ofx_fit_id"),
|
ofxFitId: text("ofx_fit_id"),
|
||||||
importBatchId: text("import_batch_id"),
|
importBatchId: text("import_batch_id"),
|
||||||
@@ -702,6 +703,11 @@ export const transactions = pgTable(
|
|||||||
),
|
),
|
||||||
// Índice para buscar parcelas de uma série
|
// Índice para buscar parcelas de uma série
|
||||||
seriesIdIdx: index("lancamentos_series_id_idx").on(table.seriesId),
|
seriesIdIdx: index("lancamentos_series_id_idx").on(table.seriesId),
|
||||||
|
// Índice para buscar shares de um split (userId + splitGroupId)
|
||||||
|
userIdSplitGroupIdIdx: index("lancamentos_user_id_split_group_id_idx").on(
|
||||||
|
table.userId,
|
||||||
|
table.splitGroupId,
|
||||||
|
),
|
||||||
// Índice para buscar transferências relacionadas
|
// Índice para buscar transferências relacionadas
|
||||||
transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId),
|
transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId),
|
||||||
// Índice para filtrar por condição (aberto, realizado, cancelado)
|
// Índice para filtrar por condição (aberto, realizado, cancelado)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { categories, financialAccounts, transactions } from "@/db/schema";
|
import { categories, financialAccounts, transactions } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
|
ACCOUNT_BALANCE_ADJUSTMENT_NAME,
|
||||||
INITIAL_BALANCE_CATEGORY_NAME,
|
INITIAL_BALANCE_CATEGORY_NAME,
|
||||||
INITIAL_BALANCE_CONDITION,
|
INITIAL_BALANCE_CONDITION,
|
||||||
INITIAL_BALANCE_NOTE,
|
INITIAL_BALANCE_NOTE,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
} from "@/shared/lib/actions/helpers";
|
} from "@/shared/lib/actions/helpers";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { PERIOD_FORMAT_REGEX } from "@/shared/lib/invoices";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
||||||
import {
|
import {
|
||||||
@@ -26,8 +28,11 @@ import {
|
|||||||
TRANSFER_ESTABLISHMENT_SAIDA,
|
TRANSFER_ESTABLISHMENT_SAIDA,
|
||||||
TRANSFER_PAYMENT_METHOD,
|
TRANSFER_PAYMENT_METHOD,
|
||||||
} from "@/shared/lib/transfers/constants";
|
} from "@/shared/lib/transfers/constants";
|
||||||
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
import {
|
||||||
import { getTodayInfo } from "@/shared/utils/date";
|
formatCurrency,
|
||||||
|
formatDecimalForDbRequired,
|
||||||
|
} from "@/shared/utils/currency";
|
||||||
|
import { getBusinessTodayDate, getTodayInfo } from "@/shared/utils/date";
|
||||||
import { normalizeFilePath } from "@/shared/utils/string";
|
import { normalizeFilePath } from "@/shared/utils/string";
|
||||||
|
|
||||||
const accountBaseSchema = z.object({
|
const accountBaseSchema = z.object({
|
||||||
@@ -99,7 +104,7 @@ export async function createAccountAction(
|
|||||||
|
|
||||||
if (hasInitialBalance && !adminPayerId) {
|
if (hasInitialBalance && !adminPayerId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Pessoa com papel administrador não encontrado. Crie um pessoa admin antes de definir um saldo inicial.",
|
"Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de definir um saldo inicial.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +304,7 @@ export async function transferBetweenAccountsAction(
|
|||||||
|
|
||||||
if (!adminPayerId) {
|
if (!adminPayerId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Pessoa administrador não encontrado. Por favor, crie um pessoa admin.",
|
"Pessoa administrador não encontrada. Por favor, crie uma pessoa admin.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,3 +396,120 @@ export async function transferBetweenAccountsAction(
|
|||||||
return handleActionError(error);
|
return handleActionError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adjustAccountBalanceSchema = z.object({
|
||||||
|
accountId: uuidSchema("FinancialAccount"),
|
||||||
|
period: z
|
||||||
|
.string({ message: "Período inválido." })
|
||||||
|
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
|
||||||
|
currentBalance: z.number({ message: "Saldo atual inválido." }),
|
||||||
|
targetBalance: z.number({ message: "Saldo correto inválido." }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AdjustAccountBalanceInput = z.infer<typeof adjustAccountBalanceSchema>;
|
||||||
|
|
||||||
|
export async function adjustAccountBalanceAction(
|
||||||
|
input: AdjustAccountBalanceInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = adjustAccountBalanceSchema.parse(input);
|
||||||
|
const adminPayerId = await getAdminPayerId(user.id);
|
||||||
|
|
||||||
|
if (!adminPayerId) {
|
||||||
|
throw new Error(
|
||||||
|
"Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de ajustar o saldo.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = "Ajuste de saldo registrado.";
|
||||||
|
|
||||||
|
await db.transaction(async (tx: typeof db) => {
|
||||||
|
const account = await tx.query.financialAccounts.findFirst({
|
||||||
|
columns: { id: true },
|
||||||
|
where: and(
|
||||||
|
eq(financialAccounts.id, data.accountId),
|
||||||
|
eq(financialAccounts.userId, user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("Conta não encontrada.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await tx.query.transactions.findFirst({
|
||||||
|
columns: { id: true, amount: true },
|
||||||
|
where: and(
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
eq(transactions.accountId, data.accountId),
|
||||||
|
eq(transactions.period, data.period),
|
||||||
|
eq(transactions.name, ACCOUNT_BALANCE_ADJUSTMENT_NAME),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingAmount = Number(existing?.amount ?? 0);
|
||||||
|
const baseBalance = data.currentBalance - existingAmount;
|
||||||
|
const adjustmentAmount =
|
||||||
|
Math.round((data.targetBalance - baseBalance) * 100) / 100;
|
||||||
|
|
||||||
|
if (adjustmentAmount === 0) {
|
||||||
|
if (existing) {
|
||||||
|
await tx.delete(transactions).where(eq(transactions.id, existing.id));
|
||||||
|
message = "Ajuste de saldo removido.";
|
||||||
|
} else {
|
||||||
|
message = "Nada a ajustar — o saldo já está correto.";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpense = adjustmentAmount < 0;
|
||||||
|
const categoryName = isExpense ? "Outras despesas" : "Outras receitas";
|
||||||
|
|
||||||
|
const category = await tx.query.categories.findFirst({
|
||||||
|
columns: { id: true },
|
||||||
|
where: and(
|
||||||
|
eq(categories.userId, user.id),
|
||||||
|
eq(categories.name, categoryName),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const amount = formatDecimalForDbRequired(adjustmentAmount);
|
||||||
|
const note = `O saldo era ${formatCurrency(baseBalance)} mas o correto é ${formatCurrency(data.targetBalance)}.`;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
condition: INITIAL_BALANCE_CONDITION,
|
||||||
|
name: ACCOUNT_BALANCE_ADJUSTMENT_NAME,
|
||||||
|
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
|
||||||
|
note,
|
||||||
|
amount,
|
||||||
|
purchaseDate: getBusinessTodayDate(),
|
||||||
|
transactionType: isExpense
|
||||||
|
? ("Despesa" as const)
|
||||||
|
: ("Receita" as const),
|
||||||
|
period: data.period,
|
||||||
|
isSettled: true,
|
||||||
|
userId: user.id,
|
||||||
|
accountId: data.accountId,
|
||||||
|
cardId: null,
|
||||||
|
categoryId: category?.id ?? null,
|
||||||
|
payerId: adminPayerId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await tx
|
||||||
|
.update(transactions)
|
||||||
|
.set(payload)
|
||||||
|
.where(eq(transactions.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(transactions).values(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateForEntity("accounts", user.id);
|
||||||
|
revalidateForEntity("transactions", user.id);
|
||||||
|
|
||||||
|
return { success: true, message };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type AccountStatementCardProps = {
|
|||||||
totalExpenses: number;
|
totalExpenses: number;
|
||||||
logo?: string | null;
|
logo?: string | null;
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
|
balanceAdjustment?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAccountStatusBadgeVariant = (
|
const getAccountStatusBadgeVariant = (
|
||||||
@@ -45,6 +46,7 @@ export function AccountStatementCard({
|
|||||||
totalExpenses,
|
totalExpenses,
|
||||||
logo,
|
logo,
|
||||||
actions,
|
actions,
|
||||||
|
balanceAdjustment,
|
||||||
}: AccountStatementCardProps) {
|
}: AccountStatementCardProps) {
|
||||||
const logoPath = resolveLogoSrc(logo);
|
const logoPath = resolveLogoSrc(logo);
|
||||||
const resultado = totalIncomes - totalExpenses;
|
const resultado = totalIncomes - totalExpenses;
|
||||||
@@ -84,10 +86,13 @@ export function AccountStatementCard({
|
|||||||
<p className="text-sm text-muted-foreground ">
|
<p className="text-sm text-muted-foreground ">
|
||||||
Saldo ao final do período
|
Saldo ao final do período
|
||||||
</p>
|
</p>
|
||||||
<MoneyValues
|
<div className="flex items-center gap-2">
|
||||||
amount={currentBalance}
|
<MoneyValues
|
||||||
className="text-3xl leading-none tracking-tighter sm:text-2xl"
|
amount={currentBalance}
|
||||||
/>
|
className="text-3xl leading-none tracking-tighter sm:text-2xl"
|
||||||
|
/>
|
||||||
|
{balanceAdjustment}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant={getAccountStatusBadgeVariant(status)}
|
variant={getAccountStatusBadgeVariant(status)}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ import {
|
|||||||
fetchTransactionsPageWithRelations,
|
fetchTransactionsPageWithRelations,
|
||||||
fetchTransactionsWithRelations,
|
fetchTransactionsWithRelations,
|
||||||
} from "@/features/transactions/queries";
|
} from "@/features/transactions/queries";
|
||||||
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
import {
|
||||||
|
INITIAL_BALANCE_NOTE,
|
||||||
|
REFUND_NOTE_PREFIX,
|
||||||
|
} from "@/shared/lib/accounts/constants";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
|
||||||
@@ -74,7 +77,9 @@ export async function fetchAccountSummary(
|
|||||||
sum(
|
sum(
|
||||||
case
|
case
|
||||||
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
|
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||||
|
when ${transactions.note} ilike ${`${REFUND_NOTE_PREFIX}%`} then 0
|
||||||
when ${transactions.transactionType} = 'Receita' then ${transactions.amount}
|
when ${transactions.transactionType} = 'Receita' then ${transactions.amount}
|
||||||
|
when ${transactions.transactionType} = 'Transferência' and ${transactions.amount} > 0 then ${transactions.amount}
|
||||||
else 0
|
else 0
|
||||||
end
|
end
|
||||||
),
|
),
|
||||||
@@ -86,7 +91,9 @@ export async function fetchAccountSummary(
|
|||||||
sum(
|
sum(
|
||||||
case
|
case
|
||||||
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
|
when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||||
|
when ${transactions.note} ilike ${`${REFUND_NOTE_PREFIX}%`} then abs(${transactions.amount})
|
||||||
when ${transactions.transactionType} = 'Despesa' then ${transactions.amount}
|
when ${transactions.transactionType} = 'Despesa' then ${transactions.amount}
|
||||||
|
when ${transactions.transactionType} = 'Transferência' and ${transactions.amount} < 0 then ${transactions.amount}
|
||||||
else 0
|
else 0
|
||||||
end
|
end
|
||||||
),
|
),
|
||||||
@@ -135,7 +142,8 @@ export async function fetchAccountSummary(
|
|||||||
const openingBalance = initialBalance + previousMovements;
|
const openingBalance = initialBalance + previousMovements;
|
||||||
const netAmount = Number(periodSummary?.netAmount ?? 0);
|
const netAmount = Number(periodSummary?.netAmount ?? 0);
|
||||||
const totalIncomes = Number(periodSummary?.incomes ?? 0);
|
const totalIncomes = Number(periodSummary?.incomes ?? 0);
|
||||||
const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0));
|
const expenseNet = Number(periodSummary?.expenses ?? 0);
|
||||||
|
const totalExpenses = Math.max(0, -expenseNet);
|
||||||
const currentBalance = openingBalance + netAmount;
|
const currentBalance = openingBalance + netAmount;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import { db } from "@/shared/lib/db";
|
|||||||
import {
|
import {
|
||||||
dayOfMonthSchema,
|
dayOfMonthSchema,
|
||||||
noteSchema,
|
noteSchema,
|
||||||
optionalDecimalSchema,
|
requiredDecimalSchema,
|
||||||
uuidSchema,
|
uuidSchema,
|
||||||
} from "@/shared/lib/schemas/common";
|
} from "@/shared/lib/schemas/common";
|
||||||
import { formatDecimalForDb } from "@/shared/utils/currency";
|
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||||
import { normalizeFilePath } from "@/shared/utils/string";
|
import { normalizeFilePath } from "@/shared/utils/string";
|
||||||
|
|
||||||
const cardBaseSchema = z.object({
|
const cardBaseSchema = z.object({
|
||||||
@@ -35,7 +35,7 @@ const cardBaseSchema = z.object({
|
|||||||
closingDay: dayOfMonthSchema,
|
closingDay: dayOfMonthSchema,
|
||||||
dueDay: dayOfMonthSchema,
|
dueDay: dayOfMonthSchema,
|
||||||
note: noteSchema,
|
note: noteSchema,
|
||||||
limit: optionalDecimalSchema,
|
limit: requiredDecimalSchema("limite"),
|
||||||
logo: z
|
logo: z
|
||||||
.string({ message: "Selecione um logo." })
|
.string({ message: "Selecione um logo." })
|
||||||
.trim()
|
.trim()
|
||||||
@@ -87,7 +87,7 @@ export async function createCardAction(
|
|||||||
closingDay: data.closingDay,
|
closingDay: data.closingDay,
|
||||||
dueDay: data.dueDay,
|
dueDay: data.dueDay,
|
||||||
note: data.note ?? null,
|
note: data.note ?? null,
|
||||||
limit: formatDecimalForDb(data.limit),
|
limit: formatDecimalForDbRequired(data.limit),
|
||||||
logo: logoFile,
|
logo: logoFile,
|
||||||
accountId: data.accountId,
|
accountId: data.accountId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -121,7 +121,7 @@ export async function updateCardAction(
|
|||||||
closingDay: data.closingDay,
|
closingDay: data.closingDay,
|
||||||
dueDay: data.dueDay,
|
dueDay: data.dueDay,
|
||||||
note: data.note ?? null,
|
note: data.note ?? null,
|
||||||
limit: formatDecimalForDb(data.limit),
|
limit: formatDecimalForDbRequired(data.limit),
|
||||||
logo: logoFile,
|
logo: logoFile,
|
||||||
accountId: data.accountId,
|
accountId: data.accountId,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -154,13 +154,21 @@ export function CardDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rawLimit = normalizeDecimalInput(formState.limit);
|
const rawLimit = normalizeDecimalInput(formState.limit);
|
||||||
|
const limitValue = rawLimit ? Number(rawLimit) : 0;
|
||||||
|
if (!Number.isFinite(limitValue) || limitValue <= 0) {
|
||||||
|
const message = "Informe um limite maior que zero.";
|
||||||
|
setErrorMessage(message);
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload: CardCreatePayload = {
|
const payload: CardCreatePayload = {
|
||||||
name: formState.name.trim(),
|
name: formState.name.trim(),
|
||||||
brand: formState.brand,
|
brand: formState.brand,
|
||||||
status: formState.status,
|
status: formState.status,
|
||||||
closingDay: formState.closingDay,
|
closingDay: formState.closingDay,
|
||||||
dueDay: formState.dueDay,
|
dueDay: formState.dueDay,
|
||||||
limit: rawLimit ? Number(rawLimit) : null,
|
limit: limitValue,
|
||||||
note: formState.note.trim() || null,
|
note: formState.note.trim() || null,
|
||||||
logo: formState.logo,
|
logo: formState.logo,
|
||||||
accountId: formState.accountId,
|
accountId: formState.accountId,
|
||||||
|
|||||||
@@ -112,12 +112,13 @@ export function CardFormFields({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="card-limit">Limite (R$)</Label>
|
<Label htmlFor="card-limit">Limite</Label>
|
||||||
<CurrencyInput
|
<CurrencyInput
|
||||||
id="card-limit"
|
id="card-limit"
|
||||||
value={values.limit}
|
value={values.limit}
|
||||||
onValueChange={(value) => onChange("limit", value)}
|
onValueChange={(value) => onChange("limit", value)}
|
||||||
placeholder="R$ 0,00"
|
placeholder="R$ 0,00"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ interface CardItemProps {
|
|||||||
status: string;
|
status: string;
|
||||||
closingDay: string;
|
closingDay: string;
|
||||||
dueDay: string;
|
dueDay: string;
|
||||||
limit: number | null;
|
limit: number;
|
||||||
limitInUse?: number | null;
|
limitInUse?: number;
|
||||||
limitAvailable?: number | null;
|
limitAvailable?: number;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
logo?: string | null;
|
logo?: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
@@ -61,30 +61,18 @@ export function CardItem({
|
|||||||
}: CardItemProps) {
|
}: CardItemProps) {
|
||||||
void _accountName;
|
void _accountName;
|
||||||
|
|
||||||
const limitTotal = limit ?? null;
|
|
||||||
const used =
|
const used =
|
||||||
limitInUse ??
|
limitInUse ??
|
||||||
(limitTotal !== null && limitAvailable != null
|
(limitAvailable !== undefined ? Math.max(limit - limitAvailable, 0) : 0);
|
||||||
? Math.max(limitTotal - limitAvailable, 0)
|
|
||||||
: limitTotal !== null
|
|
||||||
? 0
|
|
||||||
: null);
|
|
||||||
|
|
||||||
const available =
|
const available = limitAvailable ?? Math.max(limit - used, 0);
|
||||||
limitAvailable ??
|
|
||||||
(limitTotal !== null && used !== null
|
|
||||||
? Math.max(limitTotal - used, 0)
|
|
||||||
: null);
|
|
||||||
|
|
||||||
const usagePercent =
|
const usagePercent =
|
||||||
limitTotal && limitTotal > 0 && used !== null
|
limit > 0 ? Math.min(Math.max((used / limit) * 100, 0), 100) : 0;
|
||||||
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const logoPath = resolveLogoSrc(logo);
|
const logoPath = resolveLogoSrc(logo);
|
||||||
const brandAsset = resolveCardBrandAsset(brand);
|
const brandAsset = resolveCardBrandAsset(brand);
|
||||||
const isInactive = status?.toLowerCase() === "inativo";
|
const isInactive = status?.toLowerCase() === "inativo";
|
||||||
const hasMetrics = limitTotal !== null && used !== null && available !== null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col p-6 w-full">
|
<Card className="flex flex-col p-6 w-full">
|
||||||
@@ -174,54 +162,45 @@ export function CardItem({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex flex-1 flex-col gap-4 px-0">
|
<CardContent className="flex flex-1 flex-col gap-4 px-0">
|
||||||
{hasMetrics &&
|
<div className="flex flex-col gap-0.5">
|
||||||
available !== null &&
|
<span className="text-xs text-muted-foreground">
|
||||||
used !== null &&
|
Limite disponível
|
||||||
limitTotal !== null ? (
|
</span>
|
||||||
<>
|
<MoneyValues
|
||||||
<div className="flex flex-col gap-0.5">
|
amount={available}
|
||||||
<span className="text-xs text-muted-foreground">Disponível</span>
|
className="text-xl font-semibold text-success"
|
||||||
<MoneyValues
|
/>
|
||||||
amount={available}
|
</div>
|
||||||
className="text-xl font-semibold text-success"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">Limite total</span>
|
||||||
Limite total
|
<MoneyValues
|
||||||
</span>
|
amount={limit}
|
||||||
<MoneyValues
|
className="text-sm font-semibold text-foreground"
|
||||||
amount={limitTotal}
|
/>
|
||||||
className="text-sm font-semibold text-foreground"
|
</div>
|
||||||
/>
|
<div className="flex flex-col gap-0.5">
|
||||||
</div>
|
<span className="text-xs text-muted-foreground">
|
||||||
<div className="flex flex-col gap-0.5">
|
Limite utilizado
|
||||||
<span className="text-xs text-muted-foreground">Em uso</span>
|
</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={used}
|
amount={used}
|
||||||
className="text-sm font-semibold text-primary"
|
className="text-sm font-semibold text-destructive"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Progress
|
<Progress
|
||||||
value={usagePercent}
|
value={usagePercent}
|
||||||
className="h-2.5"
|
className="h-2.5"
|
||||||
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
|
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{usagePercent.toFixed(1)}% utilizado
|
{usagePercent.toFixed(1)}% utilizado
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Ainda não há limite registrado para este cartão.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">
|
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ export type Card = {
|
|||||||
dueDay: string;
|
dueDay: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
limit: number | null;
|
limit: number;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
limitInUse: number;
|
limitInUse: number;
|
||||||
limitAvailable: number | null;
|
limitAvailable: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CardFormValues = {
|
export type CardFormValues = {
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ type CardData = {
|
|||||||
dueDay: string;
|
dueDay: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
limit: number | null;
|
limit: number;
|
||||||
limitInUse: number;
|
limitInUse: number;
|
||||||
limitAvailable: number | null;
|
limitAvailable: number;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
};
|
};
|
||||||
@@ -96,15 +96,12 @@ async function fetchCardsByStatus(
|
|||||||
dueDay: card.dueDay,
|
dueDay: card.dueDay,
|
||||||
note: card.note,
|
note: card.note,
|
||||||
logo: card.logo,
|
logo: card.logo,
|
||||||
limit: card.limit ? Number(card.limit) : null,
|
limit: Number(card.limit),
|
||||||
limitInUse: (() => {
|
limitInUse: (() => {
|
||||||
const total = usageMap.get(card.id) ?? 0;
|
const total = usageMap.get(card.id) ?? 0;
|
||||||
return total < 0 ? Math.abs(total) : 0;
|
return total < 0 ? Math.abs(total) : 0;
|
||||||
})(),
|
})(),
|
||||||
limitAvailable: (() => {
|
limitAvailable: (() => {
|
||||||
if (!card.limit) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const total = usageMap.get(card.id) ?? 0;
|
const total = usageMap.get(card.id) ?? 0;
|
||||||
const inUse = total < 0 ? Math.abs(total) : 0;
|
const inUse = total < 0 ? Math.abs(total) : 0;
|
||||||
return Math.max(Number(card.limit) - inUse, 0);
|
return Math.max(Number(card.limit) - inUse, 0);
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export async function updateCategoryAction(
|
|||||||
|
|
||||||
revalidateForEntity("categories", user.id);
|
revalidateForEntity("categories", user.id);
|
||||||
|
|
||||||
return { success: true, message: "Category atualizada com sucesso." };
|
return { success: true, message: "Categoria atualizada com sucesso." };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleActionError(error);
|
return handleActionError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ export type DashboardBill = {
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
boletoPaymentDate: string | null;
|
boletoPaymentDate: string | null;
|
||||||
isSettled: boolean;
|
isSettled: boolean;
|
||||||
|
accountId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BillPaymentAccountOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
logo: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardBillsSnapshot = {
|
export type DashboardBillsSnapshot = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
type BillDialogState,
|
type BillDialogState,
|
||||||
getCurrentBillDateString,
|
getCurrentBillDateString,
|
||||||
@@ -20,12 +21,26 @@ type BillWidgetController = Omit<
|
|||||||
> & {
|
> & {
|
||||||
selectedBill: DashboardBill | null;
|
selectedBill: DashboardBill | null;
|
||||||
modalState: BillDialogState;
|
modalState: BillDialogState;
|
||||||
|
paymentAccountId: string;
|
||||||
|
setPaymentAccountId: (accountId: string) => void;
|
||||||
|
paymentDate: Date;
|
||||||
|
setPaymentDate: (date: Date) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toIsoDate = (date: Date) => date.toISOString().split("T")[0] ?? "";
|
||||||
|
|
||||||
export function useBillWidgetController(
|
export function useBillWidgetController(
|
||||||
bills?: DashboardBill[],
|
bills?: DashboardBill[],
|
||||||
): BillWidgetController {
|
): BillWidgetController {
|
||||||
const safeBills = bills ?? EMPTY_BILLS;
|
const safeBills = bills ?? EMPTY_BILLS;
|
||||||
|
const [paymentAccountId, setPaymentAccountId] = useState<string>("");
|
||||||
|
const [paymentDate, setPaymentDate] = useState<Date>(() => new Date());
|
||||||
|
|
||||||
|
const paymentAccountIdRef = useRef(paymentAccountId);
|
||||||
|
const paymentDateRef = useRef(paymentDate);
|
||||||
|
paymentAccountIdRef.current = paymentAccountId;
|
||||||
|
paymentDateRef.current = paymentDate;
|
||||||
|
|
||||||
const controller = usePaymentDialogController({
|
const controller = usePaymentDialogController({
|
||||||
items: safeBills,
|
items: safeBills,
|
||||||
getItemId: (bill) => bill.id,
|
getItemId: (bill) => bill.id,
|
||||||
@@ -34,13 +49,36 @@ export function useBillWidgetController(
|
|||||||
toggleTransactionSettlementAction({
|
toggleTransactionSettlementAction({
|
||||||
id: bill.id,
|
id: bill.id,
|
||||||
value: true,
|
value: true,
|
||||||
|
paymentAccountId: paymentAccountIdRef.current || null,
|
||||||
|
paymentDate: toIsoDate(paymentDateRef.current),
|
||||||
}),
|
}),
|
||||||
applyConfirmedState: (bill) =>
|
applyConfirmedState: (bill) =>
|
||||||
markBillAsSettled(bill, getCurrentBillDateString()),
|
markBillAsSettled(
|
||||||
|
{
|
||||||
|
...bill,
|
||||||
|
accountId: paymentAccountIdRef.current || bill.accountId,
|
||||||
|
},
|
||||||
|
toIsoDate(paymentDateRef.current) || getCurrentBillDateString(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedBillId = controller.selectedItem?.id ?? null;
|
||||||
|
const selectedBillAccountId = controller.selectedItem?.accountId ?? "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedBillId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPaymentAccountId(selectedBillAccountId ?? "");
|
||||||
|
setPaymentDate(new Date());
|
||||||
|
}, [selectedBillId, selectedBillAccountId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...controller,
|
...controller,
|
||||||
selectedBill: controller.selectedItem,
|
selectedBill: controller.selectedItem,
|
||||||
|
paymentAccountId,
|
||||||
|
setPaymentAccountId,
|
||||||
|
paymentDate,
|
||||||
|
setPaymentDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,7 @@ type UniqueCategory = {
|
|||||||
icon: string | null;
|
icon: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchAllCategories(
|
async function fetchAllCategories(userId: string): Promise<CategoryOption[]> {
|
||||||
userId: string,
|
|
||||||
): Promise<CategoryOption[]> {
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({
|
.select({
|
||||||
id: categories.id,
|
id: categories.id,
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { RiCheckboxCircleFill } from "@remixicon/react";
|
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
|
||||||
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
buildBillStatusLabel,
|
buildBillStatusLabel,
|
||||||
buildBillWidgetStatusLabel,
|
buildBillWidgetStatusLabel,
|
||||||
@@ -13,14 +14,25 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/shared/components/ui/tooltip";
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { getCurrentPeriod, formatPeriodForUrl } from "@/shared/utils/period";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
type BillListItemProps = {
|
type BillListItemProps = {
|
||||||
bill: DashboardBill;
|
bill: DashboardBill;
|
||||||
|
period?: string;
|
||||||
onPay: (billId: string) => void;
|
onPay: (billId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BillListItem({ bill, onPay }: BillListItemProps) {
|
function buildTransactionsHref(name: string, period?: string): string {
|
||||||
|
const params = new URLSearchParams({ q: name });
|
||||||
|
const current = getCurrentPeriod();
|
||||||
|
if (period && period !== current) {
|
||||||
|
params.set("periodo", formatPeriodForUrl(period));
|
||||||
|
}
|
||||||
|
return `/transactions?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillListItem({ bill, period, onPay }: BillListItemProps) {
|
||||||
const statusLabel = buildBillWidgetStatusLabel(bill);
|
const statusLabel = buildBillWidgetStatusLabel(bill);
|
||||||
const absoluteStatusLabel = buildBillStatusLabel(bill);
|
const absoluteStatusLabel = buildBillStatusLabel(bill);
|
||||||
const overdue = isBillOverdue(bill);
|
const overdue = isBillOverdue(bill);
|
||||||
@@ -28,6 +40,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
|||||||
statusLabel && statusLabel !== absoluteStatusLabel
|
statusLabel && statusLabel !== absoluteStatusLabel
|
||||||
? absoluteStatusLabel
|
? absoluteStatusLabel
|
||||||
: null;
|
: null;
|
||||||
|
const href = buildTransactionsHref(bill.name, period);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
|
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
|
||||||
@@ -35,9 +48,16 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
|||||||
<EstablishmentLogo name={bill.name} size={37} />
|
<EstablishmentLogo name={bill.name} size={37} />
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="block truncate text-sm font-medium text-foreground">
|
<Link
|
||||||
{bill.name}
|
href={href}
|
||||||
</span>
|
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<span className="truncate">{bill.name}</span>
|
||||||
|
<RiExternalLinkLine
|
||||||
|
className="size-3 shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
{statusLabel ? (
|
{statusLabel ? (
|
||||||
statusTooltipLabel ? (
|
statusTooltipLabel ? (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
RiBarcodeFill,
|
|
||||||
RiCalendarLine,
|
RiCalendarLine,
|
||||||
RiLoader4Line,
|
RiLoader4Line,
|
||||||
RiMoneyDollarCircleLine,
|
RiMoneyDollarCircleLine,
|
||||||
@@ -9,11 +8,18 @@ import {
|
|||||||
formatBillDateLabel,
|
formatBillDateLabel,
|
||||||
getBillStatusBadgeVariant,
|
getBillStatusBadgeVariant,
|
||||||
} from "@/features/dashboard/bills/bills-helpers";
|
} from "@/features/dashboard/bills/bills-helpers";
|
||||||
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
import type {
|
||||||
|
BillPaymentAccountOption,
|
||||||
|
DashboardBill,
|
||||||
|
} from "@/features/dashboard/bills/bills-queries";
|
||||||
|
import { AccountCardSelectContent } from "@/features/transactions/components/select-items";
|
||||||
|
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { PaymentSuccess } from "@/shared/components/payment-success";
|
import { PaymentSuccess } from "@/shared/components/payment-success";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Card } from "@/shared/components/ui/card";
|
||||||
|
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -22,12 +28,26 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog";
|
} from "@/shared/components/ui/dialog";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
|
|
||||||
type BillPaymentDialogProps = {
|
type BillPaymentDialogProps = {
|
||||||
bill: DashboardBill | null;
|
bill: DashboardBill | null;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
modalState: BillDialogState;
|
modalState: BillDialogState;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
|
paymentAccountId: string;
|
||||||
|
onPaymentAccountChange: (accountId: string) => void;
|
||||||
|
paymentDate: Date;
|
||||||
|
onPaymentDateChange: (date: Date) => void;
|
||||||
|
paymentAccountOptions: BillPaymentAccountOption[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
};
|
};
|
||||||
@@ -37,6 +57,11 @@ export function BillPaymentDialog({
|
|||||||
open,
|
open,
|
||||||
modalState,
|
modalState,
|
||||||
isPending,
|
isPending,
|
||||||
|
paymentAccountId,
|
||||||
|
onPaymentAccountChange,
|
||||||
|
paymentDate,
|
||||||
|
onPaymentDateChange,
|
||||||
|
paymentAccountOptions,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: BillPaymentDialogProps) {
|
}: BillPaymentDialogProps) {
|
||||||
@@ -44,6 +69,14 @@ export function BillPaymentDialog({
|
|||||||
const dueLabel = bill
|
const dueLabel = bill
|
||||||
? formatBillDateLabel(bill.dueDate, "Vencimento:")
|
? formatBillDateLabel(bill.dueDate, "Vencimento:")
|
||||||
: null;
|
: null;
|
||||||
|
const paidLabel = bill
|
||||||
|
? formatBillDateLabel(bill.boletoPaymentDate, "Pago em:")
|
||||||
|
: null;
|
||||||
|
const isBillPending = bill ? !bill.isSettled : false;
|
||||||
|
const paymentDateValue = paymentDate.toISOString().split("T")[0] ?? "";
|
||||||
|
const selectedAccount = paymentAccountOptions.find(
|
||||||
|
(option) => option.value === paymentAccountId,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -78,13 +111,12 @@ export function BillPaymentDialog({
|
|||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="mb-1 flex items-center gap-3">
|
<div className="mb-1 flex items-center gap-3">
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
|
||||||
<RiBarcodeFill className="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<DialogTitle>Confirmar pagamento</DialogTitle>
|
<DialogTitle>Confirmar pagamento</DialogTitle>
|
||||||
<DialogDescription className="mt-0.5 text-xs">
|
<DialogDescription className="mt-1 text-xs">
|
||||||
Boleto
|
{isBillPending
|
||||||
|
? "Escolha a conta de origem e a data em que o boleto foi pago."
|
||||||
|
: "Boleto"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,62 +124,118 @@ export function BillPaymentDialog({
|
|||||||
|
|
||||||
{bill ? (
|
{bill ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Card principal */}
|
<Card className="flex flex-row items-start gap-2 p-4">
|
||||||
<div className="rounded-xl border p-3">
|
<EstablishmentLogo
|
||||||
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
name={bill.name}
|
||||||
Boleto
|
size={36}
|
||||||
</p>
|
className="size-9 shrink-0"
|
||||||
<p className="text-base font-semibold text-foreground">
|
/>
|
||||||
{bill.name}
|
<div className="min-w-0">
|
||||||
</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase">
|
||||||
</div>
|
Boleto
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-base font-semibold text-foreground">
|
||||||
|
{bill.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Métricas */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-xl border p-3">
|
<Card className="p-3">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
<RiMoneyDollarCircleLine className="size-3.5" />
|
<RiMoneyDollarCircleLine className="size-3.5" />
|
||||||
<span className="text-xs font-medium uppercase tracking-wide">
|
<span className="text-xs font-medium uppercase">
|
||||||
Valor
|
Valor
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={bill.amount}
|
amount={bill.amount}
|
||||||
className="text-lg font-semibold"
|
className="text-xl font-semibold"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className="rounded-xl border p-3">
|
<Card className="p-3">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
<RiCalendarLine className="size-3.5" />
|
<RiCalendarLine className="size-3.5" />
|
||||||
<span className="text-xs font-medium uppercase tracking-wide">
|
<span className="text-xs font-medium uppercase">
|
||||||
Vencimento
|
{bill.isSettled ? "Pago em" : "Vencimento"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="font-semibold">
|
||||||
{dueLabel?.replace("Vencimento: ", "") ?? "—"}
|
{bill.isSettled
|
||||||
|
? (paidLabel?.replace("Pago em: ", "") ?? "—")
|
||||||
|
: (dueLabel?.replace("Vencimento: ", "") ?? "—")}
|
||||||
</p>
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{isBillPending ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bill-widget-payment-account">
|
||||||
|
Conta de pagamento
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={paymentAccountId}
|
||||||
|
onValueChange={onPaymentAccountChange}
|
||||||
|
disabled={
|
||||||
|
isProcessing || paymentAccountOptions.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="bill-widget-payment-account"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Selecione uma conta">
|
||||||
|
{selectedAccount ? (
|
||||||
|
<AccountCardSelectContent
|
||||||
|
label={selectedAccount.label}
|
||||||
|
logo={selectedAccount.logo}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{paymentAccountOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<AccountCardSelectContent
|
||||||
|
label={option.label}
|
||||||
|
logo={option.logo}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bill-widget-payment-date">
|
||||||
|
Data do pagamento
|
||||||
|
</Label>
|
||||||
|
<DatePicker
|
||||||
|
id="bill-widget-payment-date"
|
||||||
|
value={paymentDateValue}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
onPaymentDateChange(new Date(`${value}T00:00:00`));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex items-center justify-between rounded-xl border p-3">
|
||||||
{/* Status */}
|
<span className="text-sm text-muted-foreground">
|
||||||
<div className="flex items-center justify-between rounded-xl border p-3">
|
Status atual
|
||||||
<span className="text-sm text-muted-foreground">
|
</span>
|
||||||
Status atual
|
<Badge variant={getBillStatusBadgeVariant("Pago")}>
|
||||||
</span>
|
Pago
|
||||||
<Badge
|
</Badge>
|
||||||
variant={getBillStatusBadgeVariant(
|
</div>
|
||||||
bill.isSettled ? "Pago" : "Pendente",
|
)}
|
||||||
)}
|
|
||||||
>
|
|
||||||
{bill.isSettled ? "Pago" : "Pendente"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Aviso */}
|
|
||||||
<p className="px-1 text-xs text-muted-foreground">
|
|
||||||
Você poderá editar o lançamento depois, se necessário.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -163,7 +251,13 @@ export function BillPaymentDialog({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={isProcessing || !bill || bill.isSettled}
|
disabled={
|
||||||
|
isProcessing ||
|
||||||
|
!bill ||
|
||||||
|
bill.isSettled ||
|
||||||
|
(isBillPending &&
|
||||||
|
(!paymentAccountId || paymentAccountOptions.length === 0))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { BillListItem } from "./bill-list-item";
|
|||||||
|
|
||||||
type BillsListProps = {
|
type BillsListProps = {
|
||||||
bills: DashboardBill[];
|
bills: DashboardBill[];
|
||||||
|
period?: string;
|
||||||
onPay: (billId: string) => void;
|
onPay: (billId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BillsList({ bills, onPay }: BillsListProps) {
|
export function BillsList({ bills, period, onPay }: BillsListProps) {
|
||||||
if (bills.length === 0) {
|
if (bills.length === 0) {
|
||||||
return (
|
return (
|
||||||
<WidgetEmptyState
|
<WidgetEmptyState
|
||||||
@@ -22,7 +23,7 @@ export function BillsList({ bills, onPay }: BillsListProps) {
|
|||||||
return (
|
return (
|
||||||
<ul className="flex flex-col">
|
<ul className="flex flex-col">
|
||||||
{bills.map((bill) => (
|
{bills.map((bill) => (
|
||||||
<BillListItem key={bill.id} bill={bill} onPay={onPay} />
|
<BillListItem key={bill.id} bill={bill} period={period} onPay={onPay} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import type { BillDialogState } from "@/features/dashboard/bills/bills-helpers";
|
import type { BillDialogState } from "@/features/dashboard/bills/bills-helpers";
|
||||||
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
import type {
|
||||||
|
BillPaymentAccountOption,
|
||||||
|
DashboardBill,
|
||||||
|
} from "@/features/dashboard/bills/bills-queries";
|
||||||
import { BillPaymentDialog } from "./bill-payment-dialog";
|
import { BillPaymentDialog } from "./bill-payment-dialog";
|
||||||
import { BillsList } from "./bills-list";
|
import { BillsList } from "./bills-list";
|
||||||
|
|
||||||
type BillsWidgetViewProps = {
|
type BillsWidgetViewProps = {
|
||||||
bills: DashboardBill[];
|
bills: DashboardBill[];
|
||||||
|
period?: string;
|
||||||
selectedBill: DashboardBill | null;
|
selectedBill: DashboardBill | null;
|
||||||
isModalOpen: boolean;
|
isModalOpen: boolean;
|
||||||
modalState: BillDialogState;
|
modalState: BillDialogState;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
|
paymentAccountId: string;
|
||||||
|
onPaymentAccountChange: (accountId: string) => void;
|
||||||
|
paymentDate: Date;
|
||||||
|
onPaymentDateChange: (date: Date) => void;
|
||||||
|
paymentAccountOptions: BillPaymentAccountOption[];
|
||||||
onOpenPaymentDialog: (billId: string) => void;
|
onOpenPaymentDialog: (billId: string) => void;
|
||||||
onClosePaymentDialog: () => void;
|
onClosePaymentDialog: () => void;
|
||||||
onConfirmPayment: () => void;
|
onConfirmPayment: () => void;
|
||||||
@@ -16,10 +25,16 @@ type BillsWidgetViewProps = {
|
|||||||
|
|
||||||
export function BillsWidgetView({
|
export function BillsWidgetView({
|
||||||
bills,
|
bills,
|
||||||
|
period,
|
||||||
selectedBill,
|
selectedBill,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
modalState,
|
modalState,
|
||||||
isPending,
|
isPending,
|
||||||
|
paymentAccountId,
|
||||||
|
onPaymentAccountChange,
|
||||||
|
paymentDate,
|
||||||
|
onPaymentDateChange,
|
||||||
|
paymentAccountOptions,
|
||||||
onOpenPaymentDialog,
|
onOpenPaymentDialog,
|
||||||
onClosePaymentDialog,
|
onClosePaymentDialog,
|
||||||
onConfirmPayment,
|
onConfirmPayment,
|
||||||
@@ -27,7 +42,7 @@ export function BillsWidgetView({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<BillsList bills={bills} onPay={onOpenPaymentDialog} />
|
<BillsList bills={bills} period={period} onPay={onOpenPaymentDialog} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BillPaymentDialog
|
<BillPaymentDialog
|
||||||
@@ -35,6 +50,11 @@ export function BillsWidgetView({
|
|||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
modalState={modalState}
|
modalState={modalState}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
|
paymentAccountId={paymentAccountId}
|
||||||
|
onPaymentAccountChange={onPaymentAccountChange}
|
||||||
|
paymentDate={paymentDate}
|
||||||
|
onPaymentDateChange={onPaymentDateChange}
|
||||||
|
paymentAccountOptions={paymentAccountOptions}
|
||||||
onClose={onClosePaymentDialog}
|
onClose={onClosePaymentDialog}
|
||||||
onConfirm={onConfirmPayment}
|
onConfirm={onConfirmPayment}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { formatCurrentDate, getGreeting } from "@/features/dashboard/widget-registry/welcome-widget";
|
import {
|
||||||
|
formatCurrentDate,
|
||||||
|
getGreeting,
|
||||||
|
} from "@/features/dashboard/widget-registry/welcome-widget";
|
||||||
|
|
||||||
type DashboardWelcomeProps = {
|
type DashboardWelcomeProps = {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
|
||||||
import { buildInstallmentExpenseDisplay } from "@/features/dashboard/expenses/installment-expenses-helpers";
|
import { buildInstallmentExpenseDisplay } from "@/features/dashboard/expenses/installment-expenses-helpers";
|
||||||
|
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Progress } from "@/shared/components/ui/progress";
|
import { Progress } from "@/shared/components/ui/progress";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
RiBankCardLine,
|
|
||||||
RiCalendarLine,
|
RiCalendarLine,
|
||||||
RiLoader4Line,
|
RiLoader4Line,
|
||||||
RiMoneyDollarCircleLine,
|
RiMoneyDollarCircleLine,
|
||||||
@@ -10,11 +9,17 @@ import {
|
|||||||
type InvoiceDialogState,
|
type InvoiceDialogState,
|
||||||
parseInvoiceDueDate,
|
parseInvoiceDueDate,
|
||||||
} from "@/features/dashboard/invoices/invoices-helpers";
|
} from "@/features/dashboard/invoices/invoices-helpers";
|
||||||
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
import type {
|
||||||
|
DashboardInvoice,
|
||||||
|
InvoicePaymentAccountOption,
|
||||||
|
} from "@/features/dashboard/invoices/invoices-queries";
|
||||||
|
import { AccountCardSelectContent } from "@/features/transactions/components/select-items";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { PaymentSuccess } from "@/shared/components/payment-success";
|
import { PaymentSuccess } from "@/shared/components/payment-success";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Card } from "@/shared/components/ui/card";
|
||||||
|
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -23,6 +28,15 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog";
|
} from "@/shared/components/ui/dialog";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
INVOICE_PAYMENT_STATUS,
|
INVOICE_PAYMENT_STATUS,
|
||||||
INVOICE_STATUS_LABEL,
|
INVOICE_STATUS_LABEL,
|
||||||
@@ -34,6 +48,11 @@ type InvoicePaymentDialogProps = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
modalState: InvoiceDialogState;
|
modalState: InvoiceDialogState;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
|
paymentAccountId: string;
|
||||||
|
onPaymentAccountChange: (accountId: string) => void;
|
||||||
|
paymentDate: Date;
|
||||||
|
onPaymentDateChange: (date: Date) => void;
|
||||||
|
paymentAccountOptions: InvoicePaymentAccountOption[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
};
|
};
|
||||||
@@ -43,6 +62,11 @@ export function InvoicePaymentDialog({
|
|||||||
open,
|
open,
|
||||||
modalState,
|
modalState,
|
||||||
isPending,
|
isPending,
|
||||||
|
paymentAccountId,
|
||||||
|
onPaymentAccountChange,
|
||||||
|
paymentDate,
|
||||||
|
onPaymentDateChange,
|
||||||
|
paymentAccountOptions,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: InvoicePaymentDialogProps) {
|
}: InvoicePaymentDialogProps) {
|
||||||
@@ -51,6 +75,12 @@ export function InvoicePaymentDialog({
|
|||||||
const dueInfo = invoice
|
const dueInfo = invoice
|
||||||
? parseInvoiceDueDate(invoice.period, invoice.dueDay)
|
? parseInvoiceDueDate(invoice.period, invoice.dueDay)
|
||||||
: null;
|
: null;
|
||||||
|
const isInvoicePending =
|
||||||
|
invoice?.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING;
|
||||||
|
const paymentDateValue = paymentDate.toISOString().split("T")[0] ?? "";
|
||||||
|
const selectedAccount = paymentAccountOptions.find(
|
||||||
|
(option) => option.value === paymentAccountId,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -85,13 +115,12 @@ export function InvoicePaymentDialog({
|
|||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="mb-1 flex items-center gap-3">
|
<div className="mb-1 flex items-center gap-3">
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
|
||||||
<RiBankCardLine className="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<DialogTitle>Confirmar pagamento</DialogTitle>
|
<DialogTitle>Confirmar pagamento</DialogTitle>
|
||||||
<DialogDescription className="mt-0.5 text-xs">
|
<DialogDescription className="mt-1 text-xs">
|
||||||
Fatura do cartão
|
{isInvoicePending
|
||||||
|
? "Escolha a conta de origem e a data em que a fatura foi paga."
|
||||||
|
: "Fatura do cartão"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,8 +128,7 @@ export function InvoicePaymentDialog({
|
|||||||
|
|
||||||
{invoice ? (
|
{invoice ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Card principal */}
|
<Card className="flex flex-row items-start gap-2 p-4">
|
||||||
<div className="flex items-center gap-3 rounded-xl border p-4">
|
|
||||||
<InvoiceLogo
|
<InvoiceLogo
|
||||||
cardName={invoice.cardName}
|
cardName={invoice.cardName}
|
||||||
logo={invoice.logo}
|
logo={invoice.logo}
|
||||||
@@ -110,66 +138,119 @@ export function InvoicePaymentDialog({
|
|||||||
fallbackClassName="text-xs"
|
fallbackClassName="text-xs"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<p className="text-xs font-medium text-muted-foreground uppercase">
|
||||||
Cartão
|
Cartão
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-base font-semibold text-foreground">
|
<p className="truncate text-base font-semibold text-foreground">
|
||||||
{invoice.cardName}
|
{invoice.cardName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Métricas */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-xl border p-3">
|
<Card className="p-3">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
<RiMoneyDollarCircleLine className="size-3.5" />
|
<RiMoneyDollarCircleLine className="size-3.5" />
|
||||||
<span className="text-xs font-medium uppercase tracking-wide">
|
<span className="text-xs font-medium uppercase">
|
||||||
Total da fatura
|
Total da fatura
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={Math.abs(invoice.totalAmount)}
|
amount={Math.abs(invoice.totalAmount)}
|
||||||
className="text-lg font-semibold"
|
className="text-xl font-semibold"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className="rounded-xl border p-3">
|
<Card className="p-3">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
<RiCalendarLine className="size-3.5" />
|
<RiCalendarLine className="size-3.5" />
|
||||||
<span className="text-xs font-medium uppercase tracking-wide">
|
<span className="text-xs font-medium uppercase">
|
||||||
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
||||||
? "Pago em"
|
? "Pago em"
|
||||||
: "Vencimento"}
|
: "Vencimento"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<div className="font-semibold">
|
||||||
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
{invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID
|
||||||
? (paymentInfo?.label ?? "—")
|
? (paymentInfo?.label?.replace(/^Pago em\s*/u, "") ??
|
||||||
: (dueInfo?.label ?? "—")}
|
"—")
|
||||||
</p>
|
: (dueInfo?.label?.replace(/^Vence (em|dia)\s*/u, "") ??
|
||||||
|
"—")}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{isInvoicePending ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invoice-widget-payment-account">
|
||||||
|
Conta de pagamento
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={paymentAccountId}
|
||||||
|
onValueChange={onPaymentAccountChange}
|
||||||
|
disabled={
|
||||||
|
isProcessing || paymentAccountOptions.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="invoice-widget-payment-account"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Selecione uma conta">
|
||||||
|
{selectedAccount ? (
|
||||||
|
<AccountCardSelectContent
|
||||||
|
label={selectedAccount.label}
|
||||||
|
logo={selectedAccount.logo}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{paymentAccountOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<AccountCardSelectContent
|
||||||
|
label={option.label}
|
||||||
|
logo={option.logo}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invoice-widget-payment-date">
|
||||||
|
Data do pagamento
|
||||||
|
</Label>
|
||||||
|
<DatePicker
|
||||||
|
id="invoice-widget-payment-date"
|
||||||
|
value={paymentDateValue}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
onPaymentDateChange(new Date(`${value}T00:00:00`));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex items-center justify-between rounded-xl border p-3">
|
||||||
{/* Status */}
|
<span className="text-sm text-muted-foreground">
|
||||||
<div className="flex items-center justify-between rounded-xl border p-3">
|
Status atual
|
||||||
<span className="text-sm text-muted-foreground">
|
</span>
|
||||||
Status atual
|
<Badge
|
||||||
</span>
|
variant={getInvoiceStatusBadgeVariant(
|
||||||
<Badge
|
INVOICE_STATUS_LABEL[invoice.paymentStatus],
|
||||||
variant={getInvoiceStatusBadgeVariant(
|
)}
|
||||||
INVOICE_STATUS_LABEL[invoice.paymentStatus],
|
>
|
||||||
)}
|
{INVOICE_STATUS_LABEL[invoice.paymentStatus]}
|
||||||
>
|
</Badge>
|
||||||
{INVOICE_STATUS_LABEL[invoice.paymentStatus]}
|
</div>
|
||||||
</Badge>
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Aviso */}
|
|
||||||
<p className="px-1 text-xs text-muted-foreground">
|
|
||||||
Vamos registrar a fatura como paga. Você poderá editar depois
|
|
||||||
se necessário.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -185,7 +266,12 @@ export function InvoicePaymentDialog({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={isProcessing || !invoice}
|
disabled={
|
||||||
|
isProcessing ||
|
||||||
|
!invoice ||
|
||||||
|
(isInvoicePending &&
|
||||||
|
(!paymentAccountId || paymentAccountOptions.length === 0))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { InvoiceDialogState } from "@/features/dashboard/invoices/invoices-helpers";
|
import type { InvoiceDialogState } from "@/features/dashboard/invoices/invoices-helpers";
|
||||||
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
import type {
|
||||||
|
DashboardInvoice,
|
||||||
|
InvoicePaymentAccountOption,
|
||||||
|
} from "@/features/dashboard/invoices/invoices-queries";
|
||||||
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
|
import { InvoicePaymentDialog } from "./invoice-payment-dialog";
|
||||||
import { InvoicesList } from "./invoices-list";
|
import { InvoicesList } from "./invoices-list";
|
||||||
|
|
||||||
@@ -9,6 +12,11 @@ type InvoicesWidgetViewProps = {
|
|||||||
isModalOpen: boolean;
|
isModalOpen: boolean;
|
||||||
modalState: InvoiceDialogState;
|
modalState: InvoiceDialogState;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
|
paymentAccountId: string;
|
||||||
|
onPaymentAccountChange: (accountId: string) => void;
|
||||||
|
paymentDate: Date;
|
||||||
|
onPaymentDateChange: (date: Date) => void;
|
||||||
|
paymentAccountOptions: InvoicePaymentAccountOption[];
|
||||||
onOpenPaymentDialog: (invoiceId: string) => void;
|
onOpenPaymentDialog: (invoiceId: string) => void;
|
||||||
onClosePaymentDialog: () => void;
|
onClosePaymentDialog: () => void;
|
||||||
onConfirmPayment: () => void;
|
onConfirmPayment: () => void;
|
||||||
@@ -20,6 +28,11 @@ export function InvoicesWidgetView({
|
|||||||
isModalOpen,
|
isModalOpen,
|
||||||
modalState,
|
modalState,
|
||||||
isPending,
|
isPending,
|
||||||
|
paymentAccountId,
|
||||||
|
onPaymentAccountChange,
|
||||||
|
paymentDate,
|
||||||
|
onPaymentDateChange,
|
||||||
|
paymentAccountOptions,
|
||||||
onOpenPaymentDialog,
|
onOpenPaymentDialog,
|
||||||
onClosePaymentDialog,
|
onClosePaymentDialog,
|
||||||
onConfirmPayment,
|
onConfirmPayment,
|
||||||
@@ -35,6 +48,11 @@ export function InvoicesWidgetView({
|
|||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
modalState={modalState}
|
modalState={modalState}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
|
paymentAccountId={paymentAccountId}
|
||||||
|
onPaymentAccountChange={onPaymentAccountChange}
|
||||||
|
paymentDate={paymentDate}
|
||||||
|
onPaymentDateChange={onPaymentDateChange}
|
||||||
|
paymentAccountOptions={paymentAccountOptions}
|
||||||
onClose={onClosePaymentDialog}
|
onClose={onClosePaymentDialog}
|
||||||
onConfirm={onConfirmPayment}
|
onConfirm={onConfirmPayment}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
|
import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react";
|
||||||
import type { PaymentOverviewTab } from "@/features/dashboard/payments/payment-overview-tabs";
|
|
||||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||||
|
import type { PaymentOverviewTab } from "@/features/dashboard/payments/payment-overview-tabs";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
|
import type {
|
||||||
|
BillPaymentAccountOption,
|
||||||
|
DashboardBill,
|
||||||
|
} from "@/features/dashboard/bills/bills-queries";
|
||||||
import { useBillWidgetController } from "@/features/dashboard/bills/use-bill-widget-controller";
|
import { useBillWidgetController } from "@/features/dashboard/bills/use-bill-widget-controller";
|
||||||
import { BillsWidgetView } from "../bills/bills-widget-view";
|
import { BillsWidgetView } from "../bills/bills-widget-view";
|
||||||
|
|
||||||
type BillWidgetProps = {
|
type BillWidgetProps = {
|
||||||
bills?: DashboardBill[];
|
bills?: DashboardBill[];
|
||||||
|
paymentAccountOptions?: BillPaymentAccountOption[];
|
||||||
|
period?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BillWidget({ bills }: BillWidgetProps) {
|
const EMPTY_OPTIONS: BillPaymentAccountOption[] = [];
|
||||||
|
|
||||||
|
export function BillWidget({
|
||||||
|
bills,
|
||||||
|
paymentAccountOptions = EMPTY_OPTIONS,
|
||||||
|
period,
|
||||||
|
}: BillWidgetProps) {
|
||||||
const {
|
const {
|
||||||
items,
|
items,
|
||||||
selectedBill,
|
selectedBill,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
modalState,
|
modalState,
|
||||||
isPending,
|
isPending,
|
||||||
|
paymentAccountId,
|
||||||
|
setPaymentAccountId,
|
||||||
|
paymentDate,
|
||||||
|
setPaymentDate,
|
||||||
openPaymentDialog,
|
openPaymentDialog,
|
||||||
closePaymentDialog,
|
closePaymentDialog,
|
||||||
confirmPayment,
|
confirmPayment,
|
||||||
@@ -23,10 +38,16 @@ export function BillWidget({ bills }: BillWidgetProps) {
|
|||||||
return (
|
return (
|
||||||
<BillsWidgetView
|
<BillsWidgetView
|
||||||
bills={items}
|
bills={items}
|
||||||
|
period={period}
|
||||||
selectedBill={selectedBill}
|
selectedBill={selectedBill}
|
||||||
isModalOpen={isModalOpen}
|
isModalOpen={isModalOpen}
|
||||||
modalState={modalState}
|
modalState={modalState}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
|
paymentAccountId={paymentAccountId}
|
||||||
|
onPaymentAccountChange={setPaymentAccountId}
|
||||||
|
paymentDate={paymentDate}
|
||||||
|
onPaymentDateChange={setPaymentDate}
|
||||||
|
paymentAccountOptions={paymentAccountOptions}
|
||||||
onOpenPaymentDialog={openPaymentDialog}
|
onOpenPaymentDialog={openPaymentDialog}
|
||||||
onClosePaymentDialog={closePaymentDialog}
|
onClosePaymentDialog={closePaymentDialog}
|
||||||
onConfirmPayment={confirmPayment}
|
onConfirmPayment={confirmPayment}
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { DashboardInvoice } from "@/features/dashboard/invoices/invoices-queries";
|
import type {
|
||||||
|
DashboardInvoice,
|
||||||
|
InvoicePaymentAccountOption,
|
||||||
|
} from "@/features/dashboard/invoices/invoices-queries";
|
||||||
import { useInvoicesWidgetController } from "@/features/dashboard/invoices/use-invoices-widget-controller";
|
import { useInvoicesWidgetController } from "@/features/dashboard/invoices/use-invoices-widget-controller";
|
||||||
import { InvoicesWidgetView } from "../invoices/invoices-widget-view";
|
import { InvoicesWidgetView } from "../invoices/invoices-widget-view";
|
||||||
|
|
||||||
type InvoicesWidgetProps = {
|
type InvoicesWidgetProps = {
|
||||||
invoices: DashboardInvoice[];
|
invoices: DashboardInvoice[];
|
||||||
|
paymentAccountOptions: InvoicePaymentAccountOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
|
export function InvoicesWidget({
|
||||||
|
invoices,
|
||||||
|
paymentAccountOptions,
|
||||||
|
}: InvoicesWidgetProps) {
|
||||||
const {
|
const {
|
||||||
items,
|
items,
|
||||||
selectedInvoice,
|
selectedInvoice,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
modalState,
|
modalState,
|
||||||
isPending,
|
isPending,
|
||||||
|
paymentAccountId,
|
||||||
|
setPaymentAccountId,
|
||||||
|
paymentDate,
|
||||||
|
setPaymentDate,
|
||||||
openPaymentDialog,
|
openPaymentDialog,
|
||||||
closePaymentDialog,
|
closePaymentDialog,
|
||||||
confirmPayment,
|
confirmPayment,
|
||||||
@@ -27,6 +38,11 @@ export function InvoicesWidget({ invoices }: InvoicesWidgetProps) {
|
|||||||
isModalOpen={isModalOpen}
|
isModalOpen={isModalOpen}
|
||||||
modalState={modalState}
|
modalState={modalState}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
|
paymentAccountId={paymentAccountId}
|
||||||
|
onPaymentAccountChange={setPaymentAccountId}
|
||||||
|
paymentDate={paymentDate}
|
||||||
|
onPaymentDateChange={setPaymentDate}
|
||||||
|
paymentAccountOptions={paymentAccountOptions}
|
||||||
onOpenPaymentDialog={openPaymentDialog}
|
onOpenPaymentDialog={openPaymentDialog}
|
||||||
onClosePaymentDialog={closePaymentDialog}
|
onClosePaymentDialog={closePaymentDialog}
|
||||||
onConfirmPayment={confirmPayment}
|
onConfirmPayment={confirmPayment}
|
||||||
|
|||||||
28
src/features/dashboard/extract-logo-names.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { DashboardData } from "./fetch-dashboard-data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coleta todos os nomes de estabelecimentos exibidos nos widgets do
|
||||||
|
* dashboard que renderizam `<EstablishmentLogo />`. Usado para
|
||||||
|
* pré-resolver os mapeamentos Logo.dev no servidor.
|
||||||
|
*/
|
||||||
|
export function extractDashboardLogoNames(data: DashboardData): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
|
||||||
|
for (const bill of data.billsSnapshot.bills) names.push(bill.name);
|
||||||
|
for (const expense of data.recurringExpensesData.expenses)
|
||||||
|
names.push(expense.name);
|
||||||
|
for (const expense of data.installmentExpensesData.expenses)
|
||||||
|
names.push(expense.name);
|
||||||
|
for (const establishment of data.topEstablishmentsData.establishments)
|
||||||
|
names.push(establishment.name);
|
||||||
|
for (const expense of data.topExpensesAll.expenses) names.push(expense.name);
|
||||||
|
for (const expense of data.topExpensesCardOnly.expenses)
|
||||||
|
names.push(expense.name);
|
||||||
|
for (const transactions of Object.values(
|
||||||
|
data.purchasesByCategoryData.transactionsByCategory,
|
||||||
|
)) {
|
||||||
|
for (const transaction of transactions) names.push(transaction.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { and, eq, ilike, inArray, isNotNull, sql } from "drizzle-orm";
|
import { and, eq, ilike, inArray, isNotNull, sql } from "drizzle-orm";
|
||||||
import { cards, invoices, payers, transactions } from "@/db/schema";
|
import {
|
||||||
|
cards,
|
||||||
|
financialAccounts,
|
||||||
|
invoices,
|
||||||
|
payers,
|
||||||
|
transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +37,13 @@ type RawDashboardInvoice = {
|
|||||||
totalAmount: string | number | null;
|
totalAmount: string | number | null;
|
||||||
transactionCount: string | number | null;
|
transactionCount: string | number | null;
|
||||||
invoiceCreatedAt: Date | null;
|
invoiceCreatedAt: Date | null;
|
||||||
|
cardAccountId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InvoicePaymentAccountOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
logo: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RawInvoiceBreakdownRow = {
|
type RawInvoiceBreakdownRow = {
|
||||||
@@ -63,11 +76,13 @@ export type DashboardInvoice = {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
pagadorBreakdown: InvoicePagadorBreakdown[];
|
pagadorBreakdown: InvoicePagadorBreakdown[];
|
||||||
|
defaultPaymentAccountId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DashboardInvoicesSnapshot = {
|
type DashboardInvoicesSnapshot = {
|
||||||
invoices: DashboardInvoice[];
|
invoices: DashboardInvoice[];
|
||||||
totalPending: number;
|
totalPending: number;
|
||||||
|
paymentAccountOptions: InvoicePaymentAccountOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
|
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
|
||||||
@@ -148,7 +163,7 @@ export async function fetchDashboardInvoices(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rows, breakdownRows] = (await Promise.all([
|
const [rows, breakdownRows, accountRows] = (await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
invoiceId: invoices.id,
|
invoiceId: invoices.id,
|
||||||
@@ -159,6 +174,7 @@ export async function fetchDashboardInvoices(
|
|||||||
period: invoices.period,
|
period: invoices.period,
|
||||||
paymentStatus: invoices.paymentStatus,
|
paymentStatus: invoices.paymentStatus,
|
||||||
invoiceCreatedAt: invoices.createdAt,
|
invoiceCreatedAt: invoices.createdAt,
|
||||||
|
cardAccountId: cards.accountId,
|
||||||
totalAmount: sql<number | null>`
|
totalAmount: sql<number | null>`
|
||||||
COALESCE(SUM(${transactions.amount}), 0)
|
COALESCE(SUM(${transactions.amount}), 0)
|
||||||
`,
|
`,
|
||||||
@@ -190,6 +206,7 @@ export async function fetchDashboardInvoices(
|
|||||||
cards.status,
|
cards.status,
|
||||||
cards.logo,
|
cards.logo,
|
||||||
cards.dueDay,
|
cards.dueDay,
|
||||||
|
cards.accountId,
|
||||||
invoices.period,
|
invoices.period,
|
||||||
invoices.paymentStatus,
|
invoices.paymentStatus,
|
||||||
),
|
),
|
||||||
@@ -218,7 +235,29 @@ export async function fetchDashboardInvoices(
|
|||||||
payers.name,
|
payers.name,
|
||||||
payers.avatarUrl,
|
payers.avatarUrl,
|
||||||
),
|
),
|
||||||
])) as [RawDashboardInvoice[], RawInvoiceBreakdownRow[]];
|
db
|
||||||
|
.select({
|
||||||
|
id: financialAccounts.id,
|
||||||
|
name: financialAccounts.name,
|
||||||
|
logo: financialAccounts.logo,
|
||||||
|
})
|
||||||
|
.from(financialAccounts)
|
||||||
|
.where(eq(financialAccounts.userId, userId)),
|
||||||
|
])) as [
|
||||||
|
RawDashboardInvoice[],
|
||||||
|
RawInvoiceBreakdownRow[],
|
||||||
|
{ id: string; name: string; logo: string | null }[],
|
||||||
|
];
|
||||||
|
|
||||||
|
const paymentAccountOptions: InvoicePaymentAccountOption[] = accountRows
|
||||||
|
.map((account) => ({
|
||||||
|
value: account.id,
|
||||||
|
label: account.name,
|
||||||
|
logo: account.logo,
|
||||||
|
}))
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" }),
|
||||||
|
);
|
||||||
|
|
||||||
const groupedBreakdown = new Map<
|
const groupedBreakdown = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -336,6 +375,7 @@ export async function fetchDashboardInvoices(
|
|||||||
pagadorBreakdown: (
|
pagadorBreakdown: (
|
||||||
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
|
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
|
||||||
).sort((a, b) => b.amount - a.amount),
|
).sort((a, b) => b.amount - a.amount),
|
||||||
|
defaultPaymentAccountId: row.cardAccountId ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,5 +439,6 @@ export async function fetchDashboardInvoices(
|
|||||||
return {
|
return {
|
||||||
invoices: invoiceList,
|
invoices: invoiceList,
|
||||||
totalPending,
|
totalPending,
|
||||||
|
paymentAccountOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
getCurrentDateString,
|
getCurrentDateString,
|
||||||
type InvoiceDialogState,
|
type InvoiceDialogState,
|
||||||
@@ -20,27 +21,62 @@ type InvoicesWidgetController = Omit<
|
|||||||
> & {
|
> & {
|
||||||
selectedInvoice: DashboardInvoice | null;
|
selectedInvoice: DashboardInvoice | null;
|
||||||
modalState: InvoiceDialogState;
|
modalState: InvoiceDialogState;
|
||||||
|
paymentAccountId: string;
|
||||||
|
setPaymentAccountId: (accountId: string) => void;
|
||||||
|
paymentDate: Date;
|
||||||
|
setPaymentDate: (date: Date) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useInvoicesWidgetController(
|
export function useInvoicesWidgetController(
|
||||||
invoices: DashboardInvoice[],
|
invoices: DashboardInvoice[],
|
||||||
): InvoicesWidgetController {
|
): InvoicesWidgetController {
|
||||||
|
const [paymentAccountId, setPaymentAccountId] = useState<string>("");
|
||||||
|
const [paymentDate, setPaymentDate] = useState<Date>(() => new Date());
|
||||||
|
|
||||||
|
const paymentAccountIdRef = useRef(paymentAccountId);
|
||||||
|
const paymentDateRef = useRef(paymentDate);
|
||||||
|
paymentAccountIdRef.current = paymentAccountId;
|
||||||
|
paymentDateRef.current = paymentDate;
|
||||||
|
|
||||||
const controller = usePaymentDialogController({
|
const controller = usePaymentDialogController({
|
||||||
items: invoices,
|
items: invoices,
|
||||||
getItemId: (invoice) => invoice.id,
|
getItemId: (invoice) => invoice.id,
|
||||||
isItemConfirmed: (invoice) => isInvoicePaid(invoice.paymentStatus),
|
isItemConfirmed: (invoice) => isInvoicePaid(invoice.paymentStatus),
|
||||||
executeConfirm: (invoice) =>
|
executeConfirm: (invoice) => {
|
||||||
updateInvoicePaymentStatusAction({
|
const accountId = paymentAccountIdRef.current || undefined;
|
||||||
|
const date = paymentDateRef.current;
|
||||||
|
const isoDate = date.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
return updateInvoicePaymentStatusAction({
|
||||||
cardId: invoice.cardId,
|
cardId: invoice.cardId,
|
||||||
period: invoice.period,
|
period: invoice.period,
|
||||||
status: INVOICE_PAYMENT_STATUS.PAID,
|
status: INVOICE_PAYMENT_STATUS.PAID,
|
||||||
}),
|
paymentAccountId: accountId,
|
||||||
|
paymentDate: isoDate,
|
||||||
|
});
|
||||||
|
},
|
||||||
applyConfirmedState: (invoice) =>
|
applyConfirmedState: (invoice) =>
|
||||||
markInvoiceAsPaid(invoice, getCurrentDateString()),
|
markInvoiceAsPaid(invoice, getCurrentDateString()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedInvoiceId = controller.selectedItem?.id ?? null;
|
||||||
|
const selectedDefaultAccountId =
|
||||||
|
controller.selectedItem?.defaultPaymentAccountId ?? "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedInvoiceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPaymentAccountId(selectedDefaultAccountId);
|
||||||
|
setPaymentDate(new Date());
|
||||||
|
}, [selectedInvoiceId, selectedDefaultAccountId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...controller,
|
...controller,
|
||||||
selectedInvoice: controller.selectedItem,
|
selectedInvoice: controller.selectedItem,
|
||||||
|
paymentAccountId,
|
||||||
|
setPaymentAccountId,
|
||||||
|
paymentDate,
|
||||||
|
setPaymentDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import type { DashboardBillsSnapshot } from "@/features/dashboard/bills/bills-queries";
|
import type { DashboardBillsSnapshot } from "@/features/dashboard/bills/bills-queries";
|
||||||
|
import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries";
|
||||||
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
import type { InstallmentExpensesData } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||||
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
|
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
|
||||||
import type {
|
import type {
|
||||||
@@ -15,15 +16,16 @@ import type {
|
|||||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||||
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
||||||
import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries";
|
|
||||||
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
|
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
|
||||||
import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters";
|
import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters";
|
||||||
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),
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ export const widgetsConfig: WidgetConfig[] = [
|
|||||||
subtitle: "Resumo das faturas do período",
|
subtitle: "Resumo das faturas do período",
|
||||||
icon: <RiBillLine className="size-4" />,
|
icon: <RiBillLine className="size-4" />,
|
||||||
component: ({ data }) => (
|
component: ({ data }) => (
|
||||||
<InvoicesWidget invoices={data.invoicesSnapshot.invoices} />
|
<InvoicesWidget
|
||||||
|
invoices={data.invoicesSnapshot.invoices}
|
||||||
|
paymentAccountOptions={data.invoicesSnapshot.paymentAccountOptions}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -101,7 +104,13 @@ export const widgetsConfig: WidgetConfig[] = [
|
|||||||
title: "Boletos",
|
title: "Boletos",
|
||||||
subtitle: "Controle de boletos do período",
|
subtitle: "Controle de boletos do período",
|
||||||
icon: <RiBarcodeLine className="size-4" />,
|
icon: <RiBarcodeLine className="size-4" />,
|
||||||
component: ({ data }) => <BillWidget bills={data.billsSnapshot.bills} />,
|
component: ({ data, period }) => (
|
||||||
|
<BillWidget
|
||||||
|
bills={data.billsSnapshot.bills}
|
||||||
|
paymentAccountOptions={data.invoicesSnapshot.paymentAccountOptions}
|
||||||
|
period={period}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "payment-status",
|
id: "payment-status",
|
||||||
|
|||||||
@@ -151,7 +151,9 @@ export const InboxCard = memo(function InboxCard({
|
|||||||
|
|
||||||
<CardContent className="min-h-0 flex-1 overflow-hidden py-2">
|
<CardContent className="min-h-0 flex-1 overflow-hidden py-2">
|
||||||
{item.originalTitle && (
|
{item.originalTitle && (
|
||||||
<p className="mb-1 line-clamp-2 text-sm font-medium">{item.originalTitle}</p>
|
<p className="mb-1 line-clamp-2 text-sm font-medium">
|
||||||
|
{item.originalTitle}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground">
|
<p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||||
{item.originalText}
|
{item.originalText}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
|
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { cards, categories, invoices, transactions } from "@/db/schema";
|
import {
|
||||||
import { buildInvoicePaymentNote } from "@/shared/lib/accounts/constants";
|
cards,
|
||||||
|
categories,
|
||||||
|
financialAccounts,
|
||||||
|
invoices,
|
||||||
|
transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import {
|
||||||
|
buildInvoicePaymentNote,
|
||||||
|
INVOICE_ADJUSTMENT_NAME,
|
||||||
|
} from "@/shared/lib/accounts/constants";
|
||||||
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
@@ -14,6 +23,10 @@ import {
|
|||||||
PERIOD_FORMAT_REGEX,
|
PERIOD_FORMAT_REGEX,
|
||||||
} from "@/shared/lib/invoices";
|
} from "@/shared/lib/invoices";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatDecimalForDbRequired,
|
||||||
|
} from "@/shared/utils/currency";
|
||||||
import {
|
import {
|
||||||
getBusinessTodayDate,
|
getBusinessTodayDate,
|
||||||
parseLocalDateString,
|
parseLocalDateString,
|
||||||
@@ -36,6 +49,11 @@ const updateInvoicePaymentStatusSchema = z.object({
|
|||||||
.refine((value) => !value || isValidPaymentDate(value), {
|
.refine((value) => !value || isValidPaymentDate(value), {
|
||||||
message: "Data de pagamento inválida.",
|
message: "Data de pagamento inválida.",
|
||||||
}),
|
}),
|
||||||
|
paymentAccountId: z
|
||||||
|
.string({ message: "Conta inválida." })
|
||||||
|
.uuid("Conta inválida.")
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type UpdateInvoicePaymentStatusInput = z.infer<
|
type UpdateInvoicePaymentStatusInput = z.infer<
|
||||||
@@ -51,9 +69,6 @@ const successMessageByStatus: Record<InvoicePaymentStatus, string> = {
|
|||||||
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
|
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDecimal = (value: number) =>
|
|
||||||
(Math.round(value * 100) / 100).toFixed(2);
|
|
||||||
|
|
||||||
export async function updateInvoicePaymentStatusAction(
|
export async function updateInvoicePaymentStatusAction(
|
||||||
input: UpdateInvoicePaymentStatusInput,
|
input: UpdateInvoicePaymentStatusInput,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
@@ -121,8 +136,25 @@ export async function updateInvoicePaymentStatusAction(
|
|||||||
|
|
||||||
const adminShare = Number(adminShareRow?.total ?? 0);
|
const adminShare = Number(adminShareRow?.total ?? 0);
|
||||||
const adminPayableAmount = Math.abs(Math.min(adminShare, 0));
|
const adminPayableAmount = Math.abs(Math.min(adminShare, 0));
|
||||||
|
const paymentAccountId = data.paymentAccountId ?? card.accountId;
|
||||||
|
|
||||||
|
if (adminPayerId) {
|
||||||
|
if (!paymentAccountId) {
|
||||||
|
throw new Error("Selecione uma conta para pagar a fatura.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentAccount = await tx.query.financialAccounts.findFirst({
|
||||||
|
columns: { id: true },
|
||||||
|
where: and(
|
||||||
|
eq(financialAccounts.id, paymentAccountId),
|
||||||
|
eq(financialAccounts.userId, user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!paymentAccount) {
|
||||||
|
throw new Error("Conta de pagamento não encontrada.");
|
||||||
|
}
|
||||||
|
|
||||||
if (card.accountId && adminPayerId) {
|
|
||||||
const paymentCategory = await tx.query.categories.findFirst({
|
const paymentCategory = await tx.query.categories.findFirst({
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
where: and(
|
where: and(
|
||||||
@@ -131,12 +163,11 @@ export async function updateInvoicePaymentStatusAction(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Usar a data customizada ou a data atual como data de pagamento
|
|
||||||
const invoiceDate = data.paymentDate
|
const invoiceDate = data.paymentDate
|
||||||
? parseLocalDateString(data.paymentDate)
|
? parseLocalDateString(data.paymentDate)
|
||||||
: getBusinessTodayDate();
|
: getBusinessTodayDate();
|
||||||
|
|
||||||
const amount = `-${formatDecimal(adminPayableAmount)}`;
|
const amount = `-${formatDecimalForDbRequired(adminPayableAmount)}`;
|
||||||
const payload = {
|
const payload = {
|
||||||
condition: "À vista",
|
condition: "À vista",
|
||||||
name: `Pagamento fatura - ${card.name}`,
|
name: `Pagamento fatura - ${card.name}`,
|
||||||
@@ -148,7 +179,7 @@ export async function updateInvoicePaymentStatusAction(
|
|||||||
period: data.period,
|
period: data.period,
|
||||||
isSettled: true,
|
isSettled: true,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
accountId: card.accountId,
|
accountId: paymentAccountId,
|
||||||
categoryId: paymentCategory?.id ?? null,
|
categoryId: paymentCategory?.id ?? null,
|
||||||
payerId: adminPayerId,
|
payerId: adminPayerId,
|
||||||
};
|
};
|
||||||
@@ -270,3 +301,123 @@ export async function updatePaymentDateAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adjustInvoiceSchema = z.object({
|
||||||
|
cardId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
|
||||||
|
period: z
|
||||||
|
.string({ message: "Período inválido." })
|
||||||
|
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
|
||||||
|
currentTotal: z.number({ message: "Total atual inválido." }),
|
||||||
|
targetAmount: z
|
||||||
|
.number({ message: "Valor inválido." })
|
||||||
|
.nonnegative("O valor deve ser positivo."),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AdjustInvoiceInput = z.infer<typeof adjustInvoiceSchema>;
|
||||||
|
|
||||||
|
export async function adjustInvoiceAction(
|
||||||
|
input: AdjustInvoiceInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = adjustInvoiceSchema.parse(input);
|
||||||
|
const adminPayerId = await getAdminPayerId(user.id);
|
||||||
|
|
||||||
|
let message = "Ajuste de fatura registrado.";
|
||||||
|
|
||||||
|
await db.transaction(async (tx: typeof db) => {
|
||||||
|
const card = await tx.query.cards.findFirst({
|
||||||
|
columns: { id: true },
|
||||||
|
where: and(eq(cards.id, data.cardId), eq(cards.userId, user.id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
throw new Error("Cartão não encontrado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await tx.query.transactions.findFirst({
|
||||||
|
columns: { id: true, amount: true },
|
||||||
|
where: and(
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
eq(transactions.cardId, data.cardId),
|
||||||
|
eq(transactions.period, data.period),
|
||||||
|
eq(transactions.name, INVOICE_ADJUSTMENT_NAME),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingAmount = Number(existing?.amount ?? 0);
|
||||||
|
const baseTotal = data.currentTotal - existingAmount;
|
||||||
|
const targetTotal = -data.targetAmount;
|
||||||
|
const adjustmentAmount =
|
||||||
|
Math.round((targetTotal - baseTotal) * 100) / 100;
|
||||||
|
|
||||||
|
if (adjustmentAmount === 0) {
|
||||||
|
if (existing) {
|
||||||
|
await tx.delete(transactions).where(eq(transactions.id, existing.id));
|
||||||
|
message = "Ajuste de fatura removido.";
|
||||||
|
} else {
|
||||||
|
message = "Nada a ajustar — o valor já está correto.";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpense = adjustmentAmount < 0;
|
||||||
|
const categoryName = isExpense ? "Outras despesas" : "Outras receitas";
|
||||||
|
|
||||||
|
const category = await tx.query.categories.findFirst({
|
||||||
|
columns: { id: true },
|
||||||
|
where: and(
|
||||||
|
eq(categories.userId, user.id),
|
||||||
|
eq(categories.name, categoryName),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const amount = formatDecimalForDbRequired(adjustmentAmount);
|
||||||
|
|
||||||
|
const note = `O valor era ${formatCurrency(Math.abs(baseTotal))} mas o correto é ${formatCurrency(data.targetAmount)}.`;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
condition: "À vista",
|
||||||
|
name: INVOICE_ADJUSTMENT_NAME,
|
||||||
|
paymentMethod: "Cartão de crédito",
|
||||||
|
note,
|
||||||
|
amount,
|
||||||
|
purchaseDate: getBusinessTodayDate(),
|
||||||
|
transactionType: isExpense
|
||||||
|
? ("Despesa" as const)
|
||||||
|
: ("Receita" as const),
|
||||||
|
period: data.period,
|
||||||
|
userId: user.id,
|
||||||
|
cardId: data.cardId,
|
||||||
|
accountId: null,
|
||||||
|
categoryId: category?.id ?? null,
|
||||||
|
payerId: adminPayerId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await tx
|
||||||
|
.update(transactions)
|
||||||
|
.set(payload)
|
||||||
|
.where(eq(transactions.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(transactions).values(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateForEntity("cards", user.id);
|
||||||
|
|
||||||
|
return { success: true, message };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.issues[0]?.message ?? "Dados inválidos.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Erro inesperado.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiEditLine } from "@remixicon/react";
|
import { RiEditLine, RiEqualizerLine } from "@remixicon/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -10,11 +10,30 @@ import {
|
|||||||
updateInvoicePaymentStatusAction,
|
updateInvoicePaymentStatusAction,
|
||||||
updatePaymentDateAction,
|
updatePaymentDateAction,
|
||||||
} from "@/features/invoices/actions";
|
} from "@/features/invoices/actions";
|
||||||
|
import { AccountCardSelectContent } from "@/features/transactions/components/select-items";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import StatusDot from "@/shared/components/status-dot";
|
import StatusDot from "@/shared/components/status-dot";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
import { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets";
|
import { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets";
|
||||||
import {
|
import {
|
||||||
INVOICE_PAYMENT_STATUS,
|
INVOICE_PAYMENT_STATUS,
|
||||||
@@ -27,8 +46,15 @@ import { resolveLogoSrc } from "@/shared/lib/logo";
|
|||||||
import { formatCurrency } from "@/shared/utils/currency";
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
import { formatDateOnly } from "@/shared/utils/date";
|
import { formatDateOnly } from "@/shared/utils/date";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
import { AdjustInvoiceDialog } from "./adjust-invoice-dialog";
|
||||||
import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
|
import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
|
||||||
|
|
||||||
|
type PaymentAccountOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
logo?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type InvoiceSummaryCardProps = {
|
type InvoiceSummaryCardProps = {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
period: string;
|
period: string;
|
||||||
@@ -42,6 +68,8 @@ type InvoiceSummaryCardProps = {
|
|||||||
limitAmount: number | null;
|
limitAmount: number | null;
|
||||||
invoiceStatus: InvoicePaymentStatus;
|
invoiceStatus: InvoicePaymentStatus;
|
||||||
paymentDate: Date | null;
|
paymentDate: Date | null;
|
||||||
|
defaultPaymentAccountId: string | null;
|
||||||
|
paymentAccountOptions: PaymentAccountOption[];
|
||||||
logo?: string | null;
|
logo?: string | null;
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
};
|
};
|
||||||
@@ -87,6 +115,8 @@ export function InvoiceSummaryCard({
|
|||||||
limitAmount,
|
limitAmount,
|
||||||
invoiceStatus,
|
invoiceStatus,
|
||||||
paymentDate: initialPaymentDate,
|
paymentDate: initialPaymentDate,
|
||||||
|
defaultPaymentAccountId,
|
||||||
|
paymentAccountOptions,
|
||||||
logo,
|
logo,
|
||||||
actions,
|
actions,
|
||||||
}: InvoiceSummaryCardProps) {
|
}: InvoiceSummaryCardProps) {
|
||||||
@@ -95,11 +125,21 @@ export function InvoiceSummaryCard({
|
|||||||
const [paymentDate, setPaymentDate] = useState<Date>(
|
const [paymentDate, setPaymentDate] = useState<Date>(
|
||||||
initialPaymentDate ?? new Date(),
|
initialPaymentDate ?? new Date(),
|
||||||
);
|
);
|
||||||
|
const [paymentAccountId, setPaymentAccountId] = useState<string>(
|
||||||
|
defaultPaymentAccountId ?? paymentAccountOptions[0]?.value ?? "",
|
||||||
|
);
|
||||||
|
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPaymentDate(initialPaymentDate ?? new Date());
|
setPaymentDate(initialPaymentDate ?? new Date());
|
||||||
}, [initialPaymentDate]);
|
}, [initialPaymentDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPaymentAccountId(
|
||||||
|
defaultPaymentAccountId ?? paymentAccountOptions[0]?.value ?? "",
|
||||||
|
);
|
||||||
|
}, [defaultPaymentAccountId, paymentAccountOptions]);
|
||||||
|
|
||||||
const logoPath = resolveLogoSrc(logo);
|
const logoPath = resolveLogoSrc(logo);
|
||||||
const brandAsset = resolveCardBrandAsset(cardBrand);
|
const brandAsset = resolveCardBrandAsset(cardBrand);
|
||||||
const isPaid = invoiceStatus === INVOICE_PAYMENT_STATUS.PAID;
|
const isPaid = invoiceStatus === INVOICE_PAYMENT_STATUS.PAID;
|
||||||
@@ -112,7 +152,7 @@ export function InvoiceSummaryCard({
|
|||||||
? INVOICE_PAYMENT_STATUS.PENDING
|
? INVOICE_PAYMENT_STATUS.PENDING
|
||||||
: INVOICE_PAYMENT_STATUS.PAID;
|
: INVOICE_PAYMENT_STATUS.PAID;
|
||||||
|
|
||||||
const handleAction = () => {
|
const handleAction = (accountId?: string) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await updateInvoicePaymentStatusAction({
|
const result = await updateInvoicePaymentStatusAction({
|
||||||
cardId,
|
cardId,
|
||||||
@@ -122,10 +162,13 @@ export function InvoiceSummaryCard({
|
|||||||
targetStatus === INVOICE_PAYMENT_STATUS.PAID
|
targetStatus === INVOICE_PAYMENT_STATUS.PAID
|
||||||
? paymentDate.toISOString().split("T")[0]
|
? paymentDate.toISOString().split("T")[0]
|
||||||
: undefined,
|
: undefined,
|
||||||
|
paymentAccountId:
|
||||||
|
targetStatus === INVOICE_PAYMENT_STATUS.PAID ? accountId : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
|
setPaymentDialogOpen(false);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,6 +177,15 @@ export function InvoiceSummaryCard({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePaymentConfirm = () => {
|
||||||
|
if (!paymentAccountId) {
|
||||||
|
toast.error("Selecione uma conta para pagar a fatura.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAction(paymentAccountId);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateChange = (newDate: Date) => {
|
const handleDateChange = (newDate: Date) => {
|
||||||
setPaymentDate(newDate);
|
setPaymentDate(newDate);
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -190,13 +242,31 @@ export function InvoiceSummaryCard({
|
|||||||
{/* Linha 2 — valor da fatura (hero) */}
|
{/* Linha 2 — valor da fatura (hero) */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">Valor da fatura</p>
|
<p className="text-sm text-muted-foreground">Valor da fatura</p>
|
||||||
<MoneyValues
|
<div className="flex items-center gap-2">
|
||||||
amount={Math.abs(totalAmount)}
|
<MoneyValues
|
||||||
className={cn(
|
amount={Math.abs(totalAmount)}
|
||||||
"text-3xl tracking-tighter font-semibold",
|
className={cn(
|
||||||
isPaid ? "text-success" : "text-foreground",
|
"text-3xl tracking-tighter font-semibold",
|
||||||
)}
|
isPaid ? "text-success" : "text-foreground",
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
<AdjustInvoiceDialog
|
||||||
|
cardId={cardId}
|
||||||
|
period={period}
|
||||||
|
currentTotal={totalAmount}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Ajustar fatura"
|
||||||
|
>
|
||||||
|
<RiEqualizerLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
|
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
|
||||||
@@ -228,7 +298,7 @@ export function InvoiceSummaryCard({
|
|||||||
</MetaItem>
|
</MetaItem>
|
||||||
|
|
||||||
{typeof limitAmount === "number" ? (
|
{typeof limitAmount === "number" ? (
|
||||||
<MetaItem label="Limite">
|
<MetaItem label="Limite total">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{formatCurrency(limitAmount)}
|
{formatCurrency(limitAmount)}
|
||||||
</span>
|
</span>
|
||||||
@@ -263,16 +333,45 @@ export function InvoiceSummaryCard({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<Button
|
{isPaid ? (
|
||||||
type="button"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
variant={actionVariantByStatus[invoiceStatus]}
|
size="sm"
|
||||||
disabled={isPending}
|
variant={actionVariantByStatus[invoiceStatus]}
|
||||||
onClick={handleAction}
|
disabled={isPending}
|
||||||
className="min-w-32"
|
onClick={() => handleAction()}
|
||||||
>
|
className="min-w-32"
|
||||||
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]}
|
>
|
||||||
</Button>
|
{isPending
|
||||||
|
? "Salvando..."
|
||||||
|
: actionLabelByStatus[invoiceStatus]}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<PayInvoiceDialog
|
||||||
|
open={paymentDialogOpen}
|
||||||
|
onOpenChange={setPaymentDialogOpen}
|
||||||
|
isPending={isPending}
|
||||||
|
paymentDate={paymentDate}
|
||||||
|
onPaymentDateChange={setPaymentDate}
|
||||||
|
accountId={paymentAccountId}
|
||||||
|
onAccountChange={setPaymentAccountId}
|
||||||
|
accountOptions={paymentAccountOptions}
|
||||||
|
onConfirm={handlePaymentConfirm}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={actionVariantByStatus[invoiceStatus]}
|
||||||
|
disabled={isPending}
|
||||||
|
className="min-w-32"
|
||||||
|
>
|
||||||
|
{isPending
|
||||||
|
? "Salvando..."
|
||||||
|
: actionLabelByStatus[invoiceStatus]}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isPaid ? (
|
{isPaid ? (
|
||||||
<EditPaymentDateDialog
|
<EditPaymentDateDialog
|
||||||
trigger={
|
trigger={
|
||||||
@@ -308,3 +407,112 @@ function MetaItem({ label, children }: { label: string; children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PayInvoiceDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
paymentDate: Date;
|
||||||
|
onPaymentDateChange: (date: Date) => void;
|
||||||
|
accountId: string;
|
||||||
|
onAccountChange: (accountId: string) => void;
|
||||||
|
accountOptions: PaymentAccountOption[];
|
||||||
|
onConfirm: () => void;
|
||||||
|
trigger: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PayInvoiceDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
isPending,
|
||||||
|
paymentDate,
|
||||||
|
onPaymentDateChange,
|
||||||
|
accountId,
|
||||||
|
onAccountChange,
|
||||||
|
accountOptions,
|
||||||
|
onConfirm,
|
||||||
|
trigger,
|
||||||
|
}: PayInvoiceDialogProps) {
|
||||||
|
const paymentDateValue = paymentDate.toISOString().split("T")[0] ?? "";
|
||||||
|
const selectedAccount = accountOptions.find(
|
||||||
|
(option) => option.value === accountId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirmar pagamento</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Escolha a conta de origem e a data em que a fatura foi paga.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invoice-payment-account">Conta de pagamento</Label>
|
||||||
|
<Select
|
||||||
|
value={accountId}
|
||||||
|
onValueChange={onAccountChange}
|
||||||
|
disabled={isPending || accountOptions.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="invoice-payment-account" className="w-full">
|
||||||
|
<SelectValue placeholder="Selecione uma conta">
|
||||||
|
{selectedAccount ? (
|
||||||
|
<AccountCardSelectContent
|
||||||
|
label={selectedAccount.label}
|
||||||
|
logo={selectedAccount.logo}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{accountOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<AccountCardSelectContent
|
||||||
|
label={option.label}
|
||||||
|
logo={option.logo}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invoice-payment-date">Data do pagamento</Label>
|
||||||
|
<DatePicker
|
||||||
|
id="invoice-payment-date"
|
||||||
|
value={paymentDateValue}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value) {
|
||||||
|
onPaymentDateChange(new Date(`${value}T00:00:00`));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isPending || accountOptions.length === 0}
|
||||||
|
>
|
||||||
|
{isPending ? "Confirmando..." : "Confirmar pagamento"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from "@/shared/components/ui/sheet";
|
} from "@/shared/components/ui/sheet";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: "#telas", label: "Conheça as telas" },
|
|
||||||
{ href: "#funcionalidades", label: "Funcionalidades" },
|
{ href: "#funcionalidades", label: "Funcionalidades" },
|
||||||
{ href: "#mobile", label: "Mobile" },
|
{ href: "#mobile", label: "Mobile" },
|
||||||
{ href: "#stack", label: "Stack" },
|
{ href: "#stack", label: "Stack" },
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
RiArrowLeftRightLine,
|
|
||||||
RiAtLine,
|
|
||||||
RiBankCard2Line,
|
|
||||||
RiBarChart2Line,
|
|
||||||
RiCalendarEventLine,
|
|
||||||
RiFileDownloadLine,
|
|
||||||
RiSecurePaymentLine,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { landingImages } from "@/features/landing/images";
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "@/shared/components/ui/tabs";
|
|
||||||
|
|
||||||
const { screenshots } = landingImages;
|
|
||||||
|
|
||||||
const sections = [
|
|
||||||
{
|
|
||||||
value: "lancamentos",
|
|
||||||
label: "Lançamentos",
|
|
||||||
icon: RiArrowLeftRightLine,
|
|
||||||
...screenshots.lancamentos,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "pre-lancamentos",
|
|
||||||
label: "Pré-lançamentos",
|
|
||||||
icon: RiAtLine,
|
|
||||||
...screenshots.preLancamentos,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "importacao",
|
|
||||||
label: "Importação",
|
|
||||||
icon: RiFileDownloadLine,
|
|
||||||
...screenshots.importacao,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "orcamentos",
|
|
||||||
label: "Orçamentos",
|
|
||||||
icon: RiBarChart2Line,
|
|
||||||
...screenshots.orcamentos,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "parcelas",
|
|
||||||
label: "Análise de Parcelas",
|
|
||||||
icon: RiSecurePaymentLine,
|
|
||||||
...screenshots.parcelas,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "calendario",
|
|
||||||
label: "Calendário",
|
|
||||||
icon: RiCalendarEventLine,
|
|
||||||
...screenshots.calendario,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "cartoes",
|
|
||||||
label: "Cartões",
|
|
||||||
icon: RiBankCard2Line,
|
|
||||||
...screenshots.cartoes,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ScreenshotTabs() {
|
|
||||||
return (
|
|
||||||
<Tabs defaultValue="lancamentos" className="w-full">
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<TabsList className="w-full h-auto flex-wrap gap-1">
|
|
||||||
{sections.map((s) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={s.value}
|
|
||||||
value={s.value}
|
|
||||||
className="flex-1 gap-1.5 lowercase"
|
|
||||||
>
|
|
||||||
<s.icon className="size-4" />
|
|
||||||
{s.label}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{sections.map((s) => (
|
|
||||||
<TabsContent key={s.value} value={s.value} className="w-full mt-0">
|
|
||||||
<div className="rounded-lg overflow-hidden border bg-card">
|
|
||||||
<div className="flex items-center gap-1.5 px-3 h-8 border-b bg-muted/50">
|
|
||||||
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
|
|
||||||
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
|
|
||||||
<div className="size-2.5 rounded-full bg-muted-foreground/20" />
|
|
||||||
<div className="ml-2 flex-1 max-w-52 h-4 rounded bg-muted-foreground/10" />
|
|
||||||
</div>
|
|
||||||
<Image
|
|
||||||
src={s.light}
|
|
||||||
alt={`Preview ${s.label}`}
|
|
||||||
width={1920}
|
|
||||||
height={1080}
|
|
||||||
className="w-full h-auto dark:hidden"
|
|
||||||
/>
|
|
||||||
<Image
|
|
||||||
src={s.dark}
|
|
||||||
alt={`Preview ${s.label}`}
|
|
||||||
width={1920}
|
|
||||||
height={1080}
|
|
||||||
className="w-full h-auto hidden dark:block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||