Compare commits
64 Commits
v2.3.7
...
51652da4f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51652da4f8 | ||
|
|
7a74f9405e | ||
|
|
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 | ||
|
|
f747405264 | ||
|
|
cbc17c8513 | ||
|
|
c41fafc319 | ||
|
|
0bc3f06b77 | ||
|
|
2f68bcf039 | ||
|
|
41dcd5cec9 | ||
|
|
6391f07eb6 | ||
|
|
ae9dd364c4 | ||
|
|
e005add233 | ||
|
|
6d81ff8b53 | ||
|
|
5d84ae928a | ||
|
|
ba05985725 | ||
|
|
3e80d5995b | ||
|
|
68daae7926 | ||
|
|
9413c470a8 | ||
|
|
ad1b0aa979 | ||
|
|
4d9a1c0a35 | ||
|
|
5635705c56 | ||
|
|
4c97ed569d | ||
|
|
22a88de993 | ||
|
|
9456aa98bc | ||
|
|
21c6a8d9d0 | ||
|
|
c29ffa9a12 | ||
|
|
8875de843b | ||
|
|
679ea752bb | ||
|
|
1161e97d9e | ||
|
|
55d7dedd9a | ||
|
|
ad2752b7b0 | ||
|
|
58db357cde | ||
|
|
99a9ff5512 | ||
|
|
5bcf4f69d3 | ||
|
|
95099c1a94 | ||
|
|
94912f7edc |
14
.env.example
@@ -3,10 +3,10 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# === Database ===
|
# === Database ===
|
||||||
# PostgreSQL local (Docker): use host "db"
|
# Desenvolvimento local (pnpm dev): use host "localhost" (padrão abaixo)
|
||||||
# PostgreSQL local (sem Docker): use host "localhost"
|
# Docker Compose completo: o compose.yml define DATABASE_URL automaticamente com host "db"
|
||||||
# PostgreSQL remoto: use URL completa do provider
|
# PostgreSQL remoto: use URL completa do provider
|
||||||
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db
|
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
||||||
|
|
||||||
# Credenciais do PostgreSQL (apenas para Docker local) - Alterar
|
# Credenciais do PostgreSQL (apenas para Docker local) - Alterar
|
||||||
POSTGRES_USER=openmonetis
|
POSTGRES_USER=openmonetis
|
||||||
@@ -48,11 +48,15 @@ GOOGLE_CLIENT_SECRET=
|
|||||||
# Umami: https://umami.is — self-hosted ou cloud
|
# Umami: https://umami.is — self-hosted ou cloud
|
||||||
UMAMI_URL=
|
UMAMI_URL=
|
||||||
UMAMI_WEBSITE_ID=
|
UMAMI_WEBSITE_ID=
|
||||||
# Domínios rastreados (ex: openmonetis.com) — corresponde ao data-domains do script
|
|
||||||
UMAMI_DOMAINS=
|
UMAMI_DOMAINS=
|
||||||
|
|
||||||
# === AI Providers (Opcional) ===
|
# === AI Providers (Opcional) ===
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY=
|
GOOGLE_GENERATIVE_AI_API_KEY=
|
||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# === Logo.dev (Opcional) ===
|
||||||
|
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev
|
||||||
|
LOGO_DEV_TOKEN=
|
||||||
|
LOGO_DEV_SECRET_KEY=
|
||||||
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/
|
||||||
|
|
||||||
|
|||||||
209
CHANGELOG.md
@@ -5,7 +5,204 @@ 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.1] - 2026-05-04
|
||||||
|
|
||||||
|
Versão de correção pontual focada na exibição do indicador de anexo nas tabelas de lançamentos da fatura do cartão. Em `/cards/[cardId]/invoice`, lançamentos com anexos não mostravam o ícone porque o fetcher dedicado da fatura não calculava o flag `hasAttachments`. A primeira tentativa de adicionar o EXISTS via `extras` na query relacional gerou SQL inválido (Drizzle re-aliasava `transactionAttachments.transactionId` para o alias da tabela externa). A correção definitiva troca o fetcher pela função compartilhada `fetchTransactionsWithRelations` de `features/transactions`, que já implementa o EXISTS corretamente via `select`.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- Ícone de anexo voltou a aparecer na tabela de lançamentos da fatura do cartão (`/cards/[cardId]/invoice`). `fetchCardTransactions` em `features/invoices/queries.ts` agora delega para `fetchTransactionsWithRelations`, garantindo que o flag `hasAttachments` seja preenchido com a mesma EXISTS subquery usada no restante do app.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
Esta versão é quase toda sobre organização e polimento. O código interno do Dashboard foi reestruturado — módulos espalhados pela raiz da feature foram agrupados em subdiretórios coesos e a arquitetura de widgets foi renovada com um novo `widget-registry`. A sidebar lateral foi aposentada em favor de uma navegação concentrada na navbar. A interface passou por um refinamento visual amplo: cards redesenhados, dark mode mais consistente e efeitos decorativos removidos para uma composição mais limpa. As imagens de preview da landing page foram atualizadas. Por fim, a integração com Logo.dev ganhou uma arquitetura mais segura — o token agora é lido apenas no servidor e nunca chega ao cliente. O conceito de "Pagador" foi renomeado para "Pessoa" em toda a interface.
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Dashboard: nova arquitetura de widgets com `widget-registry` — módulos reorganizados em subdiretórios (`bills/`, `invoices/`, `notes/`, `notifications/`, `overview/`, `payments/`, `goals-progress/`, `categories/`)
|
||||||
|
- Dashboard: novos componentes `category-breakdown-chart`, `category-breakdown-list`, `goals-progress-item` e `percentage-change-indicator`
|
||||||
|
- Logo.dev: `server.ts` com `isLogoDevEnabled()` e `buildLogoDevUrl()` server-side; `LogoDevProvider` propaga flag `enabled` para Client Components
|
||||||
|
- Scripts: `mockup` adicionado ao `package.json` (`tsx scripts/mock-data.ts`)
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Nav: sidebar lateral removida — navegação unificada na navbar
|
||||||
|
- UI/Tema: raio de borda global 0.625rem → 0.7rem; ajustes finos em `--card` e `--border` (light e dark)
|
||||||
|
- UI: `DotPattern` removido do layout dashboard, tela de autenticação e landing page
|
||||||
|
- UI: account-card redesenhado com cores de saldo (success/destructive) e tooltip para flags de exclusão
|
||||||
|
- UI: budget-card, card-item e componentes do calendário (day-cell, event-modal) com layout revisado
|
||||||
|
- UI: auth-card-shell simplificado (removido glassmorphism e blob animado)
|
||||||
|
- Landing: imagens de preview atualizadas; `mainFeatures` + `extraFeatures` unificados em grid único; dark mode nos botões de CTA
|
||||||
|
- Navbar: dark mode corrigido no navbar-shell (`dark:bg-card`, `dark:border-b-border`)
|
||||||
|
- Logo.dev: `NEXT_PUBLIC_LOGO_DEV_TOKEN` renomeado para `LOGO_DEV_TOKEN` (agora lido em runtime server-side apenas)
|
||||||
|
- UI: conceito "Pagador/Pagadores" renomeado para **"Pessoa/Pessoas"** em toda a interface — labels, títulos, toasts, mensagens de erro, cabeçalhos de tabela e exportações. Código, rotas (`/payers`) e schema do banco (`pagadores`) permanecem inalterados; a divergência entre UI e código é intencional
|
||||||
|
- Deps: next 16.2.3 → 16.2.4, better-auth 1.6.2 → 1.6.5, ai 6.0.159 → 6.0.168 e outros patches menores
|
||||||
|
- Notas/Tarefas: ícone de tarefa concluída em visualização (card e detalhes) simplificado para `RiCheckLine` verde sem caixa; checkbox no modal de edição usa fundo e borda `success` com ícone `success-foreground` (claro no light, escuro no dark)
|
||||||
|
- Notas/Detalhes: botões do footer reordenados ("Cancelar" à esquerda, "Alterar" primário à direita)
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- Nav: componentes sidebar (`app-sidebar`, `nav-main`, `nav-secondary`, `nav-user`, `nav-link`), `sidebar.tsx` e `use-mobile.ts`
|
||||||
|
- Dashboard: ~25 widgets monolíticos obsoletos (`inbox-widget`, `bills-widget`, `notes-widget`, `payers-widget`, `my-accounts-widget` etc.)
|
||||||
|
- Dashboard: arquivos dispersos na raiz da feature movidos para subdiretórios (arquivos antigos removidos)
|
||||||
|
- CSS: variáveis `--data-7` a `--data-10` removidas do tema
|
||||||
|
- CI: build arg `NEXT_PUBLIC_LOGO_DEV_TOKEN` removido do `Dockerfile` e do workflow `docker-publish.yml` — basta configurar `LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` como variáveis de runtime no host (Coolify, Railway, etc.)
|
||||||
|
|
||||||
|
## [2.4.1] - 2026-04-16
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- UI/Auth: layout animado nas páginas de login e signup com efeito blob (3 círculos coloridos em movimento) e card com glassmorphism; layout compartilhado extraído para `app/(auth)/layout.tsx` eliminando duplicação (PR #42)
|
||||||
|
- DB: 17 índices em foreign keys — evita sequential scans em deletes nas tabelas pai. Impacto maior nas FKs de `lancamentos` (conta_id, categoria_id, antecipacao_id), onde deletes em `categorias` antes provocavam full scan na tabela de lançamentos
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- UI/Navbar: labels capitalizados (Lançamentos, Categorias, Contas) em vez de caixa baixa — melhora legibilidade (PR #42)
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- DB: 7 índices sem uso — `tokens_api_user_id_idx`, `cartoes_user_id_status_idx`, `contas_user_id_status_idx`, `pagadores_user_id_status_idx`, `pagadores_user_id_role_idx`, `dashboard_notification_states_user_id_archived_idx`, `antecipacoes_parcelas_series_id_idx` (0 scans em 187 dias de estatísticas)
|
||||||
|
- UI/Settings: tab de Integrações órfã removida (não tinha `TabsContent` correspondente)
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Docker: container do PostgreSQL falhava ao iniciar em instalações existentes após atualização da imagem `postgres:18-alpine` — entrypoint passou a recusar dados no caminho legado `/var/lib/postgresql/data`. Adicionada variável `PGDATA` no `docker-compose.yml` para fixar o caminho e preservar dados de quem já tinha o volume populado (resolve #41)
|
||||||
|
|
||||||
|
## [2.4.0] - 2026-04-13
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Estabelecimentos: integração com Logo.dev — logos automáticos de marcas exibidos na coluna de estabelecimentos nos lançamentos
|
||||||
|
- Estabelecimentos: picker de logo por estabelecimento — clique no avatar para buscar e fixar um domínio Logo.dev específico (salvo por usuário no banco)
|
||||||
|
- API: rotas `/api/logo/search` e `/api/logo/mapping` — proxy seguro para Logo.dev Brand Search API (secret key server-side) e consulta de mapeamentos salvos
|
||||||
|
- Schema: tabela `establishment_logos` com PK composta `(user_id, name_key)` para persistir preferências de logo por usuário
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Dev: `.env.example` usava host `db` no `DATABASE_URL`, causando erro `EAI_AGAIN` ao rodar `pnpm dev` localmente — corrigido para `localhost`
|
||||||
|
|
||||||
|
### Documentação
|
||||||
|
|
||||||
|
- README: tabela comparativa entre Perfil 1 (Usar) e Perfil 2 (Desenvolver) com diferenças de setup, `DATABASE_URL` e instruções de atualização
|
||||||
|
- README: seção "Variáveis de Ambiente" esclarecida — distingue contexto Docker (Perfil 1) de desenvolvimento local (Perfil 2)
|
||||||
|
- Logo.dev: crie uma conta em logo.dev para obter as chaves `NEXT_PUBLIC_LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` — plano gratuito inclui 500.000 requisições/mês
|
||||||
|
|
||||||
|
## [2.3.8] - 2026-04-12
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Docker: `docker-compose.yml` refatorado — removidos profiles, build e dependência de arquivo externo; compose agora é standalone (basta `curl` + `docker compose up -d`)
|
||||||
|
- Docker: `docker-entrypoint.sh` simplificado — extensão `pgcrypto` criada via Node.js antes das migrations; loop de retry reescrito; removido hack `@localhost → @db`
|
||||||
|
- Docker: scripts reduzidos de 10 para 5 — `docker:up`, `docker:db`, `docker:down`, `docker:logs`, `docker:update`
|
||||||
|
- Docs: README reestruturado em dois perfis claros — **Usar** (só Docker) e **Desenvolver** (hot-reload)
|
||||||
|
|
||||||
## [2.3.7] - 2026-04-11
|
## [2.3.7] - 2026-04-11
|
||||||
|
|
||||||
@@ -15,6 +212,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
- Lançamentos: filtro por status de pagamento (somente pagos / somente não pagos) e filtro por presença de anexo
|
- Lançamentos: filtro por status de pagamento (somente pagos / somente não pagos) e filtro por presença de anexo
|
||||||
- Lançamentos: indicador visual no status de liquidação para lançamentos de cartão de crédito com fatura paga — exibe ícone verde com tooltip explicativo
|
- Lançamentos: indicador visual no status de liquidação para lançamentos de cartão de crédito com fatura paga — exibe ícone verde com tooltip explicativo
|
||||||
- Scripts: `scripts/install-deps.sh` — script de preparação para servidores Ubuntu 24.04 limpos (instala Docker, Node.js 22, pnpm via Homebrew)
|
- Scripts: `scripts/install-deps.sh` — script de preparação para servidores Ubuntu 24.04 limpos (instala Docker, Node.js 22, pnpm via Homebrew)
|
||||||
|
- Docker: variáveis `PUBLIC_DOMAIN`, `UMAMI_URL`, `UMAMI_WEBSITE_ID` e `UMAMI_DOMAINS` passadas ao container da aplicação no `docker-compose.yml`
|
||||||
|
|
||||||
### Alterado
|
### Alterado
|
||||||
|
|
||||||
@@ -32,6 +230,15 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
- S3: corrigido `Error: Region is missing` ao usar o app sem S3 configurado — `S3_REGION` vazio (string vazia) não era tratado pelo operador `??`; substituído por `||` em todo o `s3-client.ts`
|
- S3: corrigido `Error: Region is missing` ao usar o app sem S3 configurado — `S3_REGION` vazio (string vazia) não era tratado pelo operador `??`; substituído por `||` em todo o `s3-client.ts`
|
||||||
- i18n: corrigidas mensagens de erro que exibiam "Payer" em inglês em vez de "Pagador"
|
- i18n: corrigidas mensagens de erro que exibiam "Payer" em inglês em vez de "Pagador"
|
||||||
- Logos: corrigido modal seletor de logos de cartões e contas para renderizar miniaturas sem avisos de proporção
|
- Logos: corrigido modal seletor de logos de cartões e contas para renderizar miniaturas sem avisos de proporção
|
||||||
|
- Scripts: `install-deps.sh` — spinner travava o script por `wait` retornar código não-zero com `set -e` ativo; corrigido com `|| true`
|
||||||
|
- Scripts: `install-deps.sh` — prompt interativo do corepack suprimido com `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`
|
||||||
|
- Scripts: `install-deps.sh` — PATH do Homebrew não estava configurado na seção de resumo
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- Scripts: removidos arquivos órfãos `scripts/dev.ts` e `scripts/setup-env.sh` (substituídos pelo `setup.mjs`)
|
||||||
|
- Docker: `docker-compose.yml` agora funciona sem arquivo `.env` — `DATABASE_URL` tem valor padrão com credenciais de desenvolvimento
|
||||||
|
- Docker: `docker-entrypoint.sh` converte automaticamente `@localhost:` para `@db:` na `DATABASE_URL` ao iniciar o container, eliminando a necessidade de usar hosts diferentes no `.env` para desenvolvimento local e Docker
|
||||||
|
|
||||||
## [2.3.6] - 2026-04-09
|
## [2.3.6] - 2026-04-09
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
||||||
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
||||||
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
||||||
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json` e `readme.md` (Badges do README.md).
|
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json` e `readme.md` (Badges do README.md). Cada versão deve ter um parágrafo introdutório em linguagem humana logo abaixo do cabeçalho `## [x.y.z]`, antes das seções `### Adicionado/Alterado/Removido` — descrevendo em prosa o que a versão representa (ex: "Esta versão foca em polimento visual e reorganização interna...").
|
||||||
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
||||||
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
||||||
9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.
|
9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.
|
||||||
@@ -217,7 +217,9 @@ Layouts, `loading.tsx` e metadata continuam em `src/app/`.
|
|||||||
| `contas` | `accounts` |
|
| `contas` | `accounts` |
|
||||||
| `categorias` | `categories` |
|
| `categorias` | `categories` |
|
||||||
| `orcamentos` | `budgets` |
|
| `orcamentos` | `budgets` |
|
||||||
| `pagadores` | `payers` |
|
| `pessoas` | `payers` |
|
||||||
|
|
||||||
|
> **Nota:** o conceito de "pagador" foi renomeado para **"pessoa"** na UI (labels, toasts, textos visíveis ao usuário). O código, rotas e schema continuam usando o termo original em inglês (`payer`, `payerId`, `adminPayerId`) e em português interno (`pagador` como variável). Não renomear esses identificadores — a divergência entre UI e código é intencional e documentada.
|
||||||
| `anotacoes` | `notes` |
|
| `anotacoes` | `notes` |
|
||||||
| `calendario` | `calendar` |
|
| `calendario` | `calendar` |
|
||||||
| `ajustes` | `settings` |
|
| `ajustes` | `settings` |
|
||||||
|
|||||||
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
|
||||||
@@ -40,6 +40,10 @@ COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Nota: a integração Logo.dev não precisa mais de build args. O token
|
||||||
|
# `LOGO_DEV_TOKEN` é lido em runtime no servidor — basta configurá-lo no
|
||||||
|
# host (Coolify, Railway, etc.) junto com `LOGO_DEV_SECRET_KEY`.
|
||||||
|
|
||||||
# Build da aplicação Next.js
|
# Build da aplicação Next.js
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
@@ -105,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"]
|
||||||
|
|||||||
303
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>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -32,9 +28,9 @@
|
|||||||
## 📖 Índice
|
## 📖 Índice
|
||||||
|
|
||||||
- [Sobre o Projeto](#-sobre-o-projeto)
|
- [Sobre o Projeto](#-sobre-o-projeto)
|
||||||
- [Instalação via Script](#-instalação-via-script)
|
- [Como rodar o OpenMonetis](#-como-rodar-o-openmonetis)
|
||||||
- [Preparar o servidor (Ubuntu 24.04)](#-preparar-o-servidor-ubuntu-2404)
|
- [Perfil 1 — Usar](#perfil-1--usar-self-hosting)
|
||||||
- [Início Rápido (manual)](#-início-rápido)
|
- [Perfil 2 — Desenvolver](#perfil-2--desenvolver)
|
||||||
- [Scripts Disponíveis](#-scripts-disponíveis)
|
- [Scripts Disponíveis](#-scripts-disponíveis)
|
||||||
- [Docker](#-docker)
|
- [Docker](#-docker)
|
||||||
- [Backup](#-backup)
|
- [Backup](#-backup)
|
||||||
@@ -103,107 +99,119 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚡ Instalação via Script
|
## 🚀 Como rodar o OpenMonetis
|
||||||
|
|
||||||
A forma mais rápida de instalar. O script verifica dependências, configura o `.env` interativamente e sobe o banco automaticamente.
|
Escolha o perfil que corresponde ao seu objetivo:
|
||||||
|
|
||||||
### 🖥️ Preparar o servidor (Ubuntu 24.04)
|
| | Perfil 1 — Usar | Perfil 2 — Desenvolver |
|
||||||
|
|---|---|---|
|
||||||
|
| **Objetivo** | Rodar o app pronto | Modificar o código |
|
||||||
|
| **Clonar repositório** | Não | Sim |
|
||||||
|
| **Node.js / pnpm** | Não | Sim (Node 22+) |
|
||||||
|
| **Docker** | Sim | Sim |
|
||||||
|
| **Como iniciar** | `docker compose up -d` | `pnpm docker:db` + `pnpm dev` |
|
||||||
|
| **App roda em** | Container Docker | Host local (hot-reload) |
|
||||||
|
| **Banco roda em** | Container Docker | Container Docker |
|
||||||
|
| **`DATABASE_URL` (host)** | `db` (automático pelo compose) | `localhost` |
|
||||||
|
| **Banco remoto (Supabase, Neon...)** | Sim (`docker compose up -d app`) | Sim (ajustar `DATABASE_URL`) |
|
||||||
|
| **Como atualizar** | `pnpm docker:update` | `git pull` + `pnpm install` + `pnpm db:push` |
|
||||||
|
| **Indicado para** | Self-hosting, VPS, servidor | Contribuidores, customizações |
|
||||||
|
|
||||||
Se você está num **servidor Ubuntu limpo** (VPS, Oracle Cloud, DigitalOcean...) sem Node.js, Docker ou pnpm instalados, use o script de preparação antes de continuar.
|
---
|
||||||
|
|
||||||
> ⚠️ **Testado apenas em Ubuntu Server 24.04 LTS.** Em outras distribuições ou versões é necessário testar ou ajustar o script.
|
### Perfil 1 — Usar (self-hosting)
|
||||||
|
|
||||||
|
Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar Node.js** — apenas Docker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Baixe o compose
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||||
|
|
||||||
|
# 2. Suba tudo
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Acesse em: `http://localhost:3000`
|
||||||
|
|
||||||
|
O banco sobe com credenciais padrão. Para personalizar (senha, Google OAuth, e-mail, IA...), crie um `.env` na mesma pasta **antes** de subir:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env mínimo recomendado para produção
|
||||||
|
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
|
||||||
|
BETTER_AUTH_URL=https://seu-dominio.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Veja todas as variáveis disponíveis em [Variáveis de Ambiente](#-variáveis-de-ambiente).
|
||||||
|
|
||||||
|
**Banco remoto (Supabase, Neon, Railway...):** defina `DATABASE_URL` no `.env` e suba só o app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d app
|
||||||
|
```
|
||||||
|
|
||||||
|
**Não tem Docker instalado?** Em servidores Ubuntu 24.04 limpos, use o script de instalação:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
|
||||||
sudo sh install-deps.sh
|
sudo sh install-deps.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
O script instala (e pula o que já estiver presente):
|
> Ao final, faça **logout e login** para as permissões do grupo `docker` terem efeito.
|
||||||
|
|
||||||
| Ferramenta | Como instala |
|
#### Atualizando (Perfil 1)
|
||||||
|---|---|
|
|
||||||
| `git`, `curl`, `ca-certificates` | apt |
|
|
||||||
| Docker Engine + Docker Compose | Repositório oficial do Docker |
|
|
||||||
| Homebrew | Script oficial (como usuário não-root) |
|
|
||||||
| Node.js 22 | Via Homebrew |
|
|
||||||
| pnpm | Via corepack |
|
|
||||||
|
|
||||||
Após a conclusão, adiciona o usuário atual ao grupo `docker` — faça logout/login para ativar. Em seguida, prossiga com o `setup.mjs` abaixo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Pré-requisito:** Node.js 22+
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Mac / Linux / WSL
|
pnpm docker:update
|
||||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs -o setup.mjs && node setup.mjs
|
# ou equivalente:
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
# Windows (PowerShell)
|
|
||||||
curl -o setup.mjs https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs ; node setup.mjs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
O script irá:
|
O schema do banco é aplicado automaticamente no startup — nenhum passo extra necessário.
|
||||||
- Verificar Node, pnpm, Git e Docker
|
|
||||||
- Perguntar se quer banco local (Docker) ou remoto (Supabase, Neon, etc.)
|
|
||||||
- Gerar o `BETTER_AUTH_SECRET` automaticamente
|
|
||||||
- Configurar opcionais: Google OAuth, e-mail, IA, domínio público
|
|
||||||
- Clonar o repositório, instalar dependências e aplicar o schema
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Início Rápido (manual)
|
### Perfil 2 — Desenvolver
|
||||||
|
|
||||||
### Pré-requisitos
|
Quer modificar o código com hot-reload. O banco roda no Docker, o app roda direto no seu servidor.
|
||||||
|
|
||||||
- Node.js 22+ e pnpm
|
**Requisitos:** Docker + Node.js 22+ + pnpm
|
||||||
- Docker e Docker Compose
|
|
||||||
|
|
||||||
### Passo a Passo
|
```bash
|
||||||
|
# 1. Clone o repositório
|
||||||
|
git clone https://github.com/felipegcoutinho/openmonetis.git
|
||||||
|
cd openmonetis
|
||||||
|
|
||||||
1. **Clone e instale**
|
# 2. Instale as dependências
|
||||||
|
pnpm install
|
||||||
|
|
||||||
```bash
|
# 3. Configure o ambiente
|
||||||
git clone https://github.com/felipegcoutinho/openmonetis.git
|
cp .env.example .env
|
||||||
cd openmonetis
|
# O DATABASE_URL já vem com host "localhost" (correto para dev local).
|
||||||
pnpm install
|
# Edite o .env com suas configurações (BETTER_AUTH_SECRET, etc.)
|
||||||
```
|
|
||||||
|
|
||||||
2. **Configure o `.env`**
|
# 4. Suba o banco
|
||||||
|
pnpm docker:db
|
||||||
|
|
||||||
```bash
|
# 5. Aplique o schema no banco (apenas no primeiro setup)
|
||||||
cp .env.example .env
|
pnpm db:push
|
||||||
```
|
|
||||||
|
|
||||||
Edite o `.env` com suas credenciais. O principal é o `DATABASE_URL` e o `BETTER_AUTH_SECRET`:
|
# 6. Inicie o app com hot-reload
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
```env
|
Acesse em: `http://localhost:3000`
|
||||||
# Banco local (Docker): use host "localhost"
|
|
||||||
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
|
||||||
|
|
||||||
# Banco remoto (Supabase, Neon, etc): use a URL completa do provider
|
Toda vez que salvar um arquivo, o app atualiza automaticamente sem precisar reiniciar.
|
||||||
# DATABASE_URL=postgresql://user:password@host:5432/database?sslmode=require
|
|
||||||
|
|
||||||
BETTER_AUTH_SECRET=seu-secret-aqui # gere com: openssl rand -base64 32
|
#### Atualizando (Perfil 2)
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Suba o banco de dados** (pule se estiver usando banco remoto)
|
```bash
|
||||||
|
git pull
|
||||||
|
pnpm install # instala dependências novas, se houver
|
||||||
|
pnpm db:push # aplica mudanças de schema, se houver
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
O `pnpm dev` já em execução detecta as mudanças de código automaticamente — não precisa reiniciar.
|
||||||
docker compose up db -d
|
|
||||||
pnpm db:extensions
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Execute as migrations e inicie**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm db:push
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Acesse `http://localhost:3000`
|
|
||||||
|
|
||||||
> **Docker completo** (app + banco em containers): use `pnpm docker:up:local` ao invés dos passos 3-4.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -225,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)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -238,78 +245,48 @@ pnpm backup # Backup completo do banco (ver seção Backup)
|
|||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm docker:up:local # Sobe app + banco PostgreSQL juntos (imagem do Hub)
|
pnpm docker:up # Sobe app (Docker Hub) + banco em background
|
||||||
pnpm docker:up # Sobe apenas o app com build local
|
pnpm docker:db # Sobe apenas o banco em background (usar com pnpm dev)
|
||||||
pnpm docker:up:d # Sobe apenas o app com build local em background
|
pnpm docker:down # Para e remove os containers
|
||||||
pnpm docker:up:db # Sobe apenas o banco em background
|
pnpm docker:logs # Logs em tempo real (todos os containers)
|
||||||
pnpm docker:down # Para e remove os containers
|
pnpm docker:update # Atualiza para a imagem mais recente do Hub e reinicia
|
||||||
pnpm docker:down:volumes # Para containers e remove volumes (⚠️ apaga dados!)
|
|
||||||
pnpm docker:logs # Logs em tempo real (todos os containers)
|
|
||||||
pnpm docker:logs:app # Logs do container da aplicação
|
|
||||||
pnpm docker:logs:db # Logs do container do banco
|
|
||||||
pnpm docker:restart # Reinicia todos os containers
|
|
||||||
pnpm docker:rebuild # Rebuild completo forçando recriação
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐳 Docker
|
## 🐳 Docker
|
||||||
|
|
||||||
O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root.
|
O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root. Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
|
||||||
|
|
||||||
Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
|
### Self-hosting (recomendado)
|
||||||
|
|
||||||
### Modos de uso
|
Baixe apenas o `docker-compose.yml` e suba tudo — sem clonar o repositório, sem instalar dependências:
|
||||||
|
|
||||||
**Modo 1 — App + banco local (recomendado para self-hosting)**
|
|
||||||
|
|
||||||
Puxa a imagem pronta do Docker Hub e sobe app + banco juntos. Não precisa de Node.js instalado.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Baixar o docker-compose.yml
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||||
|
docker compose up -d
|
||||||
# 2. Criar o .env (copie o .env.example como referência)
|
|
||||||
# DATABASE_URL=postgresql://openmonetis:SUA_SENHA@db:5432/openmonetis_db
|
|
||||||
# POSTGRES_PASSWORD=SUA_SENHA
|
|
||||||
# BETTER_AUTH_SECRET=string-longa-aleatoria
|
|
||||||
# BETTER_AUTH_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# 3. Subir
|
|
||||||
docker compose --profile local up
|
|
||||||
# ou, se tiver o projeto clonado:
|
|
||||||
pnpm docker:up:local
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Modo 2 — App com banco remoto**
|
As credenciais padrão do banco já estão configuradas. Para personalizar (senhas, opcionais), crie um `.env` na mesma pasta antes de subir — veja [Variáveis de Ambiente](#-variáveis-de-ambiente).
|
||||||
|
|
||||||
Use quando o banco está em um provider externo (Supabase, Neon, Railway...).
|
### Banco remoto (Supabase, Neon, Railway...)
|
||||||
|
|
||||||
|
Suba apenas o app e aponte para o banco externo via `DATABASE_URL` no `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# DATABASE_URL deve apontar para o banco remoto no .env
|
docker compose up -d app
|
||||||
docker compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
**Modo 3 — Build local (desenvolvimento)**
|
|
||||||
|
|
||||||
Builda a imagem localmente a partir do código-fonte.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm docker:up # app apenas (banco separado)
|
|
||||||
pnpm docker:up:db # sobe o banco em background
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Comandos úteis
|
### Comandos úteis
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
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
|
||||||
|
|
||||||
```env
|
```env
|
||||||
APP_PORT=3001 # Padrão: 3000
|
APP_PORT=3001 # Padrão: 3000
|
||||||
@@ -332,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
|
||||||
|
|
||||||
@@ -368,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
|
||||||
@@ -404,13 +384,53 @@ S3_BUCKET=
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🏷️ Logos de Estabelecimentos (Logo.dev)
|
||||||
|
|
||||||
|
O app exibe logos automáticos de marcas na coluna de estabelecimentos nos lançamentos. A integração usa a [Logo.dev](https://www.logo.dev) e é opcional — sem ela, o app exibe as iniciais coloridas normalmente.
|
||||||
|
|
||||||
|
### Variáveis
|
||||||
|
|
||||||
|
```env
|
||||||
|
LOGO_DEV_TOKEN=pk_... # token público (obrigatório para exibir logos)
|
||||||
|
LOGO_DEV_SECRET_KEY=sk_... # chave secreta (obrigatório para o picker de busca)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Atualizando da v2.4.1 ou anterior:** a variável foi renomeada de `NEXT_PUBLIC_LOGO_DEV_TOKEN` para `LOGO_DEV_TOKEN`. Renomeie no seu `.env` (ou nas variáveis do Coolify/host) e remova o secret homônimo do GitHub Actions — ele não é mais usado. Não há outra etapa de migração.
|
||||||
|
|
||||||
|
### Como configurar
|
||||||
|
|
||||||
|
Ambas as variáveis são lidas em **runtime** pelo servidor Next.js. Não há mais nenhuma etapa no CI nem `--build-arg` no Docker.
|
||||||
|
|
||||||
|
**Self-hosted via Docker Hub (Coolify, Railway, etc.):**
|
||||||
|
|
||||||
|
1. Adicione `LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` nas variáveis de ambiente do host
|
||||||
|
2. Reinicie o container — pronto
|
||||||
|
|
||||||
|
**Desenvolvimento local:**
|
||||||
|
|
||||||
|
Adicione as duas no `.env` e rode `pnpm dev`.
|
||||||
|
|
||||||
|
### Como usar
|
||||||
|
|
||||||
|
Após configurado, passe o mouse sobre o avatar de qualquer estabelecimento nos lançamentos — um ícone de lápis aparece. Clique para abrir o picker, busque pelo nome da marca e selecione o logo desejado. O mapeamento fica salvo por usuário no banco.
|
||||||
|
|
||||||
|
### Arquitetura
|
||||||
|
|
||||||
|
O token **nunca chega ao cliente**. O servidor constrói a URL `https://img.logo.dev/{domain}?token=...` nos endpoints `/api/logo/mapping` e `/api/logo/search`, e o cliente apenas consome a URL pronta. Um Context Provider (`LogoDevProvider`) propaga a flag `enabled` para os componentes que decidem se renderizam o picker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔐 Variáveis de Ambiente
|
## 🔐 Variáveis de Ambiente
|
||||||
|
|
||||||
Copie `.env.example` para `.env` e configure:
|
**Perfil 2 (dev):** copie `.env.example` para `.env` — o `DATABASE_URL` já vem com `localhost`, pronto para uso com `pnpm dev`.
|
||||||
|
|
||||||
|
**Perfil 1 (Docker):** não precisa definir `DATABASE_URL` — o compose já configura automaticamente com host `db`. Só defina se usar banco remoto (Supabase, Neon, etc.).
|
||||||
|
|
||||||
### Obrigatórias
|
### Obrigatórias
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
# Perfil 2 (dev): host "localhost" — o banco roda em container, o app no host
|
||||||
|
# Perfil 1 (Docker): não precisa definir — o compose usa "db" automaticamente
|
||||||
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
||||||
BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32
|
BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
@@ -447,6 +467,11 @@ ANTHROPIC_API_KEY=
|
|||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY=
|
GOOGLE_GENERATIVE_AI_API_KEY=
|
||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# Logo.dev (opcional, necessário para logos automáticos de estabelecimentos)
|
||||||
|
# Ambas as variáveis são runtime — basta definir no host; nenhum build arg necessário.
|
||||||
|
LOGO_DEV_TOKEN=
|
||||||
|
LOGO_DEV_SECRET_KEY=
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -1,138 +1,52 @@
|
|||||||
# Docker Compose para Next.js + PostgreSQL
|
|
||||||
name: openmonetis
|
name: openmonetis
|
||||||
|
|
||||||
# MODOS DE USO:
|
|
||||||
# 1. Banco LOCAL (PostgreSQL em container):
|
|
||||||
# - Configure DATABASE_URL com host "db" no .env
|
|
||||||
# - Execute: docker compose --profile local up
|
|
||||||
#
|
|
||||||
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
|
|
||||||
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
|
||||||
# - Execute: docker compose up
|
|
||||||
#
|
|
||||||
# 3. Build local (desenvolvimento):
|
|
||||||
# - Execute: docker compose --profile local up --build
|
|
||||||
#
|
|
||||||
# 4. Para parar todos os serviços:
|
|
||||||
# - Execute: docker compose down
|
|
||||||
#
|
|
||||||
# 5. Para remover volumes (CUIDADO: apaga dados do banco local):
|
|
||||||
# - Execute: docker compose down -v
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ============================================
|
|
||||||
# Serviço: PostgreSQL (Banco de dados local)
|
|
||||||
# Ativado apenas com: --profile local
|
|
||||||
# ============================================
|
|
||||||
db:
|
db:
|
||||||
profiles: ["local"]
|
|
||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
container_name: openmonetis_postgres
|
container_name: openmonetis_postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
||||||
PGDATA: /var/lib/postgresql/data
|
|
||||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||||
|
PGDATA: /var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
# Cria extensão pgcrypto inline (necessária para gen_random_bytes no schema)
|
|
||||||
entrypoint: ["/bin/sh", "-c"]
|
|
||||||
command:
|
|
||||||
- |
|
|
||||||
echo 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' > /docker-entrypoint-initdb.d/init.sql
|
|
||||||
exec docker-entrypoint.sh postgres
|
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}"]
|
||||||
[
|
|
||||||
"CMD-SHELL",
|
|
||||||
"pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}",
|
|
||||||
]
|
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
# Para ativar logs de queries (debug), adicione ao command acima:
|
|
||||||
# exec docker-entrypoint.sh postgres -c log_statement=all
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Serviço: Aplicação Next.js
|
|
||||||
# ============================================
|
|
||||||
app:
|
app:
|
||||||
build: .
|
|
||||||
image: felipegcoutinho/openmonetis:latest
|
image: felipegcoutinho/openmonetis:latest
|
||||||
|
|
||||||
container_name: openmonetis_app
|
container_name: openmonetis_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db}
|
||||||
# Banco local: use host "db" | Banco remoto: URL completa do provider
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
|
||||||
|
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
|
||||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||||
|
|
||||||
# S3 (opcional)
|
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
|
||||||
S3_REGION: ${S3_REGION:-}
|
|
||||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
|
||||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
|
||||||
S3_BUCKET: ${S3_BUCKET:-}
|
|
||||||
|
|
||||||
# Email (opcional)
|
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
|
||||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
|
||||||
|
|
||||||
# OAuth (opcional)
|
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
|
||||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
|
||||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
|
||||||
|
|
||||||
# AI providers (opcional)
|
|
||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
|
|
||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
|
||||||
|
|
||||||
# required: false permite subir sem banco local (banco remoto via DATABASE_URL)
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"wget",
|
|
||||||
"--quiet",
|
|
||||||
"--tries=1",
|
|
||||||
"--spider",
|
|
||||||
"http://localhost:3000/api/health",
|
|
||||||
]
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Volumes
|
|
||||||
# ============================================
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
echo "Rodando migrations..."
|
echo "Rodando migrations..."
|
||||||
RETRIES=5
|
MIGRATED=0
|
||||||
until NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
|
for i in 1 2 3 4 5; do
|
||||||
RETRIES=$((RETRIES - 1))
|
if NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push; then
|
||||||
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
|
MIGRATED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Tentativa $i/5 falhou. Aguardando 5s..."
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$RETRIES" -eq 0 ]; then
|
[ "$MIGRATED" -eq 0 ] && echo "Aviso: migrations não foram aplicadas."
|
||||||
echo "Aviso: migrations nao foram aplicadas"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
24
drizzle/0025_burly_colonel_america.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
DROP INDEX "tokens_api_user_id_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "cartoes_user_id_status_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "dashboard_notification_states_user_id_archived_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "contas_user_id_status_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "antecipacoes_parcelas_series_id_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "pagadores_user_id_status_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "pagadores_user_id_role_idx";--> statement-breakpoint
|
||||||
|
CREATE INDEX "account_user_id_idx" ON "account" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "anexos_user_id_idx" ON "anexos" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "orcamentos_categoria_id_idx" ON "orcamentos" USING btree ("categoria_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "cartoes_conta_id_idx" ON "cartoes" USING btree ("conta_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "import_category_mappings_category_id_idx" ON "import_category_mappings" USING btree ("category_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "pre_lancamentos_lancamento_id_idx" ON "pre_lancamentos" USING btree ("lancamento_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "antecipacoes_parcelas_lancamento_id_idx" ON "antecipacoes_parcelas" USING btree ("lancamento_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "antecipacoes_parcelas_pagador_id_idx" ON "antecipacoes_parcelas" USING btree ("pagador_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "antecipacoes_parcelas_categoria_id_idx" ON "antecipacoes_parcelas" USING btree ("categoria_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "anotacoes_user_id_idx" ON "anotacoes" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "passkey_user_id_idx" ON "passkey" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "compartilhamentos_pagador_shared_with_user_id_idx" ON "compartilhamentos_pagador" USING btree ("shared_with_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "compartilhamentos_pagador_created_by_user_id_idx" ON "compartilhamentos_pagador" USING btree ("created_by_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "session_user_id_idx" ON "session" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "lancamentos_conta_id_idx" ON "lancamentos" USING btree ("conta_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "lancamentos_categoria_id_idx" ON "lancamentos" USING btree ("categoria_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "lancamentos_antecipacao_id_idx" ON "lancamentos" USING btree ("antecipacao_id");
|
||||||
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;
|
||||||
2889
drizzle/meta/0025_snapshot.json
Normal file
2916
drizzle/meta/0026_snapshot.json
Normal file
2915
drizzle/meta/0028_snapshot.json
Normal file
2916
drizzle/meta/0029_snapshot.json
Normal file
@@ -176,6 +176,34 @@
|
|||||||
"when": 1774891206703,
|
"when": 1774891206703,
|
||||||
"tag": "0024_petite_lucky_pierre",
|
"tag": "0024_petite_lucky_pierre",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776351838548,
|
||||||
|
"tag": "0025_burly_colonel_america",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777042423451,
|
||||||
|
"tag": "0026_bored_eternity",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777153372633,
|
||||||
|
"tag": "0028_fancy_reaper",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777648189399,
|
||||||
|
"tag": "0029_friendly_spitfire",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/**"),
|
||||||
|
|||||||
93
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.3.7",
|
"version": "2.5.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,62 +11,36 @@
|
|||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:deadcode": "knip --reporter compact",
|
"lint:deadcode": "knip --reporter compact",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"env:setup": "bash scripts/setup-env.sh",
|
"env:setup": "node setup.mjs",
|
||||||
"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 ---": "---",
|
"//docker:up": "Sobe app (Docker Hub) + banco PostgreSQL em background",
|
||||||
|
"docker:db": "docker compose up -d db",
|
||||||
"docker:up:local": "docker compose --profile local up",
|
"//docker:db": "Sobe apenas o banco em background (para usar com pnpm dev)",
|
||||||
"//docker:up:local": "Sobe app + banco PostgreSQL local juntos (imagem do Docker Hub)",
|
|
||||||
|
|
||||||
"docker:up": "docker compose up --build",
|
|
||||||
"//docker:up": "Sobe apenas o app com build local (banco deve estar rodando separado)",
|
|
||||||
|
|
||||||
"docker:up:d": "docker compose up --build -d",
|
|
||||||
"//docker:up:d": "Sobe apenas o app com build local em background (detached)",
|
|
||||||
|
|
||||||
"docker:up:db": "docker compose up -d db",
|
|
||||||
"//docker:up:db": "Sobe apenas o banco PostgreSQL em background",
|
|
||||||
|
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
"//docker:down": "Para e remove os containers",
|
"//docker:down": "Para e remove os containers",
|
||||||
|
|
||||||
"docker:down:volumes": "docker compose down -v",
|
|
||||||
"//docker:down:volumes": "Para containers e remove volumes (APAGA os dados!)",
|
|
||||||
|
|
||||||
"docker:logs": "docker compose logs -f",
|
"docker:logs": "docker compose logs -f",
|
||||||
"//docker:logs": "Acompanha logs de todos os containers em tempo real",
|
"//docker:logs": "Acompanha logs de todos os containers em tempo real",
|
||||||
|
"docker:update": "docker compose pull && docker compose up -d",
|
||||||
"docker:logs:app": "docker compose logs -f app",
|
"//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia",
|
||||||
"//docker:logs:app": "Acompanha logs do container da aplicação",
|
"backup": "bash scripts/backup.sh",
|
||||||
|
"mockup": "tsx scripts/mock-data.ts"
|
||||||
"docker:logs:db": "docker compose logs -f db",
|
|
||||||
"//docker:logs:db": "Acompanha logs do container do banco",
|
|
||||||
|
|
||||||
"docker:restart": "docker compose restart",
|
|
||||||
"//docker:restart": "Reinicia todos os containers",
|
|
||||||
|
|
||||||
"docker:rebuild": "docker compose up --build --force-recreate",
|
|
||||||
"//docker:rebuild": "Rebuild completo forçando recriação dos containers",
|
|
||||||
|
|
||||||
"backup": "bash scripts/backup.sh"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.68",
|
"@ai-sdk/anthropic": "^3.0.74",
|
||||||
"@ai-sdk/google": "^3.0.61",
|
"@ai-sdk/google": "^3.0.67",
|
||||||
"@ai-sdk/openai": "^3.0.52",
|
"@ai-sdk/openai": "^3.0.57",
|
||||||
"@aws-sdk/client-s3": "^3.1027.0",
|
"@aws-sdk/client-s3": "^3.1040.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1027.0",
|
"@aws-sdk/s3-request-presigner": "^3.1040.0",
|
||||||
"@better-auth/passkey": "^1.6.2",
|
"@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.5.1",
|
"@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",
|
||||||
@@ -75,11 +49,13 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "2.1.8",
|
"@radix-ui/react-label": "2.1.8",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "1.1.8",
|
"@radix-ui/react-progress": "1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-select": "2.2.6",
|
"@radix-ui/react-select": "2.2.6",
|
||||||
"@radix-ui/react-separator": "1.1.8",
|
"@radix-ui/react-separator": "1.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "1.2.4",
|
"@radix-ui/react-slot": "1.2.4",
|
||||||
"@radix-ui/react-switch": "1.2.6",
|
"@radix-ui/react-switch": "1.2.6",
|
||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
@@ -87,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.97.0",
|
"@tanstack/react-query": "^5.100.7",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"ai": "^6.0.154",
|
"ai": "^6.0.173",
|
||||||
"better-auth": "1.6.2",
|
"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",
|
||||||
@@ -101,20 +77,19 @@
|
|||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"next": "16.2.3",
|
"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",
|
||||||
"radix-ui": "^1.4.3",
|
|
||||||
"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.10.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": {
|
||||||
@@ -122,18 +97,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.10",
|
"@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.5.2",
|
"@types/node": "25.6.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"dotenv": "^17.4.1",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"knip": "^6.3.1",
|
"knip": "^6.10.0",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.2.4",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "6.0.2"
|
"typescript": "6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2838
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: 198 KiB |
BIN
public/images/dashboard-preview-light.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
|
Before Width: | Height: | Size: 199 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: 382 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 92 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'."
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/usr/bin/env tsx
|
|
||||||
|
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import { config } from "dotenv";
|
|
||||||
|
|
||||||
// Carregar variáveis de ambiente
|
|
||||||
config();
|
|
||||||
|
|
||||||
const port = process.env.PORT || "3000";
|
|
||||||
|
|
||||||
console.log(`Starting Next.js development server on port ${port}...`);
|
|
||||||
|
|
||||||
// Executar next dev com a porta especificada
|
|
||||||
execSync(`npx next dev --turbopack --port ${port}`, {
|
|
||||||
stdio: "inherit",
|
|
||||||
env: { ...process.env, PORT: port },
|
|
||||||
});
|
|
||||||
@@ -9,6 +9,9 @@ set -e
|
|||||||
LOG_FILE="/tmp/openmonetis-install.log"
|
LOG_FILE="/tmp/openmonetis-install.log"
|
||||||
> "$LOG_FILE"
|
> "$LOG_FILE"
|
||||||
|
|
||||||
|
# Suprimir prompt interativo do corepack ao chamar pnpm/node versioning
|
||||||
|
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||||
|
|
||||||
# ── Cores ──────────────────────────────────────────────────────────────────────
|
# ── Cores ──────────────────────────────────────────────────────────────────────
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
@@ -51,8 +54,8 @@ spinner_start() {
|
|||||||
|
|
||||||
spinner_stop() {
|
spinner_stop() {
|
||||||
if [ -n "$_spin_pid" ]; then
|
if [ -n "$_spin_pid" ]; then
|
||||||
kill "$_spin_pid" 2>/dev/null
|
kill "$_spin_pid" 2>/dev/null || true
|
||||||
wait "$_spin_pid" 2>/dev/null
|
wait "$_spin_pid" 2>/dev/null || true
|
||||||
_spin_pid=""
|
_spin_pid=""
|
||||||
printf "\r\033[2K"
|
printf "\r\033[2K"
|
||||||
fi
|
fi
|
||||||
@@ -220,15 +223,19 @@ if command -v pnpm > /dev/null 2>&1; then
|
|||||||
else
|
else
|
||||||
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
|
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
|
||||||
run_as_user "Instalando pnpm via corepack" \
|
run_as_user "Instalando pnpm via corepack" \
|
||||||
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && corepack enable && corepack prepare pnpm@latest --activate'
|
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
|
||||||
else
|
else
|
||||||
run_quiet "Instalando pnpm via corepack" \
|
run_quiet "Instalando pnpm via corepack" \
|
||||||
sh -c 'corepack enable && corepack prepare pnpm@latest --activate'
|
sh -c 'corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
|
||||||
fi
|
fi
|
||||||
ok "pnpm instalado"
|
ok "pnpm instalado"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Resumo ─────────────────────────────────────────────────────────────────────
|
# ── Resumo ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Garantir que node/pnpm do brew estejam no PATH para o resumo
|
||||||
|
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
|
||||||
|
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
|
||||||
|
|
||||||
printf "\n${BOLD}Concluído em $(elapsed)${RESET}\n"
|
printf "\n${BOLD}Concluído em $(elapsed)${RESET}\n"
|
||||||
|
|
||||||
ok "git: $(git --version | cut -d' ' -f3)"
|
ok "git: $(git --version | cut -d' ' -f3)"
|
||||||
@@ -236,6 +243,3 @@ ok "docker: $(docker --version | cut -d',' -f1 | cut -d' ' -f3)"
|
|||||||
ok "docker compose: $(docker compose version | cut -d' ' -f4)"
|
ok "docker compose: $(docker compose version | cut -d' ' -f4)"
|
||||||
ok "node: $(node --version)"
|
ok "node: $(node --version)"
|
||||||
ok "pnpm: $(pnpm --version)"
|
ok "pnpm: $(pnpm --version)"
|
||||||
|
|
||||||
printf "\n${CYAN}Próximo passo:${RESET}\n"
|
|
||||||
printf " node setup.mjs\n\n"
|
|
||||||
|
|||||||
@@ -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 $$;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Script para configurar ambiente de forma segura
|
|
||||||
# Cria backup do .env atual antes de sobrescrever
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🔧 Configurando ambiente..."
|
|
||||||
|
|
||||||
# Se .env já existe, criar backup
|
|
||||||
if [ -f .env ]; then
|
|
||||||
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
|
|
||||||
echo "⚠️ Arquivo .env existente detectado!"
|
|
||||||
echo "📦 Criando backup em: $BACKUP_FILE"
|
|
||||||
cp .env "$BACKUP_FILE"
|
|
||||||
echo "✅ Backup criado com sucesso!"
|
|
||||||
echo ""
|
|
||||||
read -p "Deseja sobrescrever o .env atual com .env.example? (s/N) " -n 1 -r
|
|
||||||
echo
|
|
||||||
if [[ ! $REPLY =~ ^[Ss]$ ]]; then
|
|
||||||
echo "❌ Operação cancelada. Seu .env não foi modificado."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Copiar .env.example para .env
|
|
||||||
if [ -f .env.example ]; then
|
|
||||||
cp .env.example .env
|
|
||||||
echo "✅ Arquivo .env criado a partir de .env.example"
|
|
||||||
else
|
|
||||||
echo "❌ Erro: .env.example não encontrado!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Gerar BETTER_AUTH_SECRET automaticamente
|
|
||||||
if command -v openssl &> /dev/null; then
|
|
||||||
SECRET=$(openssl rand -base64 32)
|
|
||||||
sed -i.bak "s|BETTER_AUTH_SECRET=.*|BETTER_AUTH_SECRET=$SECRET|" .env && rm -f .env.bak
|
|
||||||
echo "✅ BETTER_AUTH_SECRET gerado automaticamente"
|
|
||||||
else
|
|
||||||
echo "⚠️ openssl não encontrado — configure BETTER_AUTH_SECRET manualmente:"
|
|
||||||
echo " openssl rand -base64 32"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "⚠️ IMPORTANTE: Edite o arquivo .env e configure:"
|
|
||||||
echo " - DATABASE_URL"
|
|
||||||
echo " - BETTER_AUTH_URL"
|
|
||||||
echo " - Demais variáveis opcionais (OAuth, e-mail, IA)"
|
|
||||||
23
src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Logo } from "@/shared/components/logo";
|
||||||
|
|
||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden flex items-center justify-center">
|
||||||
|
<div className="absolute -right-32 top-0 h-96 w-96 rounded-full bg-primary/10 blur-3xl animate-blob mix-blend-multiply" />
|
||||||
|
<div className="absolute -left-32 bottom-0 h-96 w-96 rounded-full bg-primary/7 blur-3xl animate-blob animation-delay-2000 mix-blend-multiply" />
|
||||||
|
<div className="absolute -bottom-32 left-1/2 h-80 w-80 rounded-full bg-secondary/30 blur-3xl animate-blob animation-delay-4000 mix-blend-multiply" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-6 flex md:hidden z-20">
|
||||||
|
<Logo variant="compact" colorIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-sm md:max-w-5xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,5 @@
|
|||||||
import { LoginForm } from "@/features/auth/components/login-form";
|
import { LoginForm } from "@/features/auth/components/login-form";
|
||||||
import { Logo } from "@/shared/components/logo";
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return <LoginForm />;
|
||||||
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
|
||||||
<div className="pointer-events-none absolute inset-0">
|
|
||||||
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
|
|
||||||
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mb-6 flex md:hidden">
|
|
||||||
<Logo variant="compact" colorIcon />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative w-full max-w-sm md:max-w-5xl">
|
|
||||||
<LoginForm />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import { SignupForm } from "@/features/auth/components/signup-form";
|
import { SignupForm } from "@/features/auth/components/signup-form";
|
||||||
import { Logo } from "@/shared/components/logo";
|
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
return (
|
return <SignupForm />;
|
||||||
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
|
||||||
<div className="pointer-events-none absolute inset-0">
|
|
||||||
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
|
|
||||||
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mb-6 flex md:hidden">
|
|
||||||
<Logo variant="compact" colorIcon />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative w-full max-w-sm md:max-w-5xl">
|
|
||||||
<SignupForm />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -20,22 +20,13 @@ const getSingleParam = (
|
|||||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const capitalize = (value: string) =>
|
|
||||||
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
|
|
||||||
|
|
||||||
export default async function Page({ searchParams }: PageProps) {
|
export default async function Page({ searchParams }: PageProps) {
|
||||||
await connection();
|
await connection();
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||||
|
|
||||||
const {
|
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||||
period: selectedPeriod,
|
|
||||||
monthName: rawMonthName,
|
|
||||||
year,
|
|
||||||
} = parsePeriodParam(periodoParam);
|
|
||||||
|
|
||||||
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
|
|
||||||
|
|
||||||
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
|
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
|
||||||
userId,
|
userId,
|
||||||
@@ -49,7 +40,6 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
budgets={budgets}
|
budgets={budgets}
|
||||||
categories={categoriesOptions}
|
categories={categoriesOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
periodLabel={periodLabel}
|
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
|
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
|
||||||
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget";
|
import { CategoryHistoryWidget } from "@/features/dashboard/components/widgets/category-history-widget";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { getCurrentPeriod } from "@/shared/utils/period";
|
import { getCurrentPeriod } from "@/shared/utils/period";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
|
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
|
||||||
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
|
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
|
||||||
|
import { LogoDevProvider } from "@/shared/components/providers/logo-dev-provider";
|
||||||
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
||||||
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
|
||||||
import { getUserSession } from "@/shared/lib/auth/server";
|
import { getUserSession } from "@/shared/lib/auth/server";
|
||||||
|
import { isLogoDevEnabled } from "@/shared/lib/logo/server";
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -13,33 +14,25 @@ export default async function DashboardLayout({
|
|||||||
await connection();
|
await connection();
|
||||||
const session = await getUserSession();
|
const session = await getUserSession();
|
||||||
const navbarData = await fetchDashboardNavbarData(session.user.id);
|
const navbarData = await fetchDashboardNavbarData(session.user.id);
|
||||||
|
const logoDevEnabled = isLogoDevEnabled();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrivacyProvider>
|
<LogoDevProvider enabled={logoDevEnabled}>
|
||||||
<AppNavbar
|
<PrivacyProvider>
|
||||||
user={{ ...session.user, image: session.user.image ?? null }}
|
<AppNavbar
|
||||||
pagadorAvatarUrl={navbarData.pagadorAvatarUrl}
|
user={{ ...session.user, image: session.user.image ?? null }}
|
||||||
preLancamentosCount={navbarData.preLancamentosCount}
|
pagadorAvatarUrl={navbarData.pagadorAvatarUrl}
|
||||||
notificationsSnapshot={navbarData.notificationsSnapshot}
|
preLancamentosCount={navbarData.preLancamentosCount}
|
||||||
/>
|
notificationsSnapshot={navbarData.notificationsSnapshot}
|
||||||
<div className="relative flex flex-1 flex-col pt-16">
|
/>
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-32 overflow-hidden md:h-36">
|
<div className="relative flex flex-1 flex-col pt-16">
|
||||||
<DotPattern
|
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||||
width={20}
|
<div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4 ">
|
||||||
height={20}
|
{children}
|
||||||
cx={1.25}
|
</div>
|
||||||
cy={1.25}
|
|
||||||
cr={1.25}
|
|
||||||
className="text-primary/10 mask-[linear-gradient(to_bottom,black_0%,transparent_100%)]"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-linear-to-b from-primary/6 to-transparent" />
|
|
||||||
</div>
|
|
||||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
|
||||||
<div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4 ">
|
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PrivacyProvider>
|
||||||
</PrivacyProvider>
|
</LogoDevProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { RiGroupLine } from "@remixicon/react";
|
|||||||
import PageDescription from "@/shared/components/page-description";
|
import PageDescription from "@/shared/components/page-description";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Pagadores",
|
title: "Pessoas",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -14,7 +14,7 @@ export default function RootLayout({
|
|||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiGroupLine />}
|
icon={<RiGroupLine />}
|
||||||
title="Pagadores"
|
title="Pessoas"
|
||||||
subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos."
|
subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos."
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loading state para a página de pagadores
|
* Loading state para a página de pessoas
|
||||||
* Layout: Header + Input de compartilhamento + Grid de cards
|
* Layout: Header + Input de compartilhamento + Grid de cards
|
||||||
*/
|
*/
|
||||||
export default function PagadoresLoading() {
|
export default function PagadoresLoading() {
|
||||||
@@ -17,7 +17,7 @@ export default function PagadoresLoading() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid de cards de pagadores */}
|
{/* Grid de cards de pessoas */}
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div key={i} className="rounded-md border p-6 space-y-4">
|
<div key={i} className="rounded-md border p-6 space-y-4">
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
// Extract query params
|
// Extract query params
|
||||||
const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
|
const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
|
||||||
const fimParam = getSingleParam(resolvedSearchParams, "fim");
|
const fimParam = getSingleParam(resolvedSearchParams, "fim");
|
||||||
const categoriasParam = getSingleParam(resolvedSearchParams, "categories");
|
const categoriasParam =
|
||||||
|
getSingleParam(resolvedSearchParams, "categorias") ??
|
||||||
|
getSingleParam(resolvedSearchParams, "categories");
|
||||||
|
|
||||||
// Calculate default period (last 6 months)
|
// Calculate default period (last 6 months)
|
||||||
const currentPeriod = getCurrentPeriod();
|
const currentPeriod = getCurrentPeriod();
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -30,7 +29,6 @@ import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell"
|
|||||||
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 { DotPattern } from "@/shared/components/ui/dot-pattern";
|
|
||||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
@@ -52,46 +50,47 @@ 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"
|
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 ? (
|
||||||
<Link prefetch href="/dashboard" className="hidden md:block">
|
<Link prefetch href="/dashboard" className="hidden md:block">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="navbar"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
|
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"
|
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"
|
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>
|
||||||
@@ -107,18 +106,6 @@ export default async function Page() {
|
|||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">
|
|
||||||
<DotPattern
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
cx={1.25}
|
|
||||||
cy={1.25}
|
|
||||||
cr={1.25}
|
|
||||||
className="text-primary/10 mask-[linear-gradient(to_bottom,black_0%,transparent_100%)]"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-linear-to-b from-primary/6 to-transparent" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-8xl mx-auto px-4 relative">
|
<div className="max-w-8xl mx-auto px-4 relative">
|
||||||
<div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14">
|
<div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
@@ -220,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">
|
||||||
@@ -265,72 +227,34 @@ export default async function Page() {
|
|||||||
</AnimateOnScroll>
|
</AnimateOnScroll>
|
||||||
|
|
||||||
<AnimateOnScroll>
|
<AnimateOnScroll>
|
||||||
<div className="grid gap-4 md:gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{mainFeatures.map((feature) => (
|
{[...mainFeatures, ...extraFeatures].map((feature) => (
|
||||||
<Card key={feature.title}>
|
<Card key={feature.title}>
|
||||||
<CardContent className="pt-5 pb-5 md:pt-6">
|
<CardContent>
|
||||||
<div className="flex flex-col gap-3 md:gap-4">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div
|
<div
|
||||||
className="flex h-11 w-11 md:h-12 md:w-12 items-center justify-center rounded-lg"
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 20%, transparent)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<feature.icon
|
<feature.icon
|
||||||
className="size-[22px] md:size-6"
|
className="size-5"
|
||||||
style={{ color: feature.colorVar }}
|
style={{ color: "var(--foreground)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<h3 className="font-semibold text-base leading-tight">
|
||||||
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
|
{feature.title}
|
||||||
{feature.title}
|
</h3>
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
{feature.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AnimateOnScroll>
|
</AnimateOnScroll>
|
||||||
|
|
||||||
<AnimateOnScroll>
|
|
||||||
<div className="mt-8 md:mt-12">
|
|
||||||
<h3 className="text-sm font-semibold text-center mb-4 md:mb-6 text-muted-foreground">
|
|
||||||
Também inclui
|
|
||||||
</h3>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{extraFeatures.map((feature) => (
|
|
||||||
<div
|
|
||||||
key={feature.title}
|
|
||||||
className="flex items-start gap-3 rounded-lg border bg-card p-3 md:p-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 14%, transparent)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<feature.icon
|
|
||||||
className="size-[18px]"
|
|
||||||
style={{ color: feature.colorVar }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h4 className="font-semibold text-sm mb-0.5">
|
|
||||||
{feature.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
||||||
{feature.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AnimateOnScroll>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -396,14 +320,14 @@ export default async function Page() {
|
|||||||
{pwaHighlights.map((item) => (
|
{pwaHighlights.map((item) => (
|
||||||
<li key={item.title} className="flex items-start gap-3">
|
<li key={item.title} className="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
className="size-[15px]"
|
className="size-[15px]"
|
||||||
style={{ color: item.colorVar }}
|
style={{ color: "var(--foreground)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
@@ -438,17 +362,19 @@ export default async function Page() {
|
|||||||
pré-lançamentos automaticamente para você revisar na inbox.
|
pré-lançamentos automaticamente para você revisar na inbox.
|
||||||
</p>
|
</p>
|
||||||
<ol className="space-y-3 mb-6">
|
<ol className="space-y-3 mb-6">
|
||||||
{companionSteps.map((step, index) => (
|
{companionSteps.map((step) => (
|
||||||
<li key={step.title} className="flex items-start gap-3">
|
<li key={step.title} className="flex items-start gap-3">
|
||||||
<span
|
<div
|
||||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium"
|
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${step.colorVar} 20%, transparent)`,
|
||||||
color: step.colorVar,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{index + 1}
|
<step.icon
|
||||||
</span>
|
className="size-3.5"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span className="font-medium">{step.title}</span>
|
<span className="font-medium">{step.title}</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
@@ -545,14 +471,14 @@ export default async function Page() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg"
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
className="size-6"
|
className="size-6"
|
||||||
style={{ color: item.colorVar }}
|
style={{ color: "var(--foreground)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -633,14 +559,14 @@ export default async function Page() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex gap-3 md:gap-4">
|
<div className="flex gap-3 md:gap-4">
|
||||||
<div
|
<div
|
||||||
className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-lg"
|
className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`,
|
backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon
|
||||||
className="size-[18px] md:size-5"
|
className="size-[18px] md:size-5"
|
||||||
style={{ color: item.colorVar }}
|
style={{ color: "var(--foreground)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
29
src/app/api/logo/mapping/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||||
|
import { fetchEstablishmentLogoDomain } from "@/shared/lib/logo/establishment-logo-queries";
|
||||||
|
import { buildLogoDevUrl } from "@/shared/lib/logo/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/logo/mapping?name={name}
|
||||||
|
*
|
||||||
|
* Retorna o domínio Logo.dev salvo pelo usuário para um estabelecimento,
|
||||||
|
* junto com a `logoUrl` final (construída server-side com o token). O
|
||||||
|
* cliente usa `logoUrl` diretamente — sem precisar conhecer o token.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await getOptionalUserSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ domain: null, logoUrl: null }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const name = searchParams.get("name")?.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json({ domain: null, logoUrl: null }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await fetchEstablishmentLogoDomain(session.user.id, name);
|
||||||
|
const logoUrl = buildLogoDevUrl(domain);
|
||||||
|
return NextResponse.json({ domain, logoUrl });
|
||||||
|
}
|
||||||
87
src/app/api/logo/search/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||||
|
import { buildLogoDevUrl } from "@/shared/lib/logo/server";
|
||||||
|
|
||||||
|
const LOGO_DEV_SEARCH_URL = "https://api.logo.dev/search";
|
||||||
|
|
||||||
|
interface LogoResult {
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogoResultWithUrl extends LogoResult {
|
||||||
|
logoUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchByStrategy(
|
||||||
|
q: string,
|
||||||
|
strategy: "match" | "typeahead",
|
||||||
|
secretKey: string,
|
||||||
|
): Promise<LogoResult[]> {
|
||||||
|
try {
|
||||||
|
const url = `${LOGO_DEV_SEARCH_URL}?q=${encodeURIComponent(q)}&strategy=${strategy}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${secretKey}` },
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/logo/search?q={name}
|
||||||
|
*
|
||||||
|
* Proxy seguro para a Logo.dev Brand Search API.
|
||||||
|
* Faz duas buscas paralelas (match + typeahead) e retorna até 20 resultados únicos.
|
||||||
|
* Usa LOGO_DEV_SECRET_KEY server-side — nunca exposta ao cliente.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await getOptionalUserSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Não autorizado." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const q = searchParams.get("q")?.trim();
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Parâmetro q obrigatório." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretKey = process.env.LOGO_DEV_SECRET_KEY;
|
||||||
|
if (!secretKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Logo.dev não configurado." },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duas buscas paralelas para maximizar resultados (cada uma retorna até 10)
|
||||||
|
const [matchResults, typeaheadResults] = await Promise.all([
|
||||||
|
searchByStrategy(q, "match", secretKey),
|
||||||
|
searchByStrategy(q, "typeahead", secretKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mescla e deduplica por domain, mantendo ordem (match tem prioridade)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: LogoResultWithUrl[] = [];
|
||||||
|
|
||||||
|
for (const result of [...matchResults, ...typeaheadResults]) {
|
||||||
|
if (!seen.has(result.domain)) {
|
||||||
|
seen.add(result.domain);
|
||||||
|
// logoUrl é construída server-side com o token — o cliente nunca
|
||||||
|
// precisa conhecer LOGO_DEV_TOKEN para renderizar a imagem.
|
||||||
|
merged.push({ ...result, logoUrl: buildLogoDevUrl(result.domain) });
|
||||||
|
if (merged.length >= 20) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(merged);
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
: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(99% 0.002 67);
|
--card: oklch(100% 0 0);
|
||||||
--card-foreground: var(--foreground);
|
--card-foreground: var(--foreground);
|
||||||
--popover: oklch(100% 0 0);
|
--popover: oklch(100% 0 0);
|
||||||
--popover-foreground: var(--foreground);
|
--popover-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(90.274% 0.01362 60.342);
|
--border: oklch(87.356% 0.01221 67.486);
|
||||||
--input: var(--border);
|
--input: var(--border);
|
||||||
--ring: var(--primary);
|
--ring: var(--primary);
|
||||||
|
|
||||||
@@ -57,10 +57,6 @@
|
|||||||
--data-4: oklch(74% 0.18 55); /* âmbar */
|
--data-4: oklch(74% 0.18 55); /* âmbar */
|
||||||
--data-5: oklch(78% 0.16 68); /* âmbar-dourado */
|
--data-5: oklch(78% 0.16 68); /* âmbar-dourado */
|
||||||
--data-6: oklch(76% 0.15 82); /* amarelo-quente */
|
--data-6: oklch(76% 0.15 82); /* amarelo-quente */
|
||||||
--data-7: oklch(70% 0.17 95); /* amarelo-lima */
|
|
||||||
--data-8: oklch(65% 0.18 108); /* lima-verde */
|
|
||||||
--data-9: oklch(62% 0.17 120); /* verde-oliva claro */
|
|
||||||
--data-10: oklch(56% 0.15 10); /* terracota escuro */
|
|
||||||
|
|
||||||
--sidebar: oklch(99.3% 0.0015 75);
|
--sidebar: oklch(99.3% 0.0015 75);
|
||||||
--sidebar-foreground: var(--foreground);
|
--sidebar-foreground: var(--foreground);
|
||||||
@@ -71,7 +67,7 @@
|
|||||||
--sidebar-border: oklch(91% 0.004 70);
|
--sidebar-border: oklch(91% 0.004 70);
|
||||||
--sidebar-ring: var(--primary);
|
--sidebar-ring: var(--primary);
|
||||||
|
|
||||||
--radius: 0.625rem;
|
--radius: 0.7rem;
|
||||||
|
|
||||||
--shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04);
|
--shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04);
|
||||||
--shadow-xs: 0 1px 3px 0px oklch(35% 0.02 45 / 0.06);
|
--shadow-xs: 0 1px 3px 0px oklch(35% 0.02 45 / 0.06);
|
||||||
@@ -94,7 +90,7 @@
|
|||||||
.dark {
|
.dark {
|
||||||
--background: oklch(18% 0.004 55);
|
--background: oklch(18% 0.004 55);
|
||||||
--foreground: oklch(93% 0.008 80);
|
--foreground: oklch(93% 0.008 80);
|
||||||
--card: oklch(21.5% 0.004 55);
|
--card: oklch(21.531% 0.00369 48.293);
|
||||||
--card-foreground: var(--foreground);
|
--card-foreground: var(--foreground);
|
||||||
--popover: oklch(24% 0.004 55);
|
--popover: oklch(24% 0.004 55);
|
||||||
--popover-foreground: var(--foreground);
|
--popover-foreground: var(--foreground);
|
||||||
@@ -120,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(31% 0.004 55);
|
--border: oklch(24.957% 0.00355 48.274);
|
||||||
--input: var(--border);
|
--input: var(--border);
|
||||||
--ring: var(--primary);
|
--ring: var(--primary);
|
||||||
|
|
||||||
@@ -141,10 +137,6 @@
|
|||||||
--data-4: oklch(81% 0.18 55);
|
--data-4: oklch(81% 0.18 55);
|
||||||
--data-5: oklch(84% 0.16 68);
|
--data-5: oklch(84% 0.16 68);
|
||||||
--data-6: oklch(82% 0.15 82);
|
--data-6: oklch(82% 0.15 82);
|
||||||
--data-7: oklch(77% 0.17 95);
|
|
||||||
--data-8: oklch(72% 0.18 108);
|
|
||||||
--data-9: oklch(69% 0.17 120);
|
|
||||||
--data-10: oklch(63% 0.15 10);
|
|
||||||
|
|
||||||
--sidebar: oklch(15.5% 0.004 55);
|
--sidebar: oklch(15.5% 0.004 55);
|
||||||
--sidebar-foreground: var(--foreground);
|
--sidebar-foreground: var(--foreground);
|
||||||
@@ -155,7 +147,7 @@
|
|||||||
--sidebar-border: oklch(30% 0.004 55);
|
--sidebar-border: oklch(30% 0.004 55);
|
||||||
--sidebar-ring: var(--primary);
|
--sidebar-ring: var(--primary);
|
||||||
|
|
||||||
--radius: 0.625rem;
|
--radius: 0.7rem;
|
||||||
|
|
||||||
--shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3);
|
--shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3);
|
||||||
--shadow-xs: 0 1px 3px 0px oklch(0% 0 0 / 0.4);
|
--shadow-xs: 0 1px 3px 0px oklch(0% 0 0 / 0.4);
|
||||||
@@ -354,3 +346,22 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
animation: blink-out 6s ease-in-out infinite;
|
animation: blink-out 6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes blob {
|
||||||
|
0% { transform: translate(0px, 0px) scale(1); }
|
||||||
|
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||||
|
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||||
|
100% { transform: translate(0px, 0px) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-blob {
|
||||||
|
animation: blob 10s infinite alternate ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-2000 {
|
||||||
|
animation-delay: 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-4000 {
|
||||||
|
animation-delay: 4s;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
369
src/db/schema.ts
@@ -32,57 +32,69 @@ export const user = pgTable("user", {
|
|||||||
}).notNull(),
|
}).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const account = pgTable("account", {
|
export const account = pgTable(
|
||||||
id: text("id").primaryKey(),
|
"account",
|
||||||
accountId: text("accountId").notNull(),
|
{
|
||||||
providerId: text("providerId").notNull(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("userId")
|
accountId: text("accountId").notNull(),
|
||||||
.notNull()
|
providerId: text("providerId").notNull(),
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
userId: text("userId")
|
||||||
accessToken: text("accessToken"),
|
.notNull()
|
||||||
refreshToken: text("refreshToken"),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
idToken: text("idToken"),
|
accessToken: text("accessToken"),
|
||||||
accessTokenExpiresAt: timestamp("accessTokenExpiresAt", {
|
refreshToken: text("refreshToken"),
|
||||||
mode: "date",
|
idToken: text("idToken"),
|
||||||
withTimezone: true,
|
accessTokenExpiresAt: timestamp("accessTokenExpiresAt", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
scope: text("scope"),
|
||||||
|
password: text("password"),
|
||||||
|
createdAt: timestamp("createdAt", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}).notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}).notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index("account_user_id_idx").on(table.userId),
|
||||||
}),
|
}),
|
||||||
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", {
|
);
|
||||||
mode: "date",
|
|
||||||
withTimezone: true,
|
|
||||||
}),
|
|
||||||
scope: text("scope"),
|
|
||||||
password: text("password"),
|
|
||||||
createdAt: timestamp("createdAt", {
|
|
||||||
mode: "date",
|
|
||||||
withTimezone: true,
|
|
||||||
}).notNull(),
|
|
||||||
updatedAt: timestamp("updatedAt", {
|
|
||||||
mode: "date",
|
|
||||||
withTimezone: true,
|
|
||||||
}).notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const session = pgTable("session", {
|
export const session = pgTable(
|
||||||
id: text("id").primaryKey(),
|
"session",
|
||||||
expiresAt: timestamp("expiresAt", {
|
{
|
||||||
mode: "date",
|
id: text("id").primaryKey(),
|
||||||
withTimezone: true,
|
expiresAt: timestamp("expiresAt", {
|
||||||
}).notNull(),
|
mode: "date",
|
||||||
token: text("token").notNull().unique(),
|
withTimezone: true,
|
||||||
createdAt: timestamp("createdAt", {
|
}).notNull(),
|
||||||
mode: "date",
|
token: text("token").notNull().unique(),
|
||||||
withTimezone: true,
|
createdAt: timestamp("createdAt", {
|
||||||
}).notNull(),
|
mode: "date",
|
||||||
updatedAt: timestamp("updatedAt", {
|
withTimezone: true,
|
||||||
mode: "date",
|
}).notNull(),
|
||||||
withTimezone: true,
|
updatedAt: timestamp("updatedAt", {
|
||||||
}).notNull(),
|
mode: "date",
|
||||||
ipAddress: text("ipAddress"),
|
withTimezone: true,
|
||||||
userAgent: text("userAgent"),
|
}).notNull(),
|
||||||
userId: text("userId")
|
ipAddress: text("ipAddress"),
|
||||||
.notNull()
|
userAgent: text("userAgent"),
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
userId: text("userId")
|
||||||
});
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index("session_user_id_idx").on(table.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const verification = pgTable("verification", {
|
export const verification = pgTable("verification", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -104,24 +116,30 @@ export const verification = pgTable("verification", {
|
|||||||
|
|
||||||
// ===================== PASSKEY (WebAuthn) =====================
|
// ===================== PASSKEY (WebAuthn) =====================
|
||||||
|
|
||||||
export const passkey = pgTable("passkey", {
|
export const passkey = pgTable(
|
||||||
id: text("id").primaryKey(),
|
"passkey",
|
||||||
name: text("name"),
|
{
|
||||||
publicKey: text("publicKey").notNull(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("userId")
|
name: text("name"),
|
||||||
.notNull()
|
publicKey: text("publicKey").notNull(),
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
userId: text("userId")
|
||||||
credentialID: text("credentialID").notNull(),
|
.notNull()
|
||||||
counter: integer("counter").notNull(),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
deviceType: text("deviceType").notNull(),
|
credentialID: text("credentialID").notNull(),
|
||||||
backedUp: boolean("backedUp").notNull(),
|
counter: integer("counter").notNull(),
|
||||||
transports: text("transports"),
|
deviceType: text("deviceType").notNull(),
|
||||||
aaguid: text("aaguid"),
|
backedUp: boolean("backedUp").notNull(),
|
||||||
createdAt: timestamp("createdAt", {
|
transports: text("transports"),
|
||||||
mode: "date",
|
aaguid: text("aaguid"),
|
||||||
withTimezone: true,
|
createdAt: timestamp("createdAt", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index("passkey_user_id_idx").on(table.userId),
|
||||||
}),
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
export const userPreferences = pgTable("preferencias_usuario", {
|
export const userPreferences = pgTable("preferencias_usuario", {
|
||||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
@@ -157,39 +175,30 @@ export const userPreferences = pgTable("preferencias_usuario", {
|
|||||||
|
|
||||||
// ===================== PUBLIC TABLES =====================
|
// ===================== PUBLIC TABLES =====================
|
||||||
|
|
||||||
export const financialAccounts = pgTable(
|
export const financialAccounts = pgTable("contas", {
|
||||||
"contas",
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
{
|
name: text("nome").notNull(),
|
||||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
accountType: text("tipo_conta").notNull(),
|
||||||
name: text("nome").notNull(),
|
note: text("anotacao"),
|
||||||
accountType: text("tipo_conta").notNull(),
|
status: text("status").notNull(),
|
||||||
note: text("anotacao"),
|
logo: text("logo").notNull(),
|
||||||
status: text("status").notNull(),
|
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
|
||||||
logo: text("logo").notNull(),
|
.notNull()
|
||||||
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
|
.default("0"),
|
||||||
.notNull()
|
excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false),
|
||||||
.default("0"),
|
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
|
||||||
excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false),
|
.notNull()
|
||||||
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
|
.default(false),
|
||||||
.notNull()
|
userId: text("user_id")
|
||||||
.default(false),
|
.notNull()
|
||||||
userId: text("user_id")
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
.notNull()
|
createdAt: timestamp("created_at", {
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
mode: "date",
|
||||||
createdAt: timestamp("created_at", {
|
withTimezone: true,
|
||||||
mode: "date",
|
})
|
||||||
withTimezone: true,
|
.notNull()
|
||||||
})
|
.defaultNow(),
|
||||||
.notNull()
|
});
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(table) => ({
|
|
||||||
userIdStatusIdx: index("contas_user_id_status_idx").on(
|
|
||||||
table.userId,
|
|
||||||
table.status,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const categories = pgTable(
|
export const categories = pgTable(
|
||||||
"categorias",
|
"categorias",
|
||||||
@@ -227,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,
|
||||||
@@ -248,14 +255,6 @@ export const payers = pgTable(
|
|||||||
uniqueShareCode: uniqueIndex("pagadores_share_code_key").on(
|
uniqueShareCode: uniqueIndex("pagadores_share_code_key").on(
|
||||||
table.shareCode,
|
table.shareCode,
|
||||||
),
|
),
|
||||||
userIdStatusIdx: index("pagadores_user_id_status_idx").on(
|
|
||||||
table.userId,
|
|
||||||
table.status,
|
|
||||||
),
|
|
||||||
userIdRoleIdx: index("pagadores_user_id_role_idx").on(
|
|
||||||
table.userId,
|
|
||||||
table.role,
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -285,6 +284,12 @@ export const payerShares = pgTable(
|
|||||||
table.payerId,
|
table.payerId,
|
||||||
table.sharedWithUserId,
|
table.sharedWithUserId,
|
||||||
),
|
),
|
||||||
|
sharedWithUserIdIdx: index(
|
||||||
|
"compartilhamentos_pagador_shared_with_user_id_idx",
|
||||||
|
).on(table.sharedWithUserId),
|
||||||
|
createdByUserIdIdx: index(
|
||||||
|
"compartilhamentos_pagador_created_by_user_id_idx",
|
||||||
|
).on(table.createdByUserId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -296,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(),
|
||||||
@@ -317,10 +324,7 @@ export const cards = pgTable(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
userIdStatusIdx: index("cartoes_user_id_status_idx").on(
|
accountIdIdx: index("cartoes_conta_id_idx").on(table.accountId),
|
||||||
table.userId,
|
|
||||||
table.status,
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -387,26 +391,33 @@ export const budgets = pgTable(
|
|||||||
userIdCategoryIdPeriodUnique: uniqueIndex(
|
userIdCategoryIdPeriodUnique: uniqueIndex(
|
||||||
"orcamentos_user_id_categoria_id_periodo_key",
|
"orcamentos_user_id_categoria_id_periodo_key",
|
||||||
).on(table.userId, table.categoryId, table.period),
|
).on(table.userId, table.categoryId, table.period),
|
||||||
|
categoryIdIdx: index("orcamentos_categoria_id_idx").on(table.categoryId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const notes = pgTable("anotacoes", {
|
export const notes = pgTable(
|
||||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
"anotacoes",
|
||||||
title: text("titulo"),
|
{
|
||||||
description: text("descricao"),
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
|
title: text("titulo"),
|
||||||
tasks: text("tasks"), // JSON stringificado com array de tarefas
|
description: text("descricao"),
|
||||||
archived: boolean("arquivada").notNull().default(false),
|
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
|
||||||
createdAt: timestamp("created_at", {
|
tasks: text("tasks"), // JSON stringificado com array de tarefas
|
||||||
mode: "date",
|
archived: boolean("arquivada").notNull().default(false),
|
||||||
withTimezone: true,
|
createdAt: timestamp("created_at", {
|
||||||
})
|
mode: "date",
|
||||||
.notNull()
|
withTimezone: true,
|
||||||
.defaultNow(),
|
})
|
||||||
userId: text("user_id")
|
.notNull()
|
||||||
.notNull()
|
.defaultNow(),
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
userId: text("user_id")
|
||||||
});
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index("anotacoes_user_id_idx").on(table.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const savedInsights = pgTable(
|
export const savedInsights = pgTable(
|
||||||
"insights_salvos",
|
"insights_salvos",
|
||||||
@@ -460,7 +471,6 @@ export const apiTokens = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
userIdIdx: index("tokens_api_user_id_idx").on(table.userId),
|
|
||||||
tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash),
|
tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -524,6 +534,9 @@ export const inboxItems = pgTable(
|
|||||||
table.userId,
|
table.userId,
|
||||||
table.createdAt,
|
table.createdAt,
|
||||||
),
|
),
|
||||||
|
transactionIdIdx: index("pre_lancamentos_lancamento_id_idx").on(
|
||||||
|
table.transactionId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -555,9 +568,6 @@ export const dashboardNotificationStates = pgTable(
|
|||||||
userIdNotificationKeyUnique: uniqueIndex(
|
userIdNotificationKeyUnique: uniqueIndex(
|
||||||
"dashboard_notification_states_user_id_key_unique",
|
"dashboard_notification_states_user_id_key_unique",
|
||||||
).on(table.userId, table.notificationKey),
|
).on(table.userId, table.notificationKey),
|
||||||
userIdArchivedAtIdx: index(
|
|
||||||
"dashboard_notification_states_user_id_archived_idx",
|
|
||||||
).on(table.userId, table.archivedAt),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -597,10 +607,14 @@ export const installmentAnticipations = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
seriesIdIdx: index("antecipacoes_parcelas_series_id_idx").on(
|
|
||||||
table.seriesId,
|
|
||||||
),
|
|
||||||
userIdIdx: index("antecipacoes_parcelas_user_id_idx").on(table.userId),
|
userIdIdx: index("antecipacoes_parcelas_user_id_idx").on(table.userId),
|
||||||
|
transactionIdIdx: index("antecipacoes_parcelas_lancamento_id_idx").on(
|
||||||
|
table.transactionId,
|
||||||
|
),
|
||||||
|
payerIdIdx: index("antecipacoes_parcelas_pagador_id_idx").on(table.payerId),
|
||||||
|
categoryIdIdx: index("antecipacoes_parcelas_categoria_id_idx").on(
|
||||||
|
table.categoryId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -656,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"),
|
||||||
@@ -688,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)
|
||||||
@@ -700,6 +720,12 @@ export const transactions = pgTable(
|
|||||||
table.cardId,
|
table.cardId,
|
||||||
table.period,
|
table.period,
|
||||||
),
|
),
|
||||||
|
// FK indexes: evitam seq scan em deletes/updates nas tabelas pai
|
||||||
|
accountIdIdx: index("lancamentos_conta_id_idx").on(table.accountId),
|
||||||
|
categoryIdIdx: index("lancamentos_categoria_id_idx").on(table.categoryId),
|
||||||
|
anticipationIdIdx: index("lancamentos_antecipacao_id_idx").on(
|
||||||
|
table.anticipationId,
|
||||||
|
),
|
||||||
// Dedup OFX: garante FITID único por usuário
|
// Dedup OFX: garante FITID único por usuário
|
||||||
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
|
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
|
||||||
.on(table.userId, table.ofxFitId)
|
.on(table.userId, table.ofxFitId)
|
||||||
@@ -721,6 +747,7 @@ export const userRelations = relations(user, ({ many, one }) => ({
|
|||||||
installmentAnticipations: many(installmentAnticipations),
|
installmentAnticipations: many(installmentAnticipations),
|
||||||
apiTokens: many(apiTokens),
|
apiTokens: many(apiTokens),
|
||||||
inboxItems: many(inboxItems),
|
inboxItems: many(inboxItems),
|
||||||
|
establishmentLogos: many(establishmentLogos),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({ one }) => ({
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
@@ -904,19 +931,25 @@ export const installmentAnticipationsRelations = relations(
|
|||||||
|
|
||||||
// ===================== ATTACHMENTS =====================
|
// ===================== ATTACHMENTS =====================
|
||||||
|
|
||||||
export const attachments = pgTable("anexos", {
|
export const attachments = pgTable(
|
||||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
"anexos",
|
||||||
userId: text("user_id")
|
{
|
||||||
.notNull()
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
userId: text("user_id")
|
||||||
fileKey: text("chave_arquivo").notNull().unique(),
|
.notNull()
|
||||||
fileName: text("nome_arquivo").notNull(),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
fileSize: integer("tamanho_bytes").notNull(),
|
fileKey: text("chave_arquivo").notNull().unique(),
|
||||||
mimeType: text("mime_type").notNull(),
|
fileName: text("nome_arquivo").notNull(),
|
||||||
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
fileSize: integer("tamanho_bytes").notNull(),
|
||||||
.notNull()
|
mimeType: text("mime_type").notNull(),
|
||||||
.defaultNow(),
|
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
||||||
});
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index("anexos_user_id_idx").on(table.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const transactionAttachments = pgTable(
|
export const transactionAttachments = pgTable(
|
||||||
"lancamento_anexos",
|
"lancamento_anexos",
|
||||||
@@ -952,9 +985,31 @@ export const importCategoryMappings = pgTable(
|
|||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
pk: primaryKey({ columns: [table.userId, table.descriptionKey] }),
|
pk: primaryKey({ columns: [table.userId, table.descriptionKey] }),
|
||||||
|
categoryIdIdx: index("import_category_mappings_category_id_idx").on(
|
||||||
|
table.categoryId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const establishmentLogos = pgTable(
|
||||||
|
"establishment_logos",
|
||||||
|
{
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
nameKey: text("name_key").notNull(),
|
||||||
|
domain: text("domain").notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.userId, table.nameKey] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type EstablishmentLogo = typeof establishmentLogos.$inferSelect;
|
||||||
|
|
||||||
export type User = typeof user.$inferSelect;
|
export type User = typeof user.$inferSelect;
|
||||||
export type NewUser = typeof user.$inferInsert;
|
export type NewUser = typeof user.$inferInsert;
|
||||||
export type Account = typeof account.$inferSelect;
|
export type Account = typeof account.$inferSelect;
|
||||||
@@ -1004,3 +1059,13 @@ export const transactionAttachmentsRelations = relations(
|
|||||||
|
|
||||||
export type Attachment = typeof attachments.$inferSelect;
|
export type Attachment = typeof attachments.$inferSelect;
|
||||||
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
|
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
|
||||||
|
|
||||||
|
export const establishmentLogosRelations = relations(
|
||||||
|
establishmentLogos,
|
||||||
|
({ one }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [establishmentLogos.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
"Pagador com papel administrador não encontrado. Crie um pagador 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(
|
||||||
"Pagador administrador não encontrado. Por favor, crie um pagador 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RiArrowLeftRightLine,
|
RiArrowLeftRightLine,
|
||||||
RiDeleteBin5Line,
|
RiDeleteBin5Line,
|
||||||
@@ -47,6 +48,13 @@ export function AccountCard({
|
|||||||
}: AccountCardProps) {
|
}: AccountCardProps) {
|
||||||
const isInactive = status?.toLowerCase() === "inativa";
|
const isInactive = status?.toLowerCase() === "inativa";
|
||||||
|
|
||||||
|
const balanceColor =
|
||||||
|
balance > 0
|
||||||
|
? "text-success"
|
||||||
|
: balance < 0
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-foreground";
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: "editar",
|
label: "editar",
|
||||||
@@ -75,78 +83,90 @@ export function AccountCard({
|
|||||||
].filter((action) => typeof action.onClick === "function");
|
].filter((action) => typeof action.onClick === "function");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn("h-full w-full gap-0", className)}>
|
<Card className={cn("flex w-full flex-col p-6", className)}>
|
||||||
<CardContent className="flex flex-1 flex-col gap-4">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{icon ? (
|
<div
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
"flex shrink-0 items-center justify-center",
|
||||||
"flex items-center justify-center",
|
isInactive && "grayscale opacity-40",
|
||||||
isInactive && "[&_img]:grayscale [&_img]:opacity-40",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{icon}
|
||||||
{icon}
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<h3 className="truncate font-semibold text-foreground">
|
||||||
|
{accountName}
|
||||||
|
</h3>
|
||||||
|
{excludeFromBalance || excludeInitialBalanceFromIncome ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||||
|
aria-label="Informações da conta"
|
||||||
|
>
|
||||||
|
<RiInformationLine className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="start" className="max-w-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{excludeFromBalance && (
|
||||||
|
<p className="text-xs">
|
||||||
|
<strong>Desconsiderado do saldo total:</strong> Esta
|
||||||
|
conta não é incluída no cálculo do saldo total geral.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{excludeInitialBalanceFromIncome && (
|
||||||
|
<p className="text-xs">
|
||||||
|
<strong>
|
||||||
|
Saldo inicial desconsiderado das receitas:
|
||||||
|
</strong>{" "}
|
||||||
|
O saldo inicial desta conta não é contabilizado como
|
||||||
|
receita nas métricas.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
{accountName}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
<p className="text-xs text-muted-foreground">{status}</p>
|
||||||
<Tooltip>
|
</div>
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="max-w-xs">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{excludeFromBalance && (
|
|
||||||
<p className="text-xs">
|
|
||||||
<strong>Desconsiderado do saldo total:</strong> Esta conta
|
|
||||||
não é incluída no cálculo do saldo total geral.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{excludeInitialBalanceFromIncome && (
|
|
||||||
<p className="text-xs">
|
|
||||||
<strong>
|
|
||||||
Saldo inicial desconsiderado das receitas:
|
|
||||||
</strong>{" "}
|
|
||||||
O saldo inicial desta conta não é contabilizado como
|
|
||||||
receita nas métricas.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<p className="text-xs text-muted-foreground">{accountType}</p>
|
||||||
<MoneyValues amount={balance} className="text-3xl" />
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{accountType}</p>
|
|
||||||
|
<CardContent className="flex flex-1 flex-col gap-2 px-0 pb-2">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">Saldo</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={balance}
|
||||||
|
className={cn("text-2xl font-semibold", balanceColor)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{actions.length > 0 ? (
|
<CardFooter className="flex flex-wrap gap-4 p-0 text-sm">
|
||||||
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm">
|
{actions.map(({ label, icon, onClick, variant }) => (
|
||||||
{actions.map(({ label, icon, onClick, variant }) => (
|
<button
|
||||||
<button
|
key={label}
|
||||||
key={label}
|
type="button"
|
||||||
type="button"
|
onClick={onClick}
|
||||||
onClick={onClick}
|
className={cn(
|
||||||
className={cn(
|
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
||||||
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
variant === "destructive" ? "text-destructive" : "text-primary",
|
||||||
variant === "destructive" ? "text-destructive" : "text-primary",
|
)}
|
||||||
)}
|
aria-label={`${label} conta`}
|
||||||
aria-label={`${label} conta`}
|
>
|
||||||
>
|
{icon}
|
||||||
{icon}
|
{label}
|
||||||
{label}
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
</CardFooter>
|
||||||
</CardFooter>
|
|
||||||
) : null}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,12 +227,12 @@ export function AccountDialog({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = mode === "create" ? "Nova conta" : "Editar conta";
|
const title = mode === "create" ? "Nova conta" : "Atualizar conta";
|
||||||
const description =
|
const description =
|
||||||
mode === "create"
|
mode === "create"
|
||||||
? "Cadastre uma nova conta para organizar seus lançamentos."
|
? "Cadastre uma nova conta para organizar seus lançamentos."
|
||||||
: "Atualize as informações da conta selecionada.";
|
: "Atualize as informações da conta selecionada.";
|
||||||
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
|
const submitLabel = mode === "create" ? "Salvar" : "Atualizar";
|
||||||
|
|
||||||
const handleMainDialogOpenChange = (open: boolean) => {
|
const handleMainDialogOpenChange = (open: boolean) => {
|
||||||
if (!open && logoDialogOpen) {
|
if (!open && logoDialogOpen) {
|
||||||
|
|||||||
@@ -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-[2rem]"
|
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)}
|
||||||
@@ -123,7 +128,7 @@ export function AccountStatementCard({
|
|||||||
|
|
||||||
<MetaItem
|
<MetaItem
|
||||||
label="Saídas"
|
label="Saídas"
|
||||||
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
|
tooltip="Total de despesas pagas neste mês (considerando divisão entre pessoas)."
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium text-destructive">
|
<span className="text-sm font-medium text-destructive">
|
||||||
{formatCurrency(totalExpenses)}
|
{formatCurrency(totalExpenses)}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export function AccountsPage({
|
|||||||
onOpenChange={handleRemoveOpenChange}
|
onOpenChange={handleRemoveOpenChange}
|
||||||
title={removeTitle}
|
title={removeTitle}
|
||||||
description="Ao remover esta conta, todos os dados relacionados a ela serão perdidos."
|
description="Ao remover esta conta, todos os dados relacionados a ela serão perdidos."
|
||||||
confirmLabel="Remover conta"
|
confirmLabel="Remover"
|
||||||
pendingLabel="Removendo..."
|
pendingLabel="Removendo..."
|
||||||
confirmVariant="destructive"
|
confirmVariant="destructive"
|
||||||
onConfirm={handleRemoveConfirm}
|
onConfirm={handleRemoveConfirm}
|
||||||
|
|||||||
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,11 +4,14 @@ 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";
|
||||||
|
|
||||||
export type AccountSummaryData = {
|
type AccountSummaryData = {
|
||||||
openingBalance: number;
|
openingBalance: number;
|
||||||
currentBalance: number;
|
currentBalance: number;
|
||||||
totalIncomes: number;
|
totalIncomes: number;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,25 +1,12 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
|
||||||
import AuthSidebar from "./auth-sidebar";
|
import AuthSidebar from "./auth-sidebar";
|
||||||
|
|
||||||
export function AuthCardShell({ children }: PropsWithChildren) {
|
export function AuthCardShell({ children }: PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden p-0">
|
<Card className="overflow-hidden border-primary/10 p-0 shadow-lg">
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
|
<CardContent className="grid p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr]">
|
||||||
<DotPattern
|
<div className="flex md:rounded-l-4xl">{children}</div>
|
||||||
width={17}
|
|
||||||
height={17}
|
|
||||||
cx={1.3}
|
|
||||||
cy={1.3}
|
|
||||||
cr={1.3}
|
|
||||||
className="text-primary/8 mask-[linear-gradient(to_bottom,black,transparent_84%)]"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-linear-to-br from-primary/6 via-transparent to-transparent" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardContent className="relative z-10 grid gap-0 p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr]">
|
|
||||||
<div className="flex bg-card/92 backdrop-blur-[1px]">{children}</div>
|
|
||||||
<AuthSidebar />
|
<AuthSidebar />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type { Budget } from "./types";
|
|||||||
|
|
||||||
interface BudgetCardProps {
|
interface BudgetCardProps {
|
||||||
budget: Budget;
|
budget: Budget;
|
||||||
periodLabel: string;
|
|
||||||
onEdit: (budget: Budget) => void;
|
onEdit: (budget: Budget) => void;
|
||||||
onRemove: (budget: Budget) => void;
|
onRemove: (budget: Budget) => void;
|
||||||
}
|
}
|
||||||
@@ -29,81 +28,88 @@ const buildUsagePercent = (spent: number, limit: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatCategoryName = (budget: Budget) =>
|
const formatCategoryName = (budget: Budget) =>
|
||||||
budget.category?.name ?? "Category removida";
|
budget.category?.name ?? "Categoria removida";
|
||||||
|
|
||||||
export function BudgetCard({
|
export function BudgetCard({ budget, onEdit, onRemove }: BudgetCardProps) {
|
||||||
budget,
|
|
||||||
periodLabel,
|
|
||||||
onEdit,
|
|
||||||
onRemove,
|
|
||||||
}: BudgetCardProps) {
|
|
||||||
const { amount: limit, spent } = budget;
|
const { amount: limit, spent } = budget;
|
||||||
const exceeded = spent > limit && limit >= 0;
|
const exceeded = spent > limit && limit >= 0;
|
||||||
const difference = Math.abs(spent - limit);
|
const difference = Math.abs(spent - limit);
|
||||||
const usagePercent = buildUsagePercent(spent, limit);
|
const usagePercent = buildUsagePercent(spent, limit);
|
||||||
|
const remaining = Math.max(limit - spent, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex h-full flex-col">
|
<Card className="flex w-full flex-col p-6">
|
||||||
<CardContent className="flex h-full flex-col gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-start gap-3">
|
<CategoryIconBadge
|
||||||
<CategoryIconBadge
|
icon={budget.category?.icon ?? undefined}
|
||||||
icon={budget.category?.icon ?? undefined}
|
name={formatCategoryName(budget)}
|
||||||
name={formatCategoryName(budget)}
|
size="lg"
|
||||||
size="lg"
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate font-semibold text-foreground">
|
||||||
|
{formatCategoryName(budget)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-1 flex-col gap-4 p-0">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{exceeded ? "Excedido em" : "Disponível"}
|
||||||
|
</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={exceeded ? difference : remaining}
|
||||||
|
className={cn(
|
||||||
|
"text-xl font-semibold",
|
||||||
|
exceeded ? "text-destructive" : "text-success",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-base font-semibold leading-tight">
|
|
||||||
{formatCategoryName(budget)}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Orçamento de {periodLabel}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="flex items-baseline justify-between text-sm">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-muted-foreground">Gasto até agora</span>
|
<span className="text-xs text-muted-foreground">Orçamento</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={spent}
|
amount={limit}
|
||||||
className={cn(exceeded && "text-destructive")}
|
className="text-sm font-semibold text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
<div className="flex flex-col gap-0.5">
|
||||||
value={usagePercent}
|
<span className="text-xs text-muted-foreground">Gasto</span>
|
||||||
className={cn("h-2", exceeded && "bg-destructive/20!")}
|
<MoneyValues
|
||||||
/>
|
amount={spent}
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-1 text-sm">
|
className={cn(
|
||||||
<span className="text-muted-foreground">Limite</span>
|
"text-sm font-semibold",
|
||||||
<MoneyValues amount={limit} className="text-foreground" />
|
exceeded ? "text-destructive" : "text-primary",
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
<div>
|
|
||||||
{exceeded ? (
|
|
||||||
<div className="text-xs text-destructive">
|
|
||||||
Excedeu em <MoneyValues amount={difference} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-success">
|
|
||||||
Restam <MoneyValues amount={Math.max(limit - spent, 0)} />{" "}
|
|
||||||
disponíveis.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Progress
|
||||||
|
value={usagePercent}
|
||||||
|
className={cn("h-2.5", exceeded && "bg-destructive/20!")}
|
||||||
|
aria-label={`${usagePercent.toFixed(1)}% do orçamento utilizado`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{usagePercent.toFixed(1)}% utilizado
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-wrap gap-3 px-5 text-sm">
|
|
||||||
|
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onEdit(budget)}
|
onClick={() => onEdit(budget)}
|
||||||
className="flex items-center gap-1 text-primary font-medium transition-opacity hover:opacity-80"
|
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<RiPencilLine className="size-4" aria-hidden /> editar
|
<RiPencilLine className="size-4" aria-hidden /> editar
|
||||||
</button>
|
</button>
|
||||||
{budget.category && (
|
{budget.category && (
|
||||||
<Link
|
<Link
|
||||||
href={`/categories/${budget.category.id}`}
|
href={`/categories/${budget.category.id}`}
|
||||||
className="flex items-center gap-1 text-primary font-medium transition-opacity hover:opacity-80"
|
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<RiFileList2Line className="size-4" aria-hidden /> detalhes
|
<RiFileList2Line className="size-4" aria-hidden /> detalhes
|
||||||
</Link>
|
</Link>
|
||||||
@@ -111,7 +117,7 @@ export function BudgetCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemove(budget)}
|
onClick={() => onRemove(budget)}
|
||||||
className="flex items-center gap-1 text-destructive font-medium transition-opacity hover:opacity-80"
|
className="flex items-center gap-1 font-medium text-destructive transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<RiDeleteBin5Line className="size-4" aria-hidden /> remover
|
<RiDeleteBin5Line className="size-4" aria-hidden /> remover
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -161,13 +161,12 @@ export function BudgetDialog({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = mode === "create" ? "Novo orçamento" : "Editar orçamento";
|
const title = mode === "create" ? "Novo orçamento" : "Atualizar orçamento";
|
||||||
const description =
|
const description =
|
||||||
mode === "create"
|
mode === "create"
|
||||||
? "Defina um limite de gastos para acompanhar suas despesas."
|
? "Defina um limite de gastos para acompanhar suas despesas."
|
||||||
: "Atualize os detalhes do orçamento selecionado.";
|
: "Atualize os detalhes do orçamento selecionado.";
|
||||||
const submitLabel =
|
const submitLabel = mode === "create" ? "Salvar" : "Atualizar";
|
||||||
mode === "create" ? "Salvar orçamento" : "Atualizar orçamento";
|
|
||||||
const disabled = categories.length === 0;
|
const disabled = categories.length === 0;
|
||||||
const parsedAmount = Number.parseFloat(formState.amount);
|
const parsedAmount = Number.parseFloat(formState.amount);
|
||||||
const sliderValue = Number.isFinite(parsedAmount)
|
const sliderValue = Number.isFinite(parsedAmount)
|
||||||
|
|||||||
@@ -19,14 +19,12 @@ interface BudgetsPageProps {
|
|||||||
budgets: Budget[];
|
budgets: Budget[];
|
||||||
categories: BudgetCategory[];
|
categories: BudgetCategory[];
|
||||||
selectedPeriod: string;
|
selectedPeriod: string;
|
||||||
periodLabel: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BudgetsPage({
|
export function BudgetsPage({
|
||||||
budgets,
|
budgets,
|
||||||
categories,
|
categories,
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
periodLabel,
|
|
||||||
}: BudgetsPageProps) {
|
}: BudgetsPageProps) {
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
|
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
|
||||||
@@ -137,7 +135,6 @@ export function BudgetsPage({
|
|||||||
<BudgetCard
|
<BudgetCard
|
||||||
key={budget.id}
|
key={budget.id}
|
||||||
budget={budget}
|
budget={budget}
|
||||||
periodLabel={periodLabel}
|
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onRemove={handleRemoveRequest}
|
onRemove={handleRemoveRequest}
|
||||||
/>
|
/>
|
||||||
@@ -168,7 +165,7 @@ export function BudgetsPage({
|
|||||||
onOpenChange={handleRemoveOpenChange}
|
onOpenChange={handleRemoveOpenChange}
|
||||||
title={removeTitle}
|
title={removeTitle}
|
||||||
description="Esta ação remove o limite configurado para a categoria selecionada."
|
description="Esta ação remove o limite configurado para a categoria selecionada."
|
||||||
confirmLabel="Remover orçamento"
|
confirmLabel="Remover"
|
||||||
pendingLabel="Removendo..."
|
pendingLabel="Removendo..."
|
||||||
confirmVariant="destructive"
|
confirmVariant="destructive"
|
||||||
onConfirm={handleRemoveConfirm}
|
onConfirm={handleRemoveConfirm}
|
||||||
@@ -179,7 +176,7 @@ export function BudgetsPage({
|
|||||||
onOpenChange={setDuplicateOpen}
|
onOpenChange={setDuplicateOpen}
|
||||||
title="Copiar orçamentos do último mês?"
|
title="Copiar orçamentos do último mês?"
|
||||||
description="Isso copiará os limites definidos no mês anterior para as categorias que ainda não possuem orçamento neste mês."
|
description="Isso copiará os limites definidos no mês anterior para as categorias que ainda não possuem orçamento neste mês."
|
||||||
confirmLabel="Copiar orçamentos"
|
confirmLabel="Copiar"
|
||||||
pendingLabel="Copiando..."
|
pendingLabel="Copiando..."
|
||||||
onConfirm={handleDuplicateConfirm}
|
onConfirm={handleDuplicateConfirm}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const toNumber = (value: string | number | null | undefined) => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BudgetData = {
|
type BudgetData = {
|
||||||
id: string;
|
id: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
spent: number;
|
spent: number;
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DayCell } from "@/features/calendar/components/day-cell";
|
import { DayCell } from "@/features/calendar/components/day-cell";
|
||||||
|
|
||||||
import type { CalendarDay } from "@/shared/lib/types/calendar";
|
import type { CalendarDay } from "@/shared/lib/types/calendar";
|
||||||
import { WEEK_DAYS_SHORT } from "@/shared/utils/calendar";
|
import { WEEK_DAYS_SHORT } from "@/shared/utils/calendar";
|
||||||
import { cn } from "@/shared/utils/ui";
|
|
||||||
|
|
||||||
type CalendarGridProps = {
|
type CalendarGridProps = {
|
||||||
days: CalendarDay[];
|
days: CalendarDay[];
|
||||||
@@ -18,21 +16,18 @@ export function CalendarGrid({
|
|||||||
onCreateDay,
|
onCreateDay,
|
||||||
}: CalendarGridProps) {
|
}: CalendarGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
|
<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="px-3 py-2 text-center">
|
<span key={dayName} className="text-center">
|
||||||
{dayName}
|
{dayName}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-px bg-border/60 px-px pb-px pt-px">
|
<div className="grid grid-cols-7 gap-px px-px pb-px pt-px">
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<div
|
<div key={day.date} className="h-[150px] p-0.5">
|
||||||
key={day.date}
|
|
||||||
className={cn("h-[150px] bg-card p-0.5", !day.isCurrentMonth && "")}
|
|
||||||
>
|
|
||||||
<DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} />
|
<DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,34 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
|
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
|
||||||
import StatusDot from "@/shared/components/status-dot";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import { Card } from "@/shared/components/ui/card";
|
|
||||||
import type { CalendarEvent } from "@/shared/lib/types/calendar";
|
|
||||||
|
|
||||||
const LEGEND_ITEMS: Array<{
|
const LEGEND_ITEMS = [
|
||||||
type?: CalendarEvent["type"];
|
{ label: "Lançamentos", ...EVENT_TYPE_STYLES.transaction },
|
||||||
label: string;
|
{ label: "Parcelas", ...EVENT_TYPE_STYLES.installment },
|
||||||
dotColor?: string;
|
{ label: "Boletos", ...EVENT_TYPE_STYLES.boleto },
|
||||||
}> = [
|
{ label: "Fatura de Cartão", ...EVENT_TYPE_STYLES.card },
|
||||||
{ type: "transaction", label: "Lançamentos" },
|
|
||||||
{ type: "boleto", label: "Boleto com vencimento" },
|
|
||||||
{ type: "card", label: "Vencimento de cartão" },
|
|
||||||
{ label: "Pagamento fatura", dotColor: "bg-success" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function CalendarLegend() {
|
export function CalendarLegend() {
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-row gap-2 p-2 text-sm">
|
<ul className="flex items-center justify-start gap-2 px-1">
|
||||||
{LEGEND_ITEMS.map((item, index) => {
|
{LEGEND_ITEMS.map((item) => (
|
||||||
const dotColor =
|
<li
|
||||||
item.dotColor || (item.type ? EVENT_TYPE_STYLES[item.type].dot : "");
|
key={item.label}
|
||||||
return (
|
className={cn(
|
||||||
<span key={item.type || index} className="flex items-center gap-2">
|
"flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium",
|
||||||
<StatusDot color={dotColor} />
|
item.wrapper,
|
||||||
{item.label}
|
)}
|
||||||
</span>
|
>
|
||||||
);
|
<span
|
||||||
})}
|
className={cn("size-1.5 shrink-0 rounded-full", item.dot)}
|
||||||
</Card>
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{item.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAddLine } from "@remixicon/react";
|
import { RiAddLine, RiCheckboxCircleFill } from "@remixicon/react";
|
||||||
import type { KeyboardEvent, MouseEvent } from "react";
|
import type { KeyboardEvent, MouseEvent } from "react";
|
||||||
import type { CalendarDay, CalendarEvent } from "@/shared/lib/types/calendar";
|
import type { CalendarDay, CalendarEvent } from "@/shared/lib/types/calendar";
|
||||||
import { currencyFormatter } from "@/shared/utils/currency";
|
import { currencyFormatter } from "@/shared/utils/currency";
|
||||||
@@ -14,44 +14,40 @@ type DayCellProps = {
|
|||||||
|
|
||||||
export const EVENT_TYPE_STYLES: Record<
|
export const EVENT_TYPE_STYLES: Record<
|
||||||
CalendarEvent["type"],
|
CalendarEvent["type"],
|
||||||
{ wrapper: string; dot: string; accent?: string }
|
{ wrapper: string; dot: string }
|
||||||
> = {
|
> = {
|
||||||
transaction: {
|
transaction: {
|
||||||
|
wrapper: "bg-primary/10 text-primary dark:bg-primary/5 dark:text-primary",
|
||||||
|
dot: "bg-primary",
|
||||||
|
},
|
||||||
|
installment: {
|
||||||
wrapper:
|
wrapper:
|
||||||
"bg-warning/10 text-warning dark:bg-warning/5 dark:text-warning border-l-4 border-warning",
|
"bg-amber-100 text-amber-600 dark:bg-amber-900/10 dark:text-amber-500",
|
||||||
dot: "bg-warning",
|
dot: "bg-amber-500",
|
||||||
},
|
},
|
||||||
boleto: {
|
boleto: {
|
||||||
wrapper:
|
wrapper: "bg-info/10 text-info dark:bg-info/5 dark:text-info",
|
||||||
"bg-info/10 text-info dark:bg-info/5 dark:text-info border-l-4 border-info",
|
|
||||||
dot: "bg-info",
|
dot: "bg-info",
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
wrapper:
|
wrapper:
|
||||||
"bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-50 border-l-4 border-violet-500",
|
"bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-500",
|
||||||
dot: "bg-violet-600",
|
dot: "bg-violet-600 dark:bg-violet-500",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventStyles = EVENT_TYPE_STYLES;
|
|
||||||
|
|
||||||
const formatCurrencyValue = (value: number | null | undefined) =>
|
const formatCurrencyValue = (value: number | null | undefined) =>
|
||||||
currencyFormatter.format(Math.abs(value ?? 0));
|
currencyFormatter.format(Math.abs(value ?? 0));
|
||||||
|
|
||||||
const formatAmount = (event: Extract<CalendarEvent, { type: "transaction" }>) =>
|
|
||||||
formatCurrencyValue(event.transaction.amount);
|
|
||||||
|
|
||||||
const buildEventLabel = (event: CalendarEvent) => {
|
const buildEventLabel = (event: CalendarEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "transaction": {
|
case "transaction":
|
||||||
|
case "boleto":
|
||||||
return event.transaction.name;
|
return event.transaction.name;
|
||||||
}
|
case "installment":
|
||||||
case "boleto": {
|
|
||||||
return event.transaction.name;
|
return event.transaction.name;
|
||||||
}
|
case "card":
|
||||||
case "card": {
|
|
||||||
return event.card.name;
|
return event.card.name;
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -59,60 +55,50 @@ const buildEventLabel = (event: CalendarEvent) => {
|
|||||||
|
|
||||||
const buildEventComplement = (event: CalendarEvent) => {
|
const buildEventComplement = (event: CalendarEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "transaction": {
|
case "transaction":
|
||||||
return formatAmount(event);
|
case "boleto":
|
||||||
}
|
|
||||||
case "boleto": {
|
|
||||||
return formatCurrencyValue(event.transaction.amount);
|
return formatCurrencyValue(event.transaction.amount);
|
||||||
}
|
case "installment":
|
||||||
case "card": {
|
return `${event.installmentCount}x de ${formatCurrencyValue(event.installmentValue)}`;
|
||||||
if (event.card.totalDue !== null) {
|
case "card":
|
||||||
return formatCurrencyValue(event.card.totalDue);
|
return event.card.totalDue !== null
|
||||||
}
|
? formatCurrencyValue(event.card.totalDue)
|
||||||
return null;
|
: null;
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPagamentoFatura = (event: CalendarEvent) => {
|
const isPaid = (event: CalendarEvent) => {
|
||||||
return (
|
if (event.type === "boleto") return Boolean(event.transaction.isSettled);
|
||||||
event.type === "transaction" &&
|
if (event.type === "card") return event.card.isPaid;
|
||||||
event.transaction.name.startsWith("Pagamento fatura -")
|
return false;
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEventStyle = (event: CalendarEvent) => {
|
|
||||||
if (isPagamentoFatura(event)) {
|
|
||||||
return {
|
|
||||||
wrapper:
|
|
||||||
"bg-success/10 text-success dark:bg-success/5 dark:text-success border-l-4 border-success",
|
|
||||||
dot: "bg-success",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return eventStyles[event.type];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
||||||
const complement = buildEventComplement(event);
|
const complement = buildEventComplement(event);
|
||||||
const label = buildEventLabel(event);
|
const label = buildEventLabel(event);
|
||||||
const style = getEventStyle(event);
|
const style = EVENT_TYPE_STYLES[event.type];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs",
|
"flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-xs",
|
||||||
style.wrapper,
|
style.wrapper,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<span
|
||||||
|
className={cn("size-1.5 shrink-0 rounded-full", style.dot)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
<span className="truncate">{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
|
{isPaid(event) && (
|
||||||
|
<RiCheckboxCircleFill className="size-3.5 shrink-0 text-success" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{complement ? (
|
{complement ? (
|
||||||
<span className={cn("shrink-0 font-medium", style.accent ?? "text-xs")}>
|
<span className="shrink-0 font-medium">{complement}</span>
|
||||||
{complement}
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -143,8 +129,8 @@ 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 border-transparent 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 && "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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -159,14 +145,16 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
|||||||
>
|
>
|
||||||
{day.label}
|
{day.label}
|
||||||
</span>
|
</span>
|
||||||
<button
|
{day.isCurrentMonth && (
|
||||||
type="button"
|
<button
|
||||||
onClick={handleCreateClick}
|
type="button"
|
||||||
className="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground opacity-0 transition-all group-hover:opacity-100 hover:bg-primary/20 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
onClick={handleCreateClick}
|
||||||
aria-label={`Criar lançamento em ${day.date}`}
|
className="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground opacity-0 transition-all group-hover:opacity-100 hover:bg-primary/20 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||||
>
|
aria-label={`Criar lançamento em ${day.date}`}
|
||||||
<RiAddLine className="size-3.5" />
|
>
|
||||||
</button>
|
<RiAddLine className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { RiCalendarEventLine } from "@remixicon/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
|
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
@@ -29,17 +30,13 @@ type EventModalProps = {
|
|||||||
const EventCard = ({
|
const EventCard = ({
|
||||||
children,
|
children,
|
||||||
type,
|
type,
|
||||||
isPagamentoFatura = false,
|
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
type: CalendarEvent["type"];
|
type: CalendarEvent["type"];
|
||||||
isPagamentoFatura?: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const style = isPagamentoFatura
|
const style = EVENT_TYPE_STYLES[type];
|
||||||
? { dot: "bg-success" }
|
|
||||||
: EVENT_TYPE_STYLES[type];
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-row gap-2 p-3 mb-1">
|
<Card className="flex flex-row gap-2 p-3">
|
||||||
<span
|
<span
|
||||||
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
|
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -49,41 +46,34 @@ const EventCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
};
|
||||||
|
|
||||||
const renderLancamento = (
|
const renderLancamento = (
|
||||||
event: Extract<CalendarEvent, { type: "transaction" }>,
|
event: Extract<CalendarEvent, { type: "transaction" }>,
|
||||||
) => {
|
) => {
|
||||||
const isReceita = event.transaction.transactionType === "Receita";
|
const isReceita = event.transaction.transactionType === "Receita";
|
||||||
const isPagamentoFatura =
|
|
||||||
event.transaction.name.startsWith("Pagamento fatura -");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventCard type="transaction" isPagamentoFatura={isPagamentoFatura}>
|
<EventCard type="transaction">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span
|
<span className="text-sm font-medium leading-tight">
|
||||||
className={`text-sm font-medium leading-tight ${
|
|
||||||
isPagamentoFatura && "text-success"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{event.transaction.name}
|
{event.transaction.name}
|
||||||
</span>
|
</span>
|
||||||
|
<Badge variant="outline">{event.transaction.categoriaName}</Badge>
|
||||||
<div className="flex gap-1">
|
|
||||||
<Badge variant={"outline"}>{event.transaction.categoriaName}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<MoneyValues
|
||||||
|
showPositiveSign
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-medium whitespace-nowrap",
|
"text-base whitespace-nowrap font-medium",
|
||||||
isReceita ? "text-success" : "text-foreground",
|
isReceita ? "text-success" : "text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
amount={event.transaction.amount}
|
||||||
<MoneyValues
|
/>
|
||||||
showPositiveSign
|
|
||||||
className="text-base"
|
|
||||||
amount={event.transaction.amount}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</EventCard>
|
</EventCard>
|
||||||
);
|
);
|
||||||
@@ -91,64 +81,118 @@ const renderLancamento = (
|
|||||||
|
|
||||||
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
||||||
const isPaid = Boolean(event.transaction.isSettled);
|
const isPaid = Boolean(event.transaction.isSettled);
|
||||||
const dueDate = event.transaction.dueDate;
|
const dueDateLabel = formatFinancialDateLabel(
|
||||||
const dueDateLabel = formatFinancialDateLabel(dueDate, "Vence em", {
|
event.transaction.dueDate,
|
||||||
day: "2-digit",
|
"Vence em",
|
||||||
month: "2-digit",
|
DATE_FORMAT,
|
||||||
year: "numeric",
|
);
|
||||||
});
|
const paymentDateLabel = isPaid
|
||||||
|
? formatFinancialDateLabel(
|
||||||
|
event.transaction.boletoPaymentDate,
|
||||||
|
"Pago em",
|
||||||
|
DATE_FORMAT,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventCard type="boleto">
|
<EventCard type="boleto">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex gap-1 items-center">
|
<span className="text-sm font-medium leading-tight">
|
||||||
<span className="text-sm font-medium leading-tight">
|
{event.transaction.name}
|
||||||
{event.transaction.name}
|
</span>
|
||||||
</span>
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
|
||||||
|
|
||||||
{dueDateLabel && (
|
{dueDateLabel && (
|
||||||
<span className="text-xs text-muted-foreground leading-tight">
|
<span className="text-muted-foreground">{dueDateLabel}</span>
|
||||||
{dueDateLabel}
|
)}
|
||||||
</span>
|
{paymentDateLabel && (
|
||||||
|
<span className="text-success">{paymentDateLabel}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Badge variant="outline">{isPaid ? "Pago" : "Pendente"}</Badge>
|
||||||
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">
|
<MoneyValues
|
||||||
<MoneyValues amount={event.transaction.amount} />
|
className="font-medium whitespace-nowrap"
|
||||||
</span>
|
amount={event.transaction.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</EventCard>
|
</EventCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => {
|
||||||
<EventCard type="card">
|
const paymentDateLabel = event.card.isPaid
|
||||||
<div className="flex items-start justify-between gap-3">
|
? formatFinancialDateLabel(event.card.paymentDate, "Pago em", DATE_FORMAT)
|
||||||
<div className="flex flex-col gap-1">
|
: null;
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
<span className="text-sm font-medium leading-tight">
|
|
||||||
Vencimento Fatura - {event.card.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge>
|
return (
|
||||||
|
<EventCard type="card">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium leading-tight">
|
||||||
|
Vencimento Fatura — {event.card.name}
|
||||||
|
</span>
|
||||||
|
{paymentDateLabel && (
|
||||||
|
<span className="text-xs text-success">{paymentDateLabel}</span>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline">
|
||||||
|
{event.card.isPaid ? "Pago" : (event.card.status ?? "Fatura")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{event.card.totalDue !== null ? (
|
||||||
|
<MoneyValues
|
||||||
|
className="font-medium whitespace-nowrap"
|
||||||
|
amount={event.card.totalDue}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{event.card.totalDue !== null ? (
|
</EventCard>
|
||||||
<span className="font-medium">
|
);
|
||||||
<MoneyValues amount={event.card.totalDue} />
|
};
|
||||||
</span>
|
|
||||||
) : null}
|
const renderInstallment = (
|
||||||
</div>
|
event: Extract<CalendarEvent, { type: "installment" }>,
|
||||||
</EventCard>
|
) => {
|
||||||
);
|
const isReceita = event.transaction.transactionType === "Receita";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventCard type="installment">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium leading-tight">
|
||||||
|
{event.transaction.name}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline">{event.installmentCount}x parcelas</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-0.5">
|
||||||
|
<MoneyValues
|
||||||
|
showPositiveSign
|
||||||
|
className={cn(
|
||||||
|
"text-base whitespace-nowrap font-medium",
|
||||||
|
isReceita ? "text-success" : "text-foreground",
|
||||||
|
)}
|
||||||
|
amount={event.installmentValue}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">por parcela</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EventCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<CalendarEvent["type"], string> = {
|
||||||
|
transaction: "Lançamentos",
|
||||||
|
installment: "Parcelas",
|
||||||
|
boleto: "Boletos",
|
||||||
|
card: "Faturas",
|
||||||
|
};
|
||||||
|
|
||||||
const renderEvent = (event: CalendarEvent) => {
|
const renderEvent = (event: CalendarEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "transaction":
|
case "transaction":
|
||||||
return renderLancamento(event);
|
return renderLancamento(event);
|
||||||
|
case "installment":
|
||||||
|
return renderInstallment(event);
|
||||||
case "boleto":
|
case "boleto":
|
||||||
return renderBoleto(event);
|
return renderBoleto(event);
|
||||||
case "card":
|
case "card":
|
||||||
@@ -169,28 +213,51 @@ export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
|
|||||||
onCreate(day.date);
|
onCreate(day.date);
|
||||||
};
|
};
|
||||||
|
|
||||||
const description = day?.events.length
|
const hasEvents = Boolean(day?.events.length);
|
||||||
? "Confira os lançamentos e vencimentos cadastrados para este dia."
|
|
||||||
: "Nenhum lançamento encontrado para este dia. Você pode criar um novo lançamento agora.";
|
const grouped = day
|
||||||
|
? {
|
||||||
|
transaction: day.events.filter((e) => e.type === "transaction"),
|
||||||
|
installment: day.events.filter((e) => e.type === "installment"),
|
||||||
|
boleto: day.events.filter((e) => e.type === "boleto"),
|
||||||
|
card: day.events.filter((e) => e.type === "card"),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
|
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
|
||||||
<DialogContent className="max-w-xl">
|
<DialogContent className="max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{formattedDate}</DialogTitle>
|
<DialogTitle>{formattedDate}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>
|
||||||
|
{hasEvents
|
||||||
|
? "Lançamentos e vencimentos cadastrados para este dia."
|
||||||
|
: "Nenhum lançamento encontrado para este dia."}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[380px] space-y-2 overflow-y-auto pr-2">
|
<div className="max-h-[380px] space-y-3 overflow-y-auto pr-2">
|
||||||
{day?.events.length ? (
|
{hasEvents && grouped ? (
|
||||||
day.events.map((event) => (
|
(["transaction", "installment", "boleto", "card"] as const)
|
||||||
<div key={event.id}>{renderEvent(event)}</div>
|
.filter((type) => grouped[type].length > 0)
|
||||||
))
|
.map((type) => (
|
||||||
|
<div key={type} className="space-y-1.5">
|
||||||
|
<p className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{SECTION_LABELS[type]}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{grouped[type].map((event) => (
|
||||||
|
<div key={event.id}>{renderEvent(event)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 p-6 text-center text-sm text-muted-foreground">
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border/60 bg-muted/30 p-8 text-center">
|
||||||
Nenhum lançamento ou vencimento registrado. Clique em{" "}
|
<RiCalendarEventLine className="size-8 text-muted-foreground/50" />
|
||||||
<span className="font-medium text-primary">Novo lançamento</span>{" "}
|
<p className="text-sm text-muted-foreground">
|
||||||
para começar.
|
Nenhum lançamento registrado para este dia.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { parsePeriod } from "@/shared/utils/period";
|
|||||||
|
|
||||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||||
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
|
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
|
||||||
|
const PAYMENT_PREFIX = "Pagamento fatura - ";
|
||||||
|
|
||||||
const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
|
const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
|
||||||
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
||||||
@@ -88,19 +89,28 @@ export const fetchCalendarData = async ({
|
|||||||
const transactionData = mapTransactionsData(transactionRows);
|
const transactionData = mapTransactionsData(transactionRows);
|
||||||
const events: CalendarEvent[] = [];
|
const events: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
// Totais por cartão para exibir no vencimento
|
||||||
const cardTotals = new Map<string, number>();
|
const cardTotals = new Map<string, number>();
|
||||||
for (const item of transactionData) {
|
for (const item of transactionData) {
|
||||||
if (!item.cardId || item.period !== period) {
|
if (!item.cardId || item.period !== period) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const amount = Math.abs(item.amount ?? 0);
|
const amount = Math.abs(item.amount ?? 0);
|
||||||
cardTotals.set(item.cardId, (cardTotals.get(item.cardId) ?? 0) + amount);
|
cardTotals.set(item.cardId, (cardTotals.get(item.cardId) ?? 0) + amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pagamentos de fatura por nome do cartão → data de pagamento
|
||||||
|
const paymentByCardName = new Map<string, string | null>();
|
||||||
for (const item of transactionData) {
|
for (const item of transactionData) {
|
||||||
|
if (!item.name.startsWith(PAYMENT_PREFIX)) continue;
|
||||||
|
const cardName = item.name.slice(PAYMENT_PREFIX.length);
|
||||||
|
paymentByCardName.set(cardName, item.purchaseDate?.slice(0, 10) ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of transactionData) {
|
||||||
|
// Pagamentos de fatura são consumidos pelos eventos de cartão
|
||||||
|
if (item.name.startsWith(PAYMENT_PREFIX)) continue;
|
||||||
|
|
||||||
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
|
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
|
||||||
|
|
||||||
// Para boletos, exibir apenas na data de vencimento
|
|
||||||
if (isBoleto) {
|
if (isBoleto) {
|
||||||
if (
|
if (
|
||||||
item.dueDate &&
|
item.dueDate &&
|
||||||
@@ -114,7 +124,6 @@ export const fetchCalendarData = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Para outros tipos de lançamento, exibir na data de compra
|
|
||||||
const purchaseDateKey = item.purchaseDate.slice(0, 10);
|
const purchaseDateKey = item.purchaseDate.slice(0, 10);
|
||||||
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
|
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
|
||||||
events.push({
|
events.push({
|
||||||
@@ -127,23 +136,60 @@ export const fetchCalendarData = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exibir vencimentos apenas de cartões com lançamentos do período
|
// Agrupar parcelas da mesma série em um único evento
|
||||||
|
const installmentGroups = new Map<
|
||||||
|
string,
|
||||||
|
Array<Extract<CalendarEvent, { type: "transaction" }>>
|
||||||
|
>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type !== "transaction") continue;
|
||||||
|
const { seriesId, installmentCount } = event.transaction;
|
||||||
|
if (!seriesId || !installmentCount || installmentCount <= 1) continue;
|
||||||
|
const group = installmentGroups.get(seriesId) ?? [];
|
||||||
|
group.push(event as Extract<CalendarEvent, { type: "transaction" }>);
|
||||||
|
installmentGroups.set(seriesId, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedSeriesIds = new Set<string>();
|
||||||
|
const installmentEvents: CalendarEvent[] = [];
|
||||||
|
for (const [seriesId, group] of installmentGroups) {
|
||||||
|
if (group.length < 2) continue;
|
||||||
|
groupedSeriesIds.add(seriesId);
|
||||||
|
const rep = group[0];
|
||||||
|
installmentEvents.push({
|
||||||
|
id: `${seriesId}:installment`,
|
||||||
|
type: "installment",
|
||||||
|
date: rep.date,
|
||||||
|
transaction: rep.transaction,
|
||||||
|
installmentCount: rep.transaction.installmentCount ?? group.length,
|
||||||
|
installmentValue: rep.transaction.amount ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseEvents = events.filter((e) => {
|
||||||
|
if (e.type !== "transaction") return true;
|
||||||
|
const { seriesId } = e.transaction;
|
||||||
|
return !seriesId || !groupedSeriesIds.has(seriesId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allEvents = [...baseEvents, ...installmentEvents];
|
||||||
|
|
||||||
|
// Vencimentos de cartões com lançamentos no período
|
||||||
for (const card of cardRows) {
|
for (const card of cardRows) {
|
||||||
if (!cardTotals.has(card.id)) {
|
if (!cardTotals.has(card.id)) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
|
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
|
||||||
if (Number.isNaN(dueDayNumber)) {
|
if (Number.isNaN(dueDayNumber)) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
|
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
|
||||||
const dueDateKey = formatDateKey(
|
const dueDateKey = formatDateKey(
|
||||||
new Date(Date.UTC(year, monthIndex, normalizedDay)),
|
new Date(Date.UTC(year, monthIndex, normalizedDay)),
|
||||||
);
|
);
|
||||||
|
|
||||||
events.push({
|
const isPaid = paymentByCardName.has(card.name);
|
||||||
|
const paymentDate = paymentByCardName.get(card.name) ?? null;
|
||||||
|
|
||||||
|
allEvents.push({
|
||||||
id: `${card.id}:cartao`,
|
id: `${card.id}:cartao`,
|
||||||
type: "card",
|
type: "card",
|
||||||
date: dueDateKey,
|
date: dueDateKey,
|
||||||
@@ -156,17 +202,20 @@ export const fetchCalendarData = async ({
|
|||||||
status: card.status,
|
status: card.status,
|
||||||
logo: card.logo ?? null,
|
logo: card.logo ?? null,
|
||||||
totalDue: cardTotals.get(card.id) ?? null,
|
totalDue: cardTotals.get(card.id) ?? null,
|
||||||
|
isPaid,
|
||||||
|
paymentDate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const typePriority: Record<CalendarEvent["type"], number> = {
|
const typePriority: Record<CalendarEvent["type"], number> = {
|
||||||
transaction: 0,
|
transaction: 0,
|
||||||
|
installment: 0,
|
||||||
boleto: 1,
|
boleto: 1,
|
||||||
card: 2,
|
card: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
events.sort((a, b) => {
|
allEvents.sort((a, b) => {
|
||||||
if (a.date === b.date) {
|
if (a.date === b.date) {
|
||||||
return typePriority[a.type] - typePriority[b.type];
|
return typePriority[a.type] - typePriority[b.type];
|
||||||
}
|
}
|
||||||
@@ -182,7 +231,7 @@ export const fetchCalendarData = async ({
|
|||||||
const estabelecimentos = await fetchRecentEstablishments(userId);
|
const estabelecimentos = await fetchRecentEstablishments(userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
events,
|
events: allEvents,
|
||||||
formOptions: {
|
formOptions: {
|
||||||
payerOptions: optionSets.payerOptions,
|
payerOptions: optionSets.payerOptions,
|
||||||
splitPayerOptions: optionSets.splitPayerOptions,
|
splitPayerOptions: optionSets.splitPayerOptions,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -194,12 +202,12 @@ export function CardDialog({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = mode === "create" ? "Novo cartão" : "Editar cartão";
|
const title = mode === "create" ? "Novo cartão" : "Atualizar cartão";
|
||||||
const description =
|
const description =
|
||||||
mode === "create"
|
mode === "create"
|
||||||
? "Inclua um novo cartão de crédito para acompanhar seus gastos."
|
? "Inclua um novo cartão de crédito para acompanhar seus gastos."
|
||||||
: "Atualize as informações do cartão selecionado.";
|
: "Atualize as informações do cartão selecionado.";
|
||||||
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão";
|
const submitLabel = mode === "create" ? "Salvar" : "Atualizar";
|
||||||
|
|
||||||
const handleMainDialogOpenChange = (open: boolean) => {
|
const handleMainDialogOpenChange = (open: boolean) => {
|
||||||
if (!open && logoDialogOpen) {
|
if (!open && logoDialogOpen) {
|
||||||
|
|||||||
@@ -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,62 +61,22 @@ 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 metrics =
|
|
||||||
limitTotal === null || used === null || available === null
|
|
||||||
? null
|
|
||||||
: [
|
|
||||||
{ label: "Limite Total", value: limitTotal },
|
|
||||||
{ label: "Em uso", value: used },
|
|
||||||
{ label: "Disponível", value: available },
|
|
||||||
];
|
|
||||||
|
|
||||||
const actions = [
|
|
||||||
{
|
|
||||||
label: "editar",
|
|
||||||
icon: <RiPencilLine className="size-4" aria-hidden />,
|
|
||||||
onClick: onEdit,
|
|
||||||
className: "text-primary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "ver fatura",
|
|
||||||
icon: <RiFileList2Line className="size-4" aria-hidden />,
|
|
||||||
onClick: onInvoice,
|
|
||||||
className: "text-primary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "remover",
|
|
||||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
|
||||||
onClick: onRemove,
|
|
||||||
className: "text-destructive",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col p-6 w-full">
|
<Card className="flex flex-col p-6 w-full">
|
||||||
<CardHeader className="space-y-2 px-0 pb-0">
|
<CardHeader className="space-y-2 p-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
{logoPath ? (
|
{logoPath ? (
|
||||||
@@ -135,8 +95,8 @@ export function CardItem({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
<h3 className="truncate font-semibold text-foreground">
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
{note ? (
|
{note ? (
|
||||||
@@ -166,14 +126,14 @@ export function CardItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{brandAsset ? (
|
{brandAsset ? (
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex items-center justify-center py-2">
|
||||||
<Image
|
<Image
|
||||||
src={brandAsset}
|
src={brandAsset}
|
||||||
alt={`Bandeira ${brand}`}
|
alt={`Bandeira ${brand}`}
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-5 w-auto rounded",
|
"h-4 w-auto rounded",
|
||||||
isInactive && "grayscale opacity-40",
|
isInactive && "grayscale opacity-40",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -185,79 +145,89 @@ export function CardItem({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm">
|
<div className="flex items-center justify-between border-y py-3 text-sm text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
Fecha dia{" "}
|
Fecha em{" "}
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{formatDay(closingDay)}
|
dia {formatDay(closingDay)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Vence dia{" "}
|
Vence em{" "}
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{formatDay(dueDay)}
|
dia {formatDay(dueDay)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex flex-1 flex-col gap-5 px-0">
|
<CardContent className="flex flex-1 flex-col gap-4 px-0">
|
||||||
{metrics ? (
|
<div className="flex flex-col gap-0.5">
|
||||||
<>
|
<span className="text-xs text-muted-foreground">
|
||||||
<div className="grid grid-cols-3 gap-4">
|
Limite disponível
|
||||||
<div className="flex flex-col items-start gap-1">
|
</span>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<MoneyValues
|
||||||
<MoneyValues amount={metrics[0].value} />
|
amount={available}
|
||||||
</p>
|
className="text-xl font-semibold text-success"
|
||||||
<span className="text-xs text-muted-foreground">
|
/>
|
||||||
{metrics[0].label}
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="size-2 rounded-full bg-primary" />
|
<span className="text-xs text-muted-foreground">Limite total</span>
|
||||||
<MoneyValues amount={metrics[1].value} />
|
<MoneyValues
|
||||||
</p>
|
amount={limit}
|
||||||
<span className="text-xs text-muted-foreground">
|
className="text-sm font-semibold text-foreground"
|
||||||
{metrics[1].label}
|
/>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Limite utilizado
|
||||||
|
</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={used}
|
||||||
|
className="text-sm font-semibold text-destructive"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<Progress
|
||||||
<MoneyValues amount={metrics[2].value} />
|
value={usagePercent}
|
||||||
</p>
|
className="h-2.5"
|
||||||
<span className="text-xs text-muted-foreground">
|
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
|
||||||
{metrics[2].label}
|
/>
|
||||||
</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
</div>
|
{usagePercent.toFixed(1)}% utilizado
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
<Progress value={usagePercent} className="h-3" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<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 text-sm">
|
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">
|
||||||
{actions.map(({ label, icon, onClick, className }) => (
|
<button
|
||||||
<button
|
type="button"
|
||||||
key={label}
|
onClick={onEdit}
|
||||||
type="button"
|
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
||||||
onClick={onClick}
|
>
|
||||||
className={cn(
|
<RiPencilLine className="size-4" aria-hidden />
|
||||||
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
editar
|
||||||
className,
|
</button>
|
||||||
)}
|
<button
|
||||||
>
|
type="button"
|
||||||
{icon}
|
onClick={onInvoice}
|
||||||
{label}
|
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
||||||
</button>
|
>
|
||||||
))}
|
<RiFileList2Line className="size-4" aria-hidden />
|
||||||
|
ver fatura
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="flex items-center gap-1 font-medium text-destructive transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||||
|
remover
|
||||||
|
</button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export function CardsPage({
|
|||||||
onOpenChange={handleRemoveOpenChange}
|
onOpenChange={handleRemoveOpenChange}
|
||||||
title={removeTitle}
|
title={removeTitle}
|
||||||
description="Ao remover este cartão, os registros relacionados a ele serão excluídos permanentemente."
|
description="Ao remover este cartão, os registros relacionados a ele serão excluídos permanentemente."
|
||||||
confirmLabel="Remover cartão"
|
confirmLabel="Remover"
|
||||||
pendingLabel="Removendo..."
|
pendingLabel="Removendo..."
|
||||||
confirmVariant="destructive"
|
confirmVariant="destructive"
|
||||||
onConfirm={handleRemoveConfirm}
|
onConfirm={handleRemoveConfirm}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||