mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Compare commits
17 Commits
5b03824a72
...
v2.4.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b5aa00ee | ||
|
|
863ccc0fd2 | ||
|
|
29d99cbedb | ||
|
|
dbeb98bbe4 | ||
|
|
c0436dc2ac | ||
|
|
e1e76fadc0 | ||
|
|
9b2c15ef7d | ||
|
|
fbe3fceb9f | ||
|
|
39f3cd8b20 | ||
|
|
791fec7751 | ||
|
|
114e2b4011 | ||
|
|
f15a003cef | ||
|
|
7f07a9cbf6 | ||
|
|
5fa234884e | ||
|
|
b453b432ed | ||
|
|
7f05d2a681 | ||
|
|
b14f487824 |
1
.gitignore
vendored
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/
|
||||||
|
|
||||||
|
|||||||
70
CHANGELOG.md
70
CHANGELOG.md
@@ -7,6 +7,76 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.4.4] - 2026-04-27
|
||||||
|
|
||||||
|
Esta versão remove a dependência da extensão `pgcrypto` do PostgreSQL para a geração do `share_code` em pagadores. O default a nível de banco (`gen_random_bytes`) foi removido — agora a aplicação gera o código sempre via `crypto.randomBytes` do Node.js, num utilitário compartilhado. A consequência prática é que o setup inicial fica mais simples: não há mais script de habilitação de extensão, nem etapa extra no primeiro `db:push`, e bancos restaurados de dumps externos não precisam ter `pgcrypto` instalada. O script de backup também foi enxugado para gerar dumps focados nos schemas relevantes (`public` e `drizzle`), descartando os schemas internos do Supabase e eliminando os ~148 erros de restore em PostgreSQL padrão. Por fim, os logos da marca (ícone laranja e wordmark) foram vetorizados: as PNGs antigas foram substituídas por SVGs inline em componentes próprios e por arquivos `.svg` no `public/`, escalando perfeitamente em qualquer tamanho — inclusive nos PDFs exportados, que agora rasterizam o SVG em alta resolução.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Schema: coluna `share_code` em `pagadores` perdeu o default `substr(encode(gen_random_bytes(24), 'base64'), 1, 24)` — campo continua `NOT NULL` e a aplicação passa a fornecer o valor explicitamente em todas as inserções
|
||||||
|
- Pagadores: nova função utilitária `generateShareCode()` em `src/shared/lib/payers/share-code.ts` (server-only) — usa `crypto.randomBytes(18).toString("base64url").slice(0, 24)`
|
||||||
|
- Pagadores: `createPayerAction`, `ensureDefaultPagadorForUser`, `resetUserAppData` (settings) e `mock-data.ts` agora chamam `generateShareCode()` ao inserir um pagador
|
||||||
|
- Backup: `scripts/backup.sh` agora dumpa apenas os schemas `public` e `drizzle` — schemas internos do Supabase (`auth`, `realtime`, `storage`, `vault`, `graphql`, `graphql_public`, `extensions`, `pgbouncer`) e suas extensions/roles deixam de poluir os dumps. Restaurações em PostgreSQL padrão passam a executar sem os ~148 erros de `role/extension does not exist`
|
||||||
|
- Logo: `Logo` foi quebrado em três arquivos — `src/shared/components/logo.tsx` (orquestrador), `logo-icon.tsx` (ícone laranja em SVG inline, viewBox `0 0 200 200`) e `logo-text.tsx` (wordmark em SVG inline, viewBox `0 0 574.201 89.6`). API pública (`variant`, `invertTextOnDark`, `colorIcon`, `iconClassName`, `textClassName`) preservada
|
||||||
|
- Assets: `public/images/logo_small.png` e `logo_text.png` substituídos por `logo_small.svg` e `logo_text.svg` (com `width`/`height` explícitos para compatibilidade com `<img>` em canvas)
|
||||||
|
- Exports: `loadExportLogoDataUrl` agora carrega SVG e rasteriza no canvas a 4× a resolução natural antes de gerar o data URL — mantém nitidez quando o PDF amplia a imagem
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- Pasta `scripts/postgres/` (continha `init.sql` e `enable-extensions.ts`)
|
||||||
|
- Script `pnpm db:extensions` no `package.json`
|
||||||
|
- Referências ao `pnpm db:extensions` no README
|
||||||
|
- `public/images/logo_small.png` e `public/images/logo_text.png` (substituídos pelos `.svg`)
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Migrations: conflito de numeração resolvido — `0027_fancy_reaper` renomeado para `0028_fancy_reaper` (o número 0027 já estava ocupado pelo arquivo órfão `0027_glorious_mindworm`); journal e snapshot atualizados
|
||||||
|
- TS: removido `baseUrl` do `tsconfig.json` para evitar erro `TS5101` (deprecação no TS 7) — `moduleResolution: bundler` resolve os `paths` relativos ao próprio `tsconfig`, dispensando `baseUrl`
|
||||||
|
|
||||||
|
### Documentação
|
||||||
|
|
||||||
|
- README: seção Backup atualizada — arquivos gerados agora especificam que apenas os schemas `public` e `drizzle` são dumpados
|
||||||
|
- README: seção Restore reescrita com o fluxo correto para banco Docker (`DROP SCHEMA public CASCADE` + `pg_restore --clean --if-exists --disable-triggers`)
|
||||||
|
- README: comando rápido de Docker Compose de backup/restore substituído por `pnpm backup`
|
||||||
|
- README: header passa a apontar para `logo_small.svg`
|
||||||
|
|
||||||
|
## [2.4.3] - 2026-04-25
|
||||||
|
|
||||||
|
Esta versão amplia o trabalho com lançamentos divididos: anexos passam a ser visíveis para pessoas com acesso compartilhado, a importação para conta própria copia os arquivos de forma independente e a edição ganha a opção de aplicar a alteração nos dois lados do par. Três caminhos de deleção foram corrigidos para não deixar arquivos órfãos no storage. Também traz refresh visual nos badges de tipo e radio buttons, prefetch server-side de logos para reduzir chamadas de API no dashboard, e ajustes pontuais no healthcheck do container e em rótulos da UI.
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Schema: coluna `split_group_id` (uuid, nullable) em `lancamentos` com índice `(user_id, split_group_id)` — liga as shares do mesmo evento de divisão
|
||||||
|
- Split: `buildLancamentoRecords` atribui um `splitGroupId` único por cycle (parcelado, recorrente ou único) para ambas as shares
|
||||||
|
- Split: edição cooperativa via `updateTransactionSplitPairAction` — ao editar um lançamento dividido, novo dialog `SplitPairDialog` permite escolher entre aplicar somente neste lado ou nos dois lados (nome, data, categoria e demais campos compartilhados; valor e payer permanecem por share)
|
||||||
|
- Importação: "Importar para Minha Conta" agora copia os anexos do lançamento-fonte para a conta de quem está importando (novo arquivo, novo `userId`, novo `fileKey` — cópia independente via S3 CopyObject). `createSchema` ganhou campo opcional `importFromTransactionId`; helper `copyAttachmentsForImport` valida acesso à fonte via ownership direto ou `payerShares`
|
||||||
|
- Importação: dialog "Importar para Minha Conta" exibe seção read-only "Anexos que serão copiados" listando os anexos do lançamento-fonte antes da confirmação
|
||||||
|
- Filtros: nova chave `isDivided` na tabela de lançamentos — toggle "Somente divididos" no drawer de filtros mantém o estado na URL
|
||||||
|
- Performance: prefetch server-side de mapeamentos Logo.dev no `/dashboard`, `/transactions` e `/payers/[payerId]` — uma única query SQL em batch (`fetchEstablishmentLogoMap`) semeia o cache do React Query antes do primeiro render, eliminando os N requests para `/api/logo/mapping`
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Anexos: `fetchTransactionAttachments` e `fetchTransactionAttachmentsAction` passam a autorizar leitura por acesso à transação (direto ou via `payerShares`), permitindo que pessoas com pagador compartilhado visualizem anexos de lançamentos divididos
|
||||||
|
- Anexos: upload (`confirmAttachmentUploadAction`) e detach em massa (`detachAttachmentBulkAction`) agora expandem `transactionIds` para incluir shares irmãs via `splitGroupId` — o vínculo em `transaction_attachments` é replicado para manter simetria
|
||||||
|
- Anexos: delete/detach continuam restritos ao criador (sem alteração de escrita); dashboard (`fetchAttachmentsForPeriod`) permanece listando apenas os anexos do próprio usuário
|
||||||
|
- Migração: lançamentos divididos criados antes desta versão ficam com `split_group_id` NULL e mantêm o comportamento antigo (anexos não visíveis para a contraparte); apenas splits novos são afetados
|
||||||
|
- Storage: `deleteS3Object` passa a ignorar `NoSuchKey` silenciosamente — providers S3-compatíveis (ex.: Cloudflare R2) lançam esse erro ao deletar objeto inexistente, ao contrário do comportamento idempotente do S3 padrão
|
||||||
|
- UI/Badges: `TransactionTypeBadge` redesenhado — substitui o `StatusDot` por ícones direcionais (`RiArrowRightDownLine` receita, `RiArrowRightUpLine` despesa, `RiArrowLeftRightLine` transferência), com borda visível, shadow sutil e variantes dark mode dessaturadas; rótulo "Transferência" abreviado para "Transf."
|
||||||
|
- UI/Forms: indicador do `RadioGroup` trocado de círculo (`RiCircleLine`) por check (`RiCheckLine`) com fundo sólido `primary` no estado selecionado
|
||||||
|
- UI/Antecipação: tabela de seleção de parcelas reduzida de quatro para três colunas (estabelecimento + fatura + valor) — informações de parcela e vencimento absorvidas pela coluna do estabelecimento
|
||||||
|
- Tipografia: fonte Inter agora carrega explicitamente os pesos 500, 600 e 700 (antes derivava de 400)
|
||||||
|
- Deps: better-auth 1.6.5 → 1.6.9, @aws-sdk/client-s3 3.1032 → 3.1037, @tanstack/react-query 5.99.2 → 5.100.3, @biomejs/biome 2.4.12 → 2.4.13, tailwindcss 4.2.2 → 4.2.4, resend 6.12.0 → 6.12.2
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Anexos: deleção em massa por série (`deleteTransactionBulkAction`) não chamava cleanup de storage — arquivos ficavam órfãos no S3 após apagar "este e futuros" ou "todos" de uma série parcelada/recorrente com anexo
|
||||||
|
- Anexos: deleção múltipla por seleção (`deleteMultipleTransactionsAction`) não chamava cleanup de storage — mesmo problema ao selecionar vários lançamentos com anexo e deletar em lote
|
||||||
|
- Anexos: reset de conta em Ajustes (`resetUserAppData`) não limpava o storage — todos os arquivos do usuário ficavam órfãos no S3 após a operação de zeragem
|
||||||
|
- Página da pessoa (`/payers/[payerId]`): `fetchPagadorLancamentos` agora calcula `hasAttachments` via `EXISTS`, fazendo o ícone de clipe aparecer na tabela de lançamentos (antes só aparecia em `/transactions`)
|
||||||
|
- Categorias: mensagem de sucesso ao atualizar exibia "Category atualizada com sucesso." — corrigido para "Categoria atualizada com sucesso."
|
||||||
|
- Antecipação: rótulos "Category" e "Período" no dialog corrigidos para "Categoria" e "Fatura"
|
||||||
|
- Docker: healthcheck do container `app` agora usa `127.0.0.1:3000` em vez de `localhost:3000`, evitando connection timeout em hosts com IPv6 (resolvendo [#44](https://github.com/felipegcoutinho/openmonetis/issues/44))
|
||||||
|
|
||||||
## [2.4.2] - 2026-04-20
|
## [2.4.2] - 2026-04-20
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ USER nextjs
|
|||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
CMD wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1
|
CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
|
||||||
|
|
||||||
# Entrypoint: roda migrations e depois executa o CMD
|
# Entrypoint: roda migrations e depois executa o CMD
|
||||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
|
|||||||
38
README.md
38
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/)
|
||||||
@@ -196,13 +196,10 @@ cp .env.example .env
|
|||||||
# 4. Suba o banco
|
# 4. Suba o banco
|
||||||
pnpm docker:db
|
pnpm docker:db
|
||||||
|
|
||||||
# 5. Habilite extensões do PostgreSQL (apenas no primeiro setup)
|
# 5. Aplique o schema no banco (apenas no primeiro setup)
|
||||||
pnpm db:extensions
|
|
||||||
|
|
||||||
# 6. Aplique o schema no banco (apenas no primeiro setup)
|
|
||||||
pnpm db:push
|
pnpm db:push
|
||||||
|
|
||||||
# 7. Inicie o app com hot-reload
|
# 6. Inicie o app com hot-reload
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -240,7 +237,6 @@ pnpm lint:fix # Biome auto-fix
|
|||||||
pnpm db:generate # Gerar migrations
|
pnpm db:generate # Gerar migrations
|
||||||
pnpm db:migrate # Executar migrations
|
pnpm db:migrate # Executar migrations
|
||||||
pnpm db:push # Push schema direto (dev)
|
pnpm db:push # Push schema direto (dev)
|
||||||
pnpm db:extensions # Habilitar extensões PostgreSQL (rodar uma vez)
|
|
||||||
pnpm db:studio # Drizzle Studio (UI visual)
|
pnpm db:studio # Drizzle Studio (UI visual)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -291,8 +287,7 @@ docker compose up -d app
|
|||||||
docker compose exec app sh # Shell da aplicação
|
docker compose exec app sh # Shell da aplicação
|
||||||
docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco
|
docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco
|
||||||
docker compose ps # Status
|
docker compose ps # Status
|
||||||
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup
|
pnpm backup # Backup (ver seção Backup)
|
||||||
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Customizando portas
|
### Customizando portas
|
||||||
@@ -318,9 +313,9 @@ Cada execução gera **3 arquivos** em `backup/`:
|
|||||||
|
|
||||||
| Arquivo | Conteúdo | Uso |
|
| Arquivo | Conteúdo | Uso |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom do PostgreSQL (binário) | Restore completo via `pg_restore` |
|
| `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom dos schemas `public` + `drizzle` | Restore completo via `pg_restore` |
|
||||||
| `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL completo compactado | Inspeção manual, portabilidade |
|
| `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL compactado dos schemas `public` + `drizzle` | Inspeção manual, portabilidade |
|
||||||
| `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados da schema `public` | Migração parcial, seed de outro ambiente |
|
| `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados do schema `public` (sem DDL) | Migração parcial, seed de outro ambiente |
|
||||||
|
|
||||||
### Modos de conexão
|
### Modos de conexão
|
||||||
|
|
||||||
@@ -354,16 +349,19 @@ crontab -e
|
|||||||
### Restore
|
### Restore
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# A partir do .dump (recomendado — mais rápido)
|
# 1. Zerar o banco
|
||||||
pg_restore --clean --no-owner --no-privileges \
|
docker exec <container-db> psql -U openmonetis -d openmonetis_db \
|
||||||
-d "postgresql://user:senha@host:5432/openmonetis_db" \
|
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||||
backup/openmonetis_YYYY-MM-DD_HH-MM.dump
|
|
||||||
|
|
||||||
# A partir do .sql.gz (banco local via Docker)
|
# 2. Restaurar schema + dados (um comando)
|
||||||
gunzip -c backup/openmonetis_YYYY-MM-DD_HH-MM.sql.gz | \
|
docker exec -i <container-db> pg_restore \
|
||||||
docker compose exec -T db psql -U openmonetis -d openmonetis_db
|
-U openmonetis -d openmonetis_db \
|
||||||
|
--clean --if-exists --disable-triggers --no-owner --no-privileges \
|
||||||
|
< backup/openmonetis_YYYY-MM-DD_HH-MM.dump
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `--disable-triggers` é necessário para evitar erros de FK durante o restore (os dados são inseridos fora de ordem). O usuário `openmonetis` tem permissão para isso.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ☁️ Storage S3 Compatível
|
## ☁️ Storage S3 Compatível
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
required: false
|
required: false
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
echo "Habilitando extensão pgcrypto..."
|
|
||||||
node -e "
|
|
||||||
const { Client } = require('/app/migrate/node_modules/pg');
|
|
||||||
const c = new Client({ connectionString: process.env.DATABASE_URL });
|
|
||||||
c.connect()
|
|
||||||
.then(() => c.query('CREATE EXTENSION IF NOT EXISTS pgcrypto'))
|
|
||||||
.then(() => c.end())
|
|
||||||
.catch((e) => { console.error('Aviso pgcrypto:', e.message); process.exit(0); });
|
|
||||||
"
|
|
||||||
|
|
||||||
echo "Rodando migrations..."
|
echo "Rodando migrations..."
|
||||||
MIGRATED=0
|
MIGRATED=0
|
||||||
for i in 1 2 3 4 5; do
|
for i in 1 2 3 4 5; do
|
||||||
|
|||||||
2
drizzle/0026_bored_eternity.sql
Normal file
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
1
drizzle/0028_fancy_reaper.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "pagadores" ALTER COLUMN "share_code" DROP DEFAULT;
|
||||||
2916
drizzle/meta/0026_snapshot.json
Normal file
2916
drizzle/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2915
drizzle/meta/0028_snapshot.json
Normal file
2915
drizzle/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,20 @@
|
|||||||
"when": 1776351838548,
|
"when": 1776351838548,
|
||||||
"tag": "0025_burly_colonel_america",
|
"tag": "0025_burly_colonel_america",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777042423451,
|
||||||
|
"tag": "0026_bored_eternity",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777153372633,
|
||||||
|
"tag": "0028_fancy_reaper",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/**"),
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.4.2",
|
"version": "2.4.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
||||||
"docker:up": "docker compose up -d",
|
"docker:up": "docker compose up -d",
|
||||||
@@ -35,9 +34,9 @@
|
|||||||
"@ai-sdk/anthropic": "^3.0.71",
|
"@ai-sdk/anthropic": "^3.0.71",
|
||||||
"@ai-sdk/google": "^3.0.64",
|
"@ai-sdk/google": "^3.0.64",
|
||||||
"@ai-sdk/openai": "^3.0.53",
|
"@ai-sdk/openai": "^3.0.53",
|
||||||
"@aws-sdk/client-s3": "^3.1032.0",
|
"@aws-sdk/client-s3": "^3.1037.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1032.0",
|
"@aws-sdk/s3-request-presigner": "^3.1037.0",
|
||||||
"@better-auth/passkey": "^1.6.5",
|
"@better-auth/passkey": "^1.6.9",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -64,11 +63,11 @@
|
|||||||
"@radix-ui/react-toggle-group": "1.1.11",
|
"@radix-ui/react-toggle-group": "1.1.11",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "^5.99.2",
|
"@tanstack/react-query": "^5.100.3",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"ai": "^6.0.168",
|
"ai": "^6.0.168",
|
||||||
"better-auth": "1.6.5",
|
"better-auth": "1.6.9",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -86,7 +85,7 @@
|
|||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "19.2.5",
|
"react-dom": "19.2.5",
|
||||||
"recharts": "3.8.1",
|
"recharts": "3.8.1",
|
||||||
"resend": "^6.12.0",
|
"resend": "^6.12.2",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
@@ -98,8 +97,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.12",
|
"@biomejs/biome": "2.4.13",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.2.4",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "25.6.0",
|
"@types/node": "25.6.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
@@ -107,8 +106,8 @@
|
|||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"knip": "^6.4.1",
|
"knip": "^6.7.0",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.2.4",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
}
|
}
|
||||||
|
|||||||
1217
pnpm-lock.yaml
generated
1217
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB |
3
public/images/logo_small.svg
Normal file
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 |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
3
public/images/logo_text.svg
Normal file
3
public/images/logo_text.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.7 KiB |
@@ -58,21 +58,26 @@ DATA_FILE="$BACKUP_DIR/openmonetis_${TIMESTAMP}.data.sql.gz"
|
|||||||
|
|
||||||
log "Iniciando backup (modo: $DB_MODE)..."
|
log "Iniciando backup (modo: $DB_MODE)..."
|
||||||
|
|
||||||
|
# Schemas relevantes do OpenMonetis (descarta lixo Supabase: auth, realtime, storage, vault, graphql, etc.)
|
||||||
|
SCHEMA_FLAGS=(--schema=public --schema=drizzle)
|
||||||
|
|
||||||
# --- Dump ---
|
# --- Dump ---
|
||||||
if [[ "$DB_MODE" == "remote" ]]; then
|
if [[ "$DB_MODE" == "remote" ]]; then
|
||||||
# --no-owner --no-privileges: necessário no Supabase (roles gerenciados internamente)
|
# --no-owner --no-privileges: necessário no Supabase (roles gerenciados internamente)
|
||||||
pg_dump --format=custom --no-owner --no-privileges \
|
pg_dump --format=custom --no-owner --no-privileges \
|
||||||
|
"${SCHEMA_FLAGS[@]}" \
|
||||||
"$REMOTE_DB_URL" > "$DUMP_FILE"
|
"$REMOTE_DB_URL" > "$DUMP_FILE"
|
||||||
|
|
||||||
pg_dump --no-owner --no-privileges \
|
pg_dump --no-owner --no-privileges \
|
||||||
|
"${SCHEMA_FLAGS[@]}" \
|
||||||
"$REMOTE_DB_URL" | gzip > "$SQL_FILE"
|
"$REMOTE_DB_URL" | gzip > "$SQL_FILE"
|
||||||
|
|
||||||
elif [[ "$DB_MODE" == "docker" ]]; then
|
elif [[ "$DB_MODE" == "docker" ]]; then
|
||||||
docker exec "$DOCKER_CONTAINER" pg_dump \
|
docker exec "$DOCKER_CONTAINER" pg_dump \
|
||||||
-U "$DOCKER_DB_USER" -Fc "$DOCKER_DB_NAME" > "$DUMP_FILE"
|
-U "$DOCKER_DB_USER" -Fc "${SCHEMA_FLAGS[@]}" "$DOCKER_DB_NAME" > "$DUMP_FILE"
|
||||||
|
|
||||||
docker exec "$DOCKER_CONTAINER" pg_dump \
|
docker exec "$DOCKER_CONTAINER" pg_dump \
|
||||||
-U "$DOCKER_DB_USER" "$DOCKER_DB_NAME" | gzip > "$SQL_FILE"
|
-U "$DOCKER_DB_USER" "${SCHEMA_FLAGS[@]}" "$DOCKER_DB_NAME" | gzip > "$SQL_FILE"
|
||||||
|
|
||||||
else
|
else
|
||||||
log "ERRO: DB_MODE inválido ('$DB_MODE'). Use 'remote' ou 'docker'."
|
log "ERRO: DB_MODE inválido ('$DB_MODE'). Use 'remote' ou 'docker'."
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
PAYER_ROLE_THIRD_PARTY,
|
PAYER_ROLE_THIRD_PARTY,
|
||||||
PAYER_STATUS_OPTIONS,
|
PAYER_STATUS_OPTIONS,
|
||||||
} from "@/shared/lib/payers/constants";
|
} from "@/shared/lib/payers/constants";
|
||||||
|
import { generateShareCode } from "@/shared/lib/payers/share-code";
|
||||||
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
|
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
|
||||||
import {
|
import {
|
||||||
addMonthsToDate,
|
addMonthsToDate,
|
||||||
@@ -537,6 +538,7 @@ async function ensureAdminPayer(targetUser: typeof user.$inferSelect) {
|
|||||||
note: null,
|
note: null,
|
||||||
role: PAYER_ROLE_ADMIN,
|
role: PAYER_ROLE_ADMIN,
|
||||||
isAutoSend: false,
|
isAutoSend: false,
|
||||||
|
shareCode: generateShareCode(),
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
})
|
})
|
||||||
.returning({ id: payers.id, name: payers.name });
|
.returning({ id: payers.id, name: payers.name });
|
||||||
@@ -870,6 +872,7 @@ async function main() {
|
|||||||
note: definition.note,
|
note: definition.note,
|
||||||
role: PAYER_ROLE_THIRD_PARTY,
|
role: PAYER_ROLE_THIRD_PARTY,
|
||||||
isAutoSend: definition.isAutoSend,
|
isAutoSend: definition.isAutoSend,
|
||||||
|
shareCode: generateShareCode(),
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
})
|
})
|
||||||
.returning({ id: payers.id });
|
.returning({ id: payers.id });
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import { config } from "dotenv";
|
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
|
||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
// Load environment variables from .env
|
|
||||||
config();
|
|
||||||
|
|
||||||
async function initDatabase() {
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
|
||||||
|
|
||||||
if (!databaseUrl) {
|
|
||||||
console.error("DATABASE_URL environment variable is required");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pool = new Pool({ connectionString: databaseUrl });
|
|
||||||
const db = drizzle(pool);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("🔧 Initializing database extensions...");
|
|
||||||
|
|
||||||
// Read and execute init.sql as a single query
|
|
||||||
const initSqlPath = path.join(
|
|
||||||
process.cwd(),
|
|
||||||
"scripts",
|
|
||||||
"postgres",
|
|
||||||
"init.sql",
|
|
||||||
);
|
|
||||||
const initSql = fs.readFileSync(initSqlPath, "utf-8");
|
|
||||||
|
|
||||||
console.log("Executing init.sql...");
|
|
||||||
await db.execute(initSql);
|
|
||||||
|
|
||||||
console.log("✅ Database initialization completed");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Database initialization failed:", error);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initDatabase();
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
-- Script de inicialização do PostgreSQL para Docker
|
|
||||||
-- Este script é executado automaticamente quando o banco é criado pela primeira vez
|
|
||||||
|
|
||||||
-- Habilitar extensão pgcrypto (necessária para gen_random_bytes usado pelo Drizzle)
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
-- Log de sucesso
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE '✅ Extensão pgcrypto habilitada com sucesso';
|
|
||||||
END $$;
|
|
||||||
@@ -2,10 +2,13 @@ import { connection } from "next/server";
|
|||||||
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
|
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
|
||||||
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
|
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
|
||||||
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
||||||
|
import { extractDashboardLogoNames } from "@/features/dashboard/extract-logo-names";
|
||||||
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
|
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
|
||||||
import { getSingleParam } from "@/features/transactions/page-helpers";
|
import { getSingleParam } from "@/features/transactions/page-helpers";
|
||||||
|
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||||
import { parsePeriodParam } from "@/shared/utils/period";
|
import { parsePeriodParam } from "@/shared/utils/period";
|
||||||
|
|
||||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||||
@@ -25,17 +28,24 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
await fetchDashboardPageData(user.id, selectedPeriod);
|
await fetchDashboardPageData(user.id, selectedPeriod);
|
||||||
const { dashboardWidgets } = preferences;
|
const { dashboardWidgets } = preferences;
|
||||||
|
|
||||||
|
const logoMappings = await prefetchLogoMappings(
|
||||||
|
user.id,
|
||||||
|
extractDashboardLogoNames(dashboardData),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4">
|
<main className="flex flex-col gap-4">
|
||||||
<DashboardWelcome name={user.name} />
|
<DashboardWelcome name={user.name} />
|
||||||
<MonthNavigation />
|
<MonthNavigation />
|
||||||
<DashboardMetricsCards metrics={dashboardData.metrics} />
|
<DashboardMetricsCards metrics={dashboardData.metrics} />
|
||||||
<DashboardGridEditable
|
<LogoPrefetchProvider mappings={logoMappings}>
|
||||||
data={dashboardData}
|
<DashboardGridEditable
|
||||||
period={selectedPeriod}
|
data={dashboardData}
|
||||||
initialPreferences={dashboardWidgets}
|
period={selectedPeriod}
|
||||||
quickActionOptions={quickActionOptions}
|
initialPreferences={dashboardWidgets}
|
||||||
/>
|
quickActionOptions={quickActionOptions}
|
||||||
|
/>
|
||||||
|
</LogoPrefetchProvider>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
fetchRecentEstablishments,
|
fetchRecentEstablishments,
|
||||||
fetchTransactionFilterSources,
|
fetchTransactionFilterSources,
|
||||||
} from "@/features/transactions/queries";
|
} from "@/features/transactions/queries";
|
||||||
|
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||||
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
||||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@/shared/components/ui/tabs";
|
} from "@/shared/components/ui/tabs";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||||
import { getPayerAccess } from "@/shared/lib/payers/access";
|
import { getPayerAccess } from "@/shared/lib/payers/access";
|
||||||
import {
|
import {
|
||||||
fetchPagadorBoletoItems,
|
fetchPagadorBoletoItems,
|
||||||
@@ -82,6 +84,7 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
|||||||
searchFilter: null,
|
searchFilter: null,
|
||||||
settledFilter: null,
|
settledFilter: null,
|
||||||
attachmentFilter: null,
|
attachmentFilter: null,
|
||||||
|
dividedFilter: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEmptySlugMaps = (): SlugMaps => ({
|
const createEmptySlugMaps = (): SlugMaps => ({
|
||||||
@@ -307,104 +310,113 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
lancamentoCount: transactionData.length,
|
lancamentoCount: transactionData.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const logoMappings = await prefetchLogoMappings(dataOwnerId, [
|
||||||
|
...transactionData.map((t) => t.name),
|
||||||
|
...boletoItems.map((b) => b.name),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-6">
|
<main className="flex flex-col gap-6">
|
||||||
<MonthNavigation />
|
<MonthNavigation />
|
||||||
|
|
||||||
<Tabs defaultValue="profile" className="w-full">
|
<LogoPrefetchProvider mappings={logoMappings}>
|
||||||
<TabsList className="mb-2">
|
<Tabs defaultValue="profile" className="w-full">
|
||||||
<TabsTrigger value="profile">Perfil</TabsTrigger>
|
<TabsList className="mb-2">
|
||||||
<TabsTrigger value="painel">Painel</TabsTrigger>
|
<TabsTrigger value="profile">Perfil</TabsTrigger>
|
||||||
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
|
<TabsTrigger value="painel">Painel</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
|
||||||
<PayerHeaderCard
|
</TabsList>
|
||||||
payer={payerData}
|
<PayerHeaderCard
|
||||||
selectedPeriod={selectedPeriod}
|
payer={payerData}
|
||||||
summary={summaryPreview}
|
selectedPeriod={selectedPeriod}
|
||||||
/>
|
summary={summaryPreview}
|
||||||
|
/>
|
||||||
|
|
||||||
<TabsContent value="profile" className="space-y-4">
|
<TabsContent value="profile" className="space-y-4">
|
||||||
<PagadorInfoCard payer={payerData} />
|
<PagadorInfoCard payer={payerData} />
|
||||||
{canEdit && payerData.shareCode ? (
|
{canEdit && payerData.shareCode ? (
|
||||||
<PayerSharingCard
|
<PayerSharingCard
|
||||||
payerId={pagador.id}
|
payerId={pagador.id}
|
||||||
shareCode={payerData.shareCode}
|
shareCode={payerData.shareCode}
|
||||||
shares={payerSharesData}
|
shares={payerSharesData}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!canEdit && currentUserShare ? (
|
{!canEdit && currentUserShare ? (
|
||||||
<PayerLeaveShareCard
|
<PayerLeaveShareCard
|
||||||
shareId={currentUserShare.id}
|
shareId={currentUserShare.id}
|
||||||
pagadorName={payerData.name}
|
pagadorName={payerData.name}
|
||||||
createdAt={currentUserShare.createdAt}
|
createdAt={currentUserShare.createdAt}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="painel" className="space-y-4">
|
<TabsContent value="painel" className="space-y-4">
|
||||||
<section className="grid gap-3 lg:grid-cols-2">
|
<section className="grid gap-3 lg:grid-cols-2">
|
||||||
<PayerMonthlySummaryCard
|
<PayerMonthlySummaryCard
|
||||||
periodLabel={periodLabel}
|
periodLabel={periodLabel}
|
||||||
breakdown={monthlyBreakdown}
|
breakdown={monthlyBreakdown}
|
||||||
/>
|
/>
|
||||||
<PayerHistoryCard data={historyData} />
|
<PayerHistoryCard data={historyData} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-3 lg:grid-cols-3">
|
<section className="grid gap-3 lg:grid-cols-3">
|
||||||
<ExpandableWidgetCard
|
<ExpandableWidgetCard
|
||||||
title="Minhas Faturas"
|
title="Minhas Faturas"
|
||||||
subtitle="Valores por cartão neste período"
|
subtitle="Valores por cartão neste período"
|
||||||
icon={<RiBankCard2Line className="size-4" />}
|
icon={<RiBankCard2Line className="size-4" />}
|
||||||
>
|
>
|
||||||
<PayerCardUsageCard items={cardUsage} />
|
<PayerCardUsageCard items={cardUsage} />
|
||||||
</ExpandableWidgetCard>
|
</ExpandableWidgetCard>
|
||||||
<ExpandableWidgetCard
|
<ExpandableWidgetCard
|
||||||
title="Boletos"
|
title="Boletos"
|
||||||
subtitle="Boletos registrados neste período"
|
subtitle="Boletos registrados neste período"
|
||||||
icon={<RiBarcodeLine className="size-4" />}
|
icon={<RiBarcodeLine className="size-4" />}
|
||||||
>
|
>
|
||||||
<PayerBoletoCard items={boletoItems} />
|
<PayerBoletoCard items={boletoItems} />
|
||||||
</ExpandableWidgetCard>
|
</ExpandableWidgetCard>
|
||||||
<ExpandableWidgetCard
|
<ExpandableWidgetCard
|
||||||
title="Status de Pagamento"
|
title="Status de Pagamento"
|
||||||
subtitle="Situação das despesas no período"
|
subtitle="Situação das despesas no período"
|
||||||
icon={<RiWallet3Line className="size-4" />}
|
icon={<RiWallet3Line className="size-4" />}
|
||||||
>
|
>
|
||||||
<PayerPaymentStatusCard data={paymentStatus} />
|
<PayerPaymentStatusCard data={paymentStatus} />
|
||||||
</ExpandableWidgetCard>
|
</ExpandableWidgetCard>
|
||||||
</section>
|
</section>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="lancamentos">
|
<TabsContent value="lancamentos">
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<LancamentosSection
|
<LancamentosSection
|
||||||
currentUserId={userId}
|
currentUserId={userId}
|
||||||
transactions={transactionData}
|
transactions={transactionData}
|
||||||
payerOptions={optionSets.payerOptions}
|
payerOptions={optionSets.payerOptions}
|
||||||
splitPayerOptions={optionSets.splitPayerOptions}
|
splitPayerOptions={optionSets.splitPayerOptions}
|
||||||
defaultPayerId={pagador.id}
|
defaultPayerId={pagador.id}
|
||||||
accountOptions={optionSets.accountOptions}
|
accountOptions={optionSets.accountOptions}
|
||||||
cardOptions={optionSets.cardOptions}
|
cardOptions={optionSets.cardOptions}
|
||||||
categoryOptions={optionSets.categoryOptions}
|
categoryOptions={optionSets.categoryOptions}
|
||||||
payerFilterOptions={payerFilterOptions}
|
payerFilterOptions={payerFilterOptions}
|
||||||
categoryFilterOptions={optionSets.categoryFilterOptions}
|
categoryFilterOptions={optionSets.categoryFilterOptions}
|
||||||
accountCardFilterOptions={optionSets.accountCardFilterOptions}
|
accountCardFilterOptions={optionSets.accountCardFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
allowCreate={canEdit}
|
allowCreate={canEdit}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
||||||
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
|
importSplitPayerOptions={
|
||||||
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
loggedUserOptionSets?.splitPayerOptions
|
||||||
importAccountOptions={loggedUserOptionSets?.accountOptions}
|
}
|
||||||
importCardOptions={loggedUserOptionSets?.cardOptions}
|
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
||||||
importCategoryOptions={loggedUserOptionSets?.categoryOptions}
|
importAccountOptions={loggedUserOptionSets?.accountOptions}
|
||||||
/>
|
importCardOptions={loggedUserOptionSets?.cardOptions}
|
||||||
</section>
|
importCategoryOptions={loggedUserOptionSets?.categoryOptions}
|
||||||
</TabsContent>
|
/>
|
||||||
</Tabs>
|
</section>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</LogoPrefetchProvider>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,9 +236,7 @@ export const payers = pgTable(
|
|||||||
note: text("anotacao"),
|
note: text("anotacao"),
|
||||||
role: text("role"),
|
role: text("role"),
|
||||||
isAutoSend: boolean("is_auto_send").notNull().default(false),
|
isAutoSend: boolean("is_auto_send").notNull().default(false),
|
||||||
shareCode: text("share_code")
|
shareCode: text("share_code").notNull(),
|
||||||
.notNull()
|
|
||||||
.default(sql`substr(encode(gen_random_bytes(24), 'base64'), 1, 24)`),
|
|
||||||
lastMailAt: timestamp("last_mail", {
|
lastMailAt: timestamp("last_mail", {
|
||||||
mode: "date",
|
mode: "date",
|
||||||
withTimezone: true,
|
withTimezone: true,
|
||||||
@@ -670,6 +668,7 @@ export const transactions = pgTable(
|
|||||||
onUpdate: "cascade",
|
onUpdate: "cascade",
|
||||||
}),
|
}),
|
||||||
seriesId: uuid("series_id"),
|
seriesId: uuid("series_id"),
|
||||||
|
splitGroupId: uuid("split_group_id"),
|
||||||
transferId: uuid("transfer_id"),
|
transferId: uuid("transfer_id"),
|
||||||
ofxFitId: text("ofx_fit_id"),
|
ofxFitId: text("ofx_fit_id"),
|
||||||
importBatchId: text("import_batch_id"),
|
importBatchId: text("import_batch_id"),
|
||||||
@@ -702,6 +701,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)
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export async function updateCategoryAction(
|
|||||||
|
|
||||||
revalidateForEntity("categories", user.id);
|
revalidateForEntity("categories", user.id);
|
||||||
|
|
||||||
return { success: true, message: "Category atualizada com sucesso." };
|
return { success: true, message: "Categoria atualizada com sucesso." };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleActionError(error);
|
return handleActionError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/features/dashboard/extract-logo-names.ts
Normal file
28
src/features/dashboard/extract-logo-names.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { DashboardData } from "./fetch-dashboard-data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coleta todos os nomes de estabelecimentos exibidos nos widgets do
|
||||||
|
* dashboard que renderizam `<EstablishmentLogo />`. Usado para
|
||||||
|
* pré-resolver os mapeamentos Logo.dev no servidor.
|
||||||
|
*/
|
||||||
|
export function extractDashboardLogoNames(data: DashboardData): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
|
||||||
|
for (const bill of data.billsSnapshot.bills) names.push(bill.name);
|
||||||
|
for (const expense of data.recurringExpensesData.expenses)
|
||||||
|
names.push(expense.name);
|
||||||
|
for (const expense of data.installmentExpensesData.expenses)
|
||||||
|
names.push(expense.name);
|
||||||
|
for (const establishment of data.topEstablishmentsData.establishments)
|
||||||
|
names.push(establishment.name);
|
||||||
|
for (const expense of data.topExpensesAll.expenses) names.push(expense.name);
|
||||||
|
for (const expense of data.topExpensesCardOnly.expenses)
|
||||||
|
names.push(expense.name);
|
||||||
|
for (const transactions of Object.values(
|
||||||
|
data.purchasesByCategoryData.transactionsByCategory,
|
||||||
|
)) {
|
||||||
|
for (const transaction of transactions) names.push(transaction.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomBytes } from "node:crypto";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -17,6 +16,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 { normalizeAvatarPath } from "@/shared/lib/payers/utils";
|
import { normalizeAvatarPath } from "@/shared/lib/payers/utils";
|
||||||
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
||||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
@@ -83,12 +83,6 @@ type ShareCodeRegenerateInput = z.infer<typeof shareCodeRegenerateSchema>;
|
|||||||
|
|
||||||
const revalidate = (userId: string) => revalidateForEntity("payers", userId);
|
const revalidate = (userId: string) => revalidateForEntity("payers", userId);
|
||||||
|
|
||||||
const generateShareCode = () => {
|
|
||||||
// base64url já retorna apenas [a-zA-Z0-9_-]
|
|
||||||
// 18 bytes = 24 caracteres em base64
|
|
||||||
return randomBytes(18).toString("base64url").slice(0, 24);
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createPayerAction(
|
export async function createPayerAction(
|
||||||
input: CreateInput,
|
input: CreateInput,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { and, desc, eq, type SQL } from "drizzle-orm";
|
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
cards,
|
cards,
|
||||||
categories,
|
categories,
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
payerShares,
|
payerShares,
|
||||||
payers,
|
payers,
|
||||||
|
transactionAttachments,
|
||||||
transactions,
|
transactions,
|
||||||
user as usersTable,
|
user as usersTable,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
@@ -73,6 +74,10 @@ export async function fetchPagadorLancamentos(filters: SQL[]) {
|
|||||||
financialAccount: financialAccounts,
|
financialAccount: financialAccounts,
|
||||||
card: cards,
|
card: cards,
|
||||||
category: categories,
|
category: categories,
|
||||||
|
hasAttachments: sql<boolean>`EXISTS (
|
||||||
|
SELECT 1 FROM ${transactionAttachments}
|
||||||
|
WHERE ${transactionAttachments.transactionId} = ${transactions.id}
|
||||||
|
)`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.leftJoin(payers, eq(transactions.payerId, payers.id))
|
.leftJoin(payers, eq(transactions.payerId, payers.id))
|
||||||
@@ -85,12 +90,12 @@ export async function fetchPagadorLancamentos(filters: SQL[]) {
|
|||||||
.where(and(...filters))
|
.where(and(...filters))
|
||||||
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
|
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
|
||||||
|
|
||||||
// Transformar resultado para o formato esperado
|
|
||||||
return transactionRows.map((row) => ({
|
return transactionRows.map((row) => ({
|
||||||
...row.transaction,
|
...row.transaction,
|
||||||
payer: row.payer,
|
payer: row.payer,
|
||||||
financialAccount: row.financialAccount,
|
financialAccount: row.financialAccount,
|
||||||
card: row.card,
|
card: row.card,
|
||||||
category: row.category,
|
category: row.category,
|
||||||
|
hasAttachments: row.hasAttachments,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,8 +224,8 @@ export function CategoryReportExport({
|
|||||||
const doc = new jsPDF({ orientation: "landscape" });
|
const doc = new jsPDF({ orientation: "landscape" });
|
||||||
const primaryColor = getPrimaryPdfColor();
|
const primaryColor = getPrimaryPdfColor();
|
||||||
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
|
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
|
||||||
loadExportLogoDataUrl("/images/logo_small.png"),
|
loadExportLogoDataUrl("/images/logo_small.svg"),
|
||||||
loadExportLogoDataUrl("/images/logo_text.png"),
|
loadExportLogoDataUrl("/images/logo_text.svg"),
|
||||||
]);
|
]);
|
||||||
let brandingEndX = 14;
|
let brandingEndX = 14;
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
PAYER_STATUS_OPTIONS,
|
PAYER_STATUS_OPTIONS,
|
||||||
} from "@/shared/lib/payers/constants";
|
} from "@/shared/lib/payers/constants";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
import { generateShareCode } from "@/shared/lib/payers/share-code";
|
||||||
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
|
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
|
||||||
|
import { deleteS3Object } from "@/shared/lib/storage/presign";
|
||||||
|
|
||||||
type ActionResponse<T = void> = {
|
type ActionResponse<T = void> = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -85,6 +87,11 @@ async function resetUserAppData(
|
|||||||
const avatarUrl = user.image ?? DEFAULT_PAYER_AVATAR;
|
const avatarUrl = user.image ?? DEFAULT_PAYER_AVATAR;
|
||||||
const defaultPayerStatus = PAYER_STATUS_OPTIONS[0];
|
const defaultPayerStatus = PAYER_STATUS_OPTIONS[0];
|
||||||
|
|
||||||
|
const userAttachments = await db
|
||||||
|
.select({ id: schema.attachments.id, fileKey: schema.attachments.fileKey })
|
||||||
|
.from(schema.attachments)
|
||||||
|
.where(eq(schema.attachments.userId, userId));
|
||||||
|
|
||||||
await db.transaction(async (tx: typeof db) => {
|
await db.transaction(async (tx: typeof db) => {
|
||||||
await tx
|
await tx
|
||||||
.delete(schema.payerShares)
|
.delete(schema.payerShares)
|
||||||
@@ -115,6 +122,9 @@ async function resetUserAppData(
|
|||||||
await tx
|
await tx
|
||||||
.delete(schema.transactions)
|
.delete(schema.transactions)
|
||||||
.where(eq(schema.transactions.userId, userId));
|
.where(eq(schema.transactions.userId, userId));
|
||||||
|
await tx
|
||||||
|
.delete(schema.attachments)
|
||||||
|
.where(eq(schema.attachments.userId, userId));
|
||||||
await tx.delete(schema.invoices).where(eq(schema.invoices.userId, userId));
|
await tx.delete(schema.invoices).where(eq(schema.invoices.userId, userId));
|
||||||
await tx.delete(schema.cards).where(eq(schema.cards.userId, userId));
|
await tx.delete(schema.cards).where(eq(schema.cards.userId, userId));
|
||||||
await tx
|
await tx
|
||||||
@@ -144,9 +154,18 @@ async function resetUserAppData(
|
|||||||
note: null,
|
note: null,
|
||||||
role: PAYER_ROLE_ADMIN,
|
role: PAYER_ROLE_ADMIN,
|
||||||
isAutoSend: false,
|
isAutoSend: false,
|
||||||
|
shareCode: generateShareCode(),
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
userAttachments.map((att) =>
|
||||||
|
deleteS3Object(att.fileKey).catch((err) => {
|
||||||
|
console.error("Falha ao remover anexo do S3 no reset:", err);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
deleteTransactionAction as deleteTransactionActionImpl,
|
deleteTransactionAction as deleteTransactionActionImpl,
|
||||||
toggleTransactionSettlementAction as toggleTransactionSettlementActionImpl,
|
toggleTransactionSettlementAction as toggleTransactionSettlementActionImpl,
|
||||||
updateTransactionAction as updateTransactionActionImpl,
|
updateTransactionAction as updateTransactionActionImpl,
|
||||||
|
updateTransactionSplitPairAction as updateTransactionSplitPairActionImpl,
|
||||||
} from "./actions/single-actions";
|
} from "./actions/single-actions";
|
||||||
|
|
||||||
export async function createTransactionAction(
|
export async function createTransactionAction(
|
||||||
@@ -62,6 +63,12 @@ export async function deleteMultipleTransactionsAction(
|
|||||||
return deleteMultipleTransactionsActionImpl(...args);
|
return deleteMultipleTransactionsActionImpl(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTransactionSplitPairAction(
|
||||||
|
...args: Parameters<typeof updateTransactionSplitPairActionImpl>
|
||||||
|
): ReturnType<typeof updateTransactionSplitPairActionImpl> {
|
||||||
|
return updateTransactionSplitPairActionImpl(...args);
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportTransactionsDataAction(
|
export async function exportTransactionsDataAction(
|
||||||
...args: Parameters<typeof exportTransactionsDataActionImpl>
|
...args: Parameters<typeof exportTransactionsDataActionImpl>
|
||||||
): ReturnType<typeof exportTransactionsDataActionImpl> {
|
): ReturnType<typeof exportTransactionsDataActionImpl> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import crypto, { randomUUID } from "node:crypto";
|
import crypto, { randomUUID } from "node:crypto";
|
||||||
import { and, count, eq, inArray } from "drizzle-orm";
|
import { and, count, eq, inArray, isNotNull } from "drizzle-orm";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
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 {
|
import {
|
||||||
createPresignedGetUrl,
|
|
||||||
createPresignedPutUrl,
|
createPresignedPutUrl,
|
||||||
deleteS3Object,
|
deleteS3Object,
|
||||||
headS3Object,
|
headS3Object,
|
||||||
@@ -98,6 +97,46 @@ function signUploadToken(payload: UploadTokenPayload): string {
|
|||||||
return `${encodedPayload}.${signature}`;
|
return `${encodedPayload}.${signature}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expandSplitSiblings(
|
||||||
|
transactionIds: string[],
|
||||||
|
userId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (transactionIds.length === 0) return transactionIds;
|
||||||
|
|
||||||
|
const groupRows = await db
|
||||||
|
.select({ splitGroupId: transactions.splitGroupId })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(transactions.id, transactionIds),
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
isNotNull(transactions.splitGroupId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const splitGroupIds = [
|
||||||
|
...new Set(
|
||||||
|
groupRows
|
||||||
|
.map((r) => r.splitGroupId)
|
||||||
|
.filter((v): v is string => v !== null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (splitGroupIds.length === 0) return transactionIds;
|
||||||
|
|
||||||
|
const siblingRows = await db
|
||||||
|
.select({ id: transactions.id })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(transactions.splitGroupId, splitGroupIds),
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...new Set([...transactionIds, ...siblingRows.map((r) => r.id)])];
|
||||||
|
}
|
||||||
|
|
||||||
function verifyUploadToken(token: string): UploadTokenPayload | null {
|
function verifyUploadToken(token: string): UploadTokenPayload | null {
|
||||||
try {
|
try {
|
||||||
const [encodedPayload, signature] = token.split(".");
|
const [encodedPayload, signature] = token.split(".");
|
||||||
@@ -281,6 +320,8 @@ export async function confirmAttachmentUploadAction(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transactionIds = await expandSplitSiblings(transactionIds, user.id);
|
||||||
|
|
||||||
await db.insert(transactionAttachments).values(
|
await db.insert(transactionAttachments).values(
|
||||||
transactionIds.map((tid) => ({
|
transactionIds.map((tid) => ({
|
||||||
transactionId: tid,
|
transactionId: tid,
|
||||||
@@ -359,69 +400,6 @@ export async function detachTransactionAttachmentAction(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTransactionAttachmentsAction(
|
|
||||||
transactionId: string,
|
|
||||||
): Promise<
|
|
||||||
Array<{
|
|
||||||
attachmentId: string;
|
|
||||||
fileName: string;
|
|
||||||
fileSize: number;
|
|
||||||
mimeType: string;
|
|
||||||
createdAt: Date;
|
|
||||||
url: string;
|
|
||||||
}>
|
|
||||||
> {
|
|
||||||
const user = await getUser();
|
|
||||||
|
|
||||||
const [transaction] = await db
|
|
||||||
.select({ id: transactions.id })
|
|
||||||
.from(transactions)
|
|
||||||
.where(
|
|
||||||
and(eq(transactions.id, transactionId), eq(transactions.userId, user.id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!transaction) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
attachmentId: transactionAttachments.attachmentId,
|
|
||||||
fileName: attachments.fileName,
|
|
||||||
fileSize: attachments.fileSize,
|
|
||||||
mimeType: attachments.mimeType,
|
|
||||||
fileKey: attachments.fileKey,
|
|
||||||
createdAt: attachments.createdAt,
|
|
||||||
})
|
|
||||||
.from(transactionAttachments)
|
|
||||||
.innerJoin(
|
|
||||||
transactions,
|
|
||||||
and(
|
|
||||||
eq(transactionAttachments.transactionId, transactions.id),
|
|
||||||
eq(transactions.userId, user.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.innerJoin(
|
|
||||||
attachments,
|
|
||||||
and(
|
|
||||||
eq(transactionAttachments.attachmentId, attachments.id),
|
|
||||||
eq(attachments.userId, user.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.where(eq(transactionAttachments.transactionId, transactionId));
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
rows.map(async (row) => ({
|
|
||||||
attachmentId: row.attachmentId,
|
|
||||||
fileName: row.fileName,
|
|
||||||
fileSize: row.fileSize,
|
|
||||||
mimeType: row.mimeType,
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
url: await createPresignedGetUrl(row.fileKey),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const detachBulkSchema = z.object({
|
const detachBulkSchema = z.object({
|
||||||
attachmentId: z.string().uuid(),
|
attachmentId: z.string().uuid(),
|
||||||
transactionId: z.string().uuid(),
|
transactionId: z.string().uuid(),
|
||||||
@@ -497,6 +475,11 @@ export async function detachAttachmentBulkAction(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targetTransactionIds = await expandSplitSiblings(
|
||||||
|
targetTransactionIds,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
if (targetTransactionIds.length > 0) {
|
if (targetTransactionIds.length > 0) {
|
||||||
await db
|
await db
|
||||||
.delete(transactionAttachments)
|
.delete(transactionAttachments)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
|
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||||
import { transactions } from "@/db/schema";
|
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
||||||
import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period";
|
import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period";
|
||||||
|
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
||||||
import {
|
import {
|
||||||
centsToDecimalString,
|
centsToDecimalString,
|
||||||
type DeleteBulkInput,
|
type DeleteBulkInput,
|
||||||
@@ -78,71 +79,64 @@ export async function deleteTransactionBulkAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let scopeFilter: ReturnType<typeof and>;
|
||||||
|
let successMessage: string;
|
||||||
|
|
||||||
if (data.scope === "current") {
|
if (data.scope === "current") {
|
||||||
await db
|
scopeFilter = eq(transactions.id, data.id);
|
||||||
.delete(transactions)
|
successMessage = "Lançamento removido com sucesso.";
|
||||||
.where(
|
} else if (data.scope === "period") {
|
||||||
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
scopeFilter = and(
|
||||||
);
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
|
eq(transactions.period, existing.period ?? ""),
|
||||||
revalidate(user.id);
|
);
|
||||||
return { success: true, message: "Lançamento removido com sucesso." };
|
successMessage = "Todos os lançamentos do período foram removidos.";
|
||||||
|
} else if (data.scope === "future") {
|
||||||
|
scopeFilter = and(
|
||||||
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
|
sql`${transactions.period} >= ${existing.period}`,
|
||||||
|
);
|
||||||
|
successMessage = "Lançamentos removidos com sucesso.";
|
||||||
|
} else if (data.scope === "all") {
|
||||||
|
scopeFilter = eq(transactions.seriesId, existing.seriesId);
|
||||||
|
successMessage = "Todos os lançamentos da série foram removidos.";
|
||||||
|
} else {
|
||||||
|
return { success: false, error: "Escopo de ação inválido." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.scope === "period") {
|
const targetRows = await db
|
||||||
await db
|
.select({ id: transactions.id })
|
||||||
.delete(transactions)
|
.from(transactions)
|
||||||
.where(
|
.where(and(scopeFilter, eq(transactions.userId, user.id)));
|
||||||
and(
|
|
||||||
eq(transactions.seriesId, existing.seriesId),
|
|
||||||
eq(transactions.userId, user.id),
|
|
||||||
eq(transactions.period, existing.period ?? ""),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
revalidate(user.id);
|
const targetIds = targetRows.map((r) => r.id);
|
||||||
return {
|
|
||||||
success: true,
|
if (targetIds.length === 0) {
|
||||||
message: "Todos os lançamentos do período foram removidos.",
|
return { success: false, error: "Nenhum lançamento encontrado." };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.scope === "future") {
|
const linkedAttachments = await db
|
||||||
await db
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
||||||
.delete(transactions)
|
.from(transactionAttachments)
|
||||||
.where(
|
.innerJoin(
|
||||||
and(
|
attachments,
|
||||||
eq(transactions.seriesId, existing.seriesId),
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
eq(transactions.userId, user.id),
|
)
|
||||||
sql`${transactions.period} >= ${existing.period}`,
|
.where(inArray(transactionAttachments.transactionId, targetIds));
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
revalidate(user.id);
|
await db
|
||||||
return {
|
.delete(transactions)
|
||||||
success: true,
|
.where(
|
||||||
message: "Lançamentos removidos com sucesso.",
|
and(
|
||||||
};
|
inArray(transactions.id, targetIds),
|
||||||
}
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (data.scope === "all") {
|
await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
|
||||||
await db
|
|
||||||
.delete(transactions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(transactions.seriesId, existing.seriesId),
|
|
||||||
eq(transactions.userId, user.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
revalidate(user.id);
|
revalidate(user.id);
|
||||||
return {
|
return { success: true, message: successMessage };
|
||||||
success: true,
|
|
||||||
message: "Todos os lançamentos da série foram removidos.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, error: "Escopo de ação inválido." };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleActionError(error);
|
return handleActionError(error);
|
||||||
}
|
}
|
||||||
@@ -759,6 +753,15 @@ export async function deleteMultipleTransactionsAction(
|
|||||||
return { success: false, error: "Nenhum lançamento encontrado." };
|
return { success: false, error: "Nenhum lançamento encontrado." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linkedAttachments = await db
|
||||||
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.innerJoin(
|
||||||
|
attachments,
|
||||||
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
|
)
|
||||||
|
.where(inArray(transactionAttachments.transactionId, data.ids));
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(transactions)
|
.delete(transactions)
|
||||||
.where(
|
.where(
|
||||||
@@ -768,6 +771,8 @@ export async function deleteMultipleTransactionsAction(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
|
||||||
|
|
||||||
const notificationData = existing
|
const notificationData = existing
|
||||||
.filter(
|
.filter(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -394,7 +395,11 @@ const refineLancamento = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createSchema = baseFields.superRefine(refineLancamento);
|
export const createSchema = baseFields
|
||||||
|
.extend({
|
||||||
|
importFromTransactionId: uuidSchema("Lançamento fonte").optional(),
|
||||||
|
})
|
||||||
|
.superRefine(refineLancamento);
|
||||||
export const updateSchema = baseFields
|
export const updateSchema = baseFields
|
||||||
.extend({
|
.extend({
|
||||||
id: uuidSchema("Lançamento"),
|
id: uuidSchema("Lançamento"),
|
||||||
@@ -544,6 +549,7 @@ export const buildLancamentoRecords = ({
|
|||||||
seriesId,
|
seriesId,
|
||||||
}: BuildTransactionRecordsParams): TransactionInsert[] => {
|
}: BuildTransactionRecordsParams): TransactionInsert[] => {
|
||||||
const records: TransactionInsert[] = [];
|
const records: TransactionInsert[] = [];
|
||||||
|
const isSplit = (data.isSplit ?? false) && shares.length > 1;
|
||||||
|
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -562,6 +568,8 @@ export const buildLancamentoRecords = ({
|
|||||||
seriesId,
|
seriesId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cycleSplitGroupId = () => (isSplit ? randomUUID() : null);
|
||||||
|
|
||||||
const resolveSettledValue = (cycleIndex: number) => {
|
const resolveSettledValue = (cycleIndex: number) => {
|
||||||
if (shouldNullifySettled) {
|
if (shouldNullifySettled) {
|
||||||
return null;
|
return null;
|
||||||
@@ -588,6 +596,7 @@ export const buildLancamentoRecords = ({
|
|||||||
const installmentDueDate = dueDate
|
const installmentDueDate = dueDate
|
||||||
? addMonthsToDate(dueDate, installment)
|
? addMonthsToDate(dueDate, installment)
|
||||||
: null;
|
: null;
|
||||||
|
const splitGroupId = cycleSplitGroupId();
|
||||||
|
|
||||||
shares.forEach((share, shareIndex) => {
|
shares.forEach((share, shareIndex) => {
|
||||||
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
|
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
|
||||||
@@ -603,6 +612,7 @@ export const buildLancamentoRecords = ({
|
|||||||
currentInstallment: installment + 1,
|
currentInstallment: installment + 1,
|
||||||
recurrenceCount: null,
|
recurrenceCount: null,
|
||||||
dueDate: installmentDueDate,
|
dueDate: installmentDueDate,
|
||||||
|
splitGroupId,
|
||||||
boletoPaymentDate:
|
boletoPaymentDate:
|
||||||
data.paymentMethod === "Boleto" && settled
|
data.paymentMethod === "Boleto" && settled
|
||||||
? boletoPaymentDate
|
? boletoPaymentDate
|
||||||
@@ -623,6 +633,7 @@ export const buildLancamentoRecords = ({
|
|||||||
const recurrenceDueDate = dueDate
|
const recurrenceDueDate = dueDate
|
||||||
? addMonthsToDate(dueDate, index)
|
? addMonthsToDate(dueDate, index)
|
||||||
: null;
|
: null;
|
||||||
|
const splitGroupId = cycleSplitGroupId();
|
||||||
|
|
||||||
shares.forEach((share) => {
|
shares.forEach((share) => {
|
||||||
const settled = resolveSettledValue(index);
|
const settled = resolveSettledValue(index);
|
||||||
@@ -635,6 +646,7 @@ export const buildLancamentoRecords = ({
|
|||||||
isSettled: settled,
|
isSettled: settled,
|
||||||
recurrenceCount: recurrenceTotal,
|
recurrenceCount: recurrenceTotal,
|
||||||
dueDate: recurrenceDueDate,
|
dueDate: recurrenceDueDate,
|
||||||
|
splitGroupId,
|
||||||
boletoPaymentDate:
|
boletoPaymentDate:
|
||||||
data.paymentMethod === "Boleto" && settled
|
data.paymentMethod === "Boleto" && settled
|
||||||
? boletoPaymentDate
|
? boletoPaymentDate
|
||||||
@@ -646,6 +658,8 @@ export const buildLancamentoRecords = ({
|
|||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const splitGroupId = cycleSplitGroupId();
|
||||||
|
|
||||||
shares.forEach((share) => {
|
shares.forEach((share) => {
|
||||||
const settled = resolveSettledValue(0);
|
const settled = resolveSettledValue(0);
|
||||||
records.push({
|
records.push({
|
||||||
@@ -656,6 +670,7 @@ export const buildLancamentoRecords = ({
|
|||||||
period,
|
period,
|
||||||
isSettled: settled,
|
isSettled: settled,
|
||||||
dueDate,
|
dueDate,
|
||||||
|
splitGroupId,
|
||||||
boletoPaymentDate:
|
boletoPaymentDate:
|
||||||
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
|
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
|||||||
searchFilter: z.string().nullable(),
|
searchFilter: z.string().nullable(),
|
||||||
settledFilter: z.string().nullable(),
|
settledFilter: z.string().nullable(),
|
||||||
attachmentFilter: z.string().nullable(),
|
attachmentFilter: z.string().nullable(),
|
||||||
|
dividedFilter: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
accountId: z.string().min(1).nullable().optional(),
|
accountId: z.string().min(1).nullable().optional(),
|
||||||
cardId: z.string().min(1).nullable().optional(),
|
cardId: z.string().min(1).nullable().optional(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
attachments,
|
attachments,
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getBusinessTodayDate,
|
getBusinessTodayDate,
|
||||||
parseLocalDateString,
|
parseLocalDateString,
|
||||||
} from "@/shared/utils/date";
|
} from "@/shared/utils/date";
|
||||||
|
import { copyAttachmentsForImport } from "../attachment-copy";
|
||||||
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
||||||
import {
|
import {
|
||||||
buildLancamentoRecords,
|
buildLancamentoRecords,
|
||||||
@@ -138,6 +139,14 @@ export async function createTransactionAction(
|
|||||||
.values(records)
|
.values(records)
|
||||||
.returning({ id: transactions.id });
|
.returning({ id: transactions.id });
|
||||||
|
|
||||||
|
if (data.importFromTransactionId && inserted.length > 0) {
|
||||||
|
await copyAttachmentsForImport({
|
||||||
|
sourceTransactionId: data.importFromTransactionId,
|
||||||
|
targetTransactionIds: inserted.map((r) => r.id),
|
||||||
|
targetUserId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const notificationEntries = buildEntriesByPayer(
|
const notificationEntries = buildEntriesByPayer(
|
||||||
records.map((record) => ({
|
records.map((record) => ({
|
||||||
payerId: record.payerId ?? null,
|
payerId: record.payerId ?? null,
|
||||||
@@ -437,6 +446,134 @@ export async function deleteTransactionAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTransactionSplitPairAction(
|
||||||
|
input: UpdateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = updateSchema.parse(input);
|
||||||
|
|
||||||
|
const ownershipError = await validateAllOwnership(user.id, {
|
||||||
|
payerId: data.payerId,
|
||||||
|
categoryId: data.categoryId,
|
||||||
|
accountId: data.accountId,
|
||||||
|
cardId: data.cardId,
|
||||||
|
});
|
||||||
|
if (ownershipError) {
|
||||||
|
return { success: false, error: ownershipError };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.transactions.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
period: true,
|
||||||
|
transactionType: true,
|
||||||
|
condition: true,
|
||||||
|
paymentMethod: true,
|
||||||
|
accountId: true,
|
||||||
|
cardId: true,
|
||||||
|
categoryId: true,
|
||||||
|
splitGroupId: true,
|
||||||
|
},
|
||||||
|
where: and(
|
||||||
|
eq(transactions.id, data.id),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = resolvePeriod(data.purchaseDate, data.period);
|
||||||
|
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
|
||||||
|
const amountCents = Math.round(Math.abs(data.amount) * 100);
|
||||||
|
const normalizedAmount = centsToDecimalString(amountCents * amountSign);
|
||||||
|
const normalizedSettled =
|
||||||
|
data.paymentMethod === "Cartão de crédito"
|
||||||
|
? null
|
||||||
|
: (data.isSettled ?? false);
|
||||||
|
const shouldSetBoletoPaymentDate =
|
||||||
|
data.paymentMethod === "Boleto" && Boolean(normalizedSettled);
|
||||||
|
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
|
||||||
|
? data.boletoPaymentDate
|
||||||
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
|
: getBusinessTodayDate()
|
||||||
|
: null;
|
||||||
|
const targetCardId = data.cardId ?? existing.cardId;
|
||||||
|
const movedInvoice =
|
||||||
|
data.paymentMethod === "Cartão de crédito" &&
|
||||||
|
targetCardId &&
|
||||||
|
(targetCardId !== existing.cardId || period !== existing.period);
|
||||||
|
|
||||||
|
if (movedInvoice) {
|
||||||
|
const paidPeriods = await getPaidInvoicePeriods(user.id, targetCardId, [
|
||||||
|
period,
|
||||||
|
]);
|
||||||
|
if (paidPeriods.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `As faturas dos meses ${formatPaidInvoicePeriods(
|
||||||
|
paidPeriods,
|
||||||
|
)} já estão pagas. Desfaça o pagamento antes de mover este lançamento.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseDate = parseLocalDateString(data.purchaseDate);
|
||||||
|
const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null;
|
||||||
|
|
||||||
|
const sharedPayload = {
|
||||||
|
name: data.name,
|
||||||
|
purchaseDate,
|
||||||
|
transactionType: data.transactionType,
|
||||||
|
condition: data.condition,
|
||||||
|
paymentMethod: data.paymentMethod,
|
||||||
|
accountId: data.accountId ?? null,
|
||||||
|
cardId: data.cardId ?? null,
|
||||||
|
categoryId: data.categoryId ?? null,
|
||||||
|
note: data.note ?? null,
|
||||||
|
dueDate,
|
||||||
|
period,
|
||||||
|
isSettled: normalizedSettled,
|
||||||
|
boletoPaymentDate: boletoPaymentDateValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.transaction(async (tx: typeof db) => {
|
||||||
|
await tx
|
||||||
|
.update(transactions)
|
||||||
|
.set({
|
||||||
|
...sharedPayload,
|
||||||
|
amount: normalizedAmount,
|
||||||
|
payerId: data.payerId ?? null,
|
||||||
|
installmentCount: data.installmentCount ?? null,
|
||||||
|
recurrenceCount: data.recurrenceCount ?? null,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.splitGroupId) {
|
||||||
|
await tx
|
||||||
|
.update(transactions)
|
||||||
|
.set(sharedPayload)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.splitGroupId, existing.splitGroupId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
ne(transactions.id, data.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return { success: true, message: "Lançamentos atualizados com sucesso." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleTransactionSettlementAction(
|
export async function toggleTransactionSettlementAction(
|
||||||
input: ToggleSettlementInput,
|
input: ToggleSettlementInput,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
|
|||||||
107
src/features/transactions/attachment-copy.ts
Normal file
107
src/features/transactions/attachment-copy.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { CopyObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { getPayerAccess } from "@/shared/lib/payers/access";
|
||||||
|
import { deleteS3Object } from "@/shared/lib/storage/presign";
|
||||||
|
import { S3_BUCKET, s3 } from "@/shared/lib/storage/s3-client";
|
||||||
|
|
||||||
|
const SAFE_EXTENSION = /^[a-z0-9]{1,10}$/i;
|
||||||
|
|
||||||
|
function sanitizeExtension(fileKey: string): string {
|
||||||
|
const ext = fileKey.split(".").pop() ?? "";
|
||||||
|
return SAFE_EXTENSION.test(ext) ? ext.toLowerCase() : "bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyAttachmentsForImport({
|
||||||
|
sourceTransactionId,
|
||||||
|
targetTransactionIds,
|
||||||
|
targetUserId,
|
||||||
|
}: {
|
||||||
|
sourceTransactionId: string;
|
||||||
|
targetTransactionIds: string[];
|
||||||
|
targetUserId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (targetTransactionIds.length === 0) return;
|
||||||
|
|
||||||
|
const [source] = await db
|
||||||
|
.select({
|
||||||
|
id: transactions.id,
|
||||||
|
userId: transactions.userId,
|
||||||
|
payerId: transactions.payerId,
|
||||||
|
})
|
||||||
|
.from(transactions)
|
||||||
|
.where(eq(transactions.id, sourceTransactionId));
|
||||||
|
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
if (source.userId !== targetUserId) {
|
||||||
|
if (!source.payerId) return;
|
||||||
|
const access = await getPayerAccess(targetUserId, source.payerId);
|
||||||
|
if (!access) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceAttachments = await db
|
||||||
|
.select({
|
||||||
|
fileKey: attachments.fileKey,
|
||||||
|
fileName: attachments.fileName,
|
||||||
|
fileSize: attachments.fileSize,
|
||||||
|
mimeType: attachments.mimeType,
|
||||||
|
})
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.innerJoin(
|
||||||
|
attachments,
|
||||||
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
|
)
|
||||||
|
.where(eq(transactionAttachments.transactionId, sourceTransactionId));
|
||||||
|
|
||||||
|
if (sourceAttachments.length === 0) return;
|
||||||
|
|
||||||
|
for (const src of sourceAttachments) {
|
||||||
|
const newFileKey = `${targetUserId}/${randomUUID()}.${sanitizeExtension(src.fileKey)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await s3.send(
|
||||||
|
new CopyObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
CopySource: `${S3_BUCKET}/${src.fileKey}`,
|
||||||
|
Key: newFileKey,
|
||||||
|
ContentType: src.mimeType,
|
||||||
|
MetadataDirective: "COPY",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Falha ao copiar anexo no S3:", error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [newAttachment] = await db
|
||||||
|
.insert(attachments)
|
||||||
|
.values({
|
||||||
|
userId: targetUserId,
|
||||||
|
fileKey: newFileKey,
|
||||||
|
fileName: src.fileName,
|
||||||
|
fileSize: src.fileSize,
|
||||||
|
mimeType: src.mimeType,
|
||||||
|
})
|
||||||
|
.returning({ id: attachments.id });
|
||||||
|
|
||||||
|
if (!newAttachment) {
|
||||||
|
await deleteS3Object(newFileKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(transactionAttachments).values(
|
||||||
|
targetTransactionIds.map((tid) => ({
|
||||||
|
transactionId: tid,
|
||||||
|
attachmentId: newAttachment.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Falha ao registrar anexo copiado:", error);
|
||||||
|
await deleteS3Object(newFileKey).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { getPayerAccess } from "@/shared/lib/payers/access";
|
||||||
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||||
|
|
||||||
export type TransactionAttachmentListItem = {
|
export type TransactionAttachmentListItem = {
|
||||||
@@ -17,16 +18,24 @@ export async function fetchTransactionAttachments(
|
|||||||
transactionId: string,
|
transactionId: string,
|
||||||
): Promise<TransactionAttachmentListItem[]> {
|
): Promise<TransactionAttachmentListItem[]> {
|
||||||
const [transaction] = await db
|
const [transaction] = await db
|
||||||
.select({ id: transactions.id })
|
.select({
|
||||||
|
id: transactions.id,
|
||||||
|
userId: transactions.userId,
|
||||||
|
payerId: transactions.payerId,
|
||||||
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.where(
|
.where(eq(transactions.id, transactionId));
|
||||||
and(eq(transactions.id, transactionId), eq(transactions.userId, userId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transaction.userId !== userId) {
|
||||||
|
if (!transaction.payerId) return [];
|
||||||
|
const access = await getPayerAccess(userId, transaction.payerId);
|
||||||
|
if (!access) return [];
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
attachmentId: transactionAttachments.attachmentId,
|
attachmentId: transactionAttachments.attachmentId,
|
||||||
@@ -37,19 +46,9 @@ export async function fetchTransactionAttachments(
|
|||||||
createdAt: attachments.createdAt,
|
createdAt: attachments.createdAt,
|
||||||
})
|
})
|
||||||
.from(transactionAttachments)
|
.from(transactionAttachments)
|
||||||
.innerJoin(
|
|
||||||
transactions,
|
|
||||||
and(
|
|
||||||
eq(transactionAttachments.transactionId, transactions.id),
|
|
||||||
eq(transactions.userId, userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
attachments,
|
attachments,
|
||||||
and(
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
eq(transactionAttachments.attachmentId, attachments.id),
|
|
||||||
eq(attachments.userId, userId),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.where(eq(transactionAttachments.transactionId, transactionId));
|
.where(eq(transactionAttachments.transactionId, transactionId));
|
||||||
|
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<Field className="gap-1">
|
<Field className="gap-1">
|
||||||
<FieldLabel htmlFor="anticipation-period">Período</FieldLabel>
|
<FieldLabel htmlFor="anticipation-period">Fatura</FieldLabel>
|
||||||
<FieldContent>
|
<FieldContent>
|
||||||
<PeriodPicker
|
<PeriodPicker
|
||||||
value={formState.anticipationPeriod}
|
value={formState.anticipationPeriod}
|
||||||
@@ -292,7 +292,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
|
|
||||||
<Field className="gap-1">
|
<Field className="gap-1">
|
||||||
<FieldLabel htmlFor="anticipation-categoria">
|
<FieldLabel htmlFor="anticipation-categoria">
|
||||||
Category
|
Categoria
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<FieldContent>
|
<FieldContent>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { ptBR } from "date-fns/locale";
|
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
@@ -44,11 +42,6 @@ export function InstallmentSelectionTable({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: Date | null) => {
|
|
||||||
if (!date) return "—";
|
|
||||||
return format(date, "dd/MM/yyyy", { locale: ptBR });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (installments.length === 0) {
|
if (installments.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||||
@@ -63,11 +56,11 @@ export function InstallmentSelectionTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border">
|
<div className="overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-12">
|
<TableHead>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={
|
||||||
selectedIds.length === installments.length &&
|
selectedIds.length === installments.length &&
|
||||||
@@ -77,9 +70,8 @@ export function InstallmentSelectionTable({
|
|||||||
aria-label="Selecionar todas as parcelas"
|
aria-label="Selecionar todas as parcelas"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Parcela</TableHead>
|
<TableHead>Estabelecimento</TableHead>
|
||||||
<TableHead>Período</TableHead>
|
<TableHead>Fatura</TableHead>
|
||||||
<TableHead>Vencimento</TableHead>
|
|
||||||
<TableHead className="text-right">Valor</TableHead>
|
<TableHead className="text-right">Valor</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -103,6 +95,7 @@ export function InstallmentSelectionTable({
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
{inst.name}{" "}
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{formatCurrentInstallment(
|
{formatCurrentInstallment(
|
||||||
inst.currentInstallment ?? 0,
|
inst.currentInstallment ?? 0,
|
||||||
@@ -110,12 +103,11 @@ export function InstallmentSelectionTable({
|
|||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{formatShortPeriodLabel(inst.period)}
|
{formatShortPeriodLabel(inst.period)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{formatDate(inst.dueDate)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-medium">
|
<TableCell className="text-right font-medium">
|
||||||
<MoneyValues amount={Number(inst.amount)} />
|
<MoneyValues amount={Number(inst.amount)} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
|
||||||
|
|
||||||
|
export type SplitPairScope = "current" | "both";
|
||||||
|
|
||||||
|
type SplitPairDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: (scope: SplitPairScope) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SplitPairDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}: SplitPairDialogProps) {
|
||||||
|
const [scope, setScope] = useState<SplitPairScope>("current");
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(scope);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar lançamento dividido</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Este lançamento está dividido com outra pessoa. Escolha o que deseja
|
||||||
|
editar:
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={scope}
|
||||||
|
onValueChange={(v) => setScope(v as SplitPairScope)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="current"
|
||||||
|
id="split-current"
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="split-current"
|
||||||
|
className="text-sm cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
Apenas este lançamento
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Aplica a alteração somente neste lado da divisão
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<RadioGroupItem value="both" id="split-both" className="mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="split-both"
|
||||||
|
className="text-sm cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
Atualizar os dois lançamentos
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Aplica nome, data, categoria e outros campos compartilhados
|
||||||
|
nos dois lados da divisão
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleConfirm}>
|
||||||
|
Confirmar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,26 @@ export interface TransactionDialogProps {
|
|||||||
pendingDetachIds: string[];
|
pendingDetachIds: string[];
|
||||||
pendingUploadFiles: File[];
|
pendingUploadFiles: File[];
|
||||||
}) => void;
|
}) => void;
|
||||||
|
onSplitEditRequest?: (data: {
|
||||||
|
id: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
period: string;
|
||||||
|
name: string;
|
||||||
|
transactionType: string;
|
||||||
|
amount: number;
|
||||||
|
condition: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
categoryId: string | undefined;
|
||||||
|
note: string;
|
||||||
|
payerId: string | undefined;
|
||||||
|
accountId: string | undefined;
|
||||||
|
cardId: string | undefined;
|
||||||
|
isSettled: boolean | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
boletoPaymentDate: string | null;
|
||||||
|
pendingDetachIds: string[];
|
||||||
|
pendingUploadFiles: File[];
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseFieldSectionProps {
|
export interface BaseFieldSectionProps {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function TransactionDialog({
|
|||||||
onSuccess,
|
onSuccess,
|
||||||
maxSizeMb,
|
maxSizeMb,
|
||||||
onBulkEditRequest,
|
onBulkEditRequest,
|
||||||
|
onSplitEditRequest,
|
||||||
}: TransactionDialogProps) {
|
}: TransactionDialogProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||||
open,
|
open,
|
||||||
@@ -321,6 +322,10 @@ export function TransactionDialog({
|
|||||||
formState.boletoPaymentDate
|
formState.boletoPaymentDate
|
||||||
? formState.boletoPaymentDate
|
? formState.boletoPaymentDate
|
||||||
: undefined,
|
: undefined,
|
||||||
|
importFromTransactionId:
|
||||||
|
mode === "create" && isImporting && transaction?.id
|
||||||
|
? transaction.id
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -365,6 +370,11 @@ export function TransactionDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasSeriesId = Boolean(transaction?.seriesId);
|
const hasSeriesId = Boolean(transaction?.seriesId);
|
||||||
|
const hasSplitPair = Boolean(
|
||||||
|
transaction?.isDivided &&
|
||||||
|
transaction?.splitGroupId &&
|
||||||
|
!transaction?.seriesId,
|
||||||
|
);
|
||||||
|
|
||||||
if (hasSeriesId && onBulkEditRequest) {
|
if (hasSeriesId && onBulkEditRequest) {
|
||||||
// Para lançamentos em série, passa os arquivos para a página confirmar
|
// Para lançamentos em série, passa os arquivos para a página confirmar
|
||||||
@@ -398,6 +408,39 @@ export function TransactionDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasSplitPair && onSplitEditRequest) {
|
||||||
|
onSplitEditRequest({
|
||||||
|
id: transaction?.id ?? "",
|
||||||
|
purchaseDate: formState.purchaseDate,
|
||||||
|
period: formState.period,
|
||||||
|
name: formState.name.trim(),
|
||||||
|
transactionType: formState.transactionType,
|
||||||
|
amount: sanitizedAmount,
|
||||||
|
condition: formState.condition,
|
||||||
|
paymentMethod: formState.paymentMethod,
|
||||||
|
categoryId: formState.categoryId,
|
||||||
|
note: formState.note.trim() || "",
|
||||||
|
payerId: formState.payerId,
|
||||||
|
accountId: formState.accountId,
|
||||||
|
cardId: formState.cardId,
|
||||||
|
isSettled:
|
||||||
|
formState.paymentMethod === "Cartão de crédito"
|
||||||
|
? null
|
||||||
|
: Boolean(formState.isSettled),
|
||||||
|
dueDate:
|
||||||
|
formState.paymentMethod === "Boleto"
|
||||||
|
? formState.dueDate || null
|
||||||
|
: null,
|
||||||
|
boletoPaymentDate:
|
||||||
|
mode === "update" && formState.paymentMethod === "Boleto"
|
||||||
|
? formState.boletoPaymentDate || null
|
||||||
|
: null,
|
||||||
|
pendingDetachIds,
|
||||||
|
pendingUploadFiles,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Atualização normal para lançamentos únicos
|
// Atualização normal para lançamentos únicos
|
||||||
const updatePayload: UpdateTransactionInput = {
|
const updatePayload: UpdateTransactionInput = {
|
||||||
id: transaction?.id ?? "",
|
id: transaction?.id ?? "",
|
||||||
@@ -609,6 +652,17 @@ export function TransactionDialog({
|
|||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
/>
|
/>
|
||||||
|
{isImportMode && transaction?.id && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium leading-none">
|
||||||
|
Anexos que serão copiados
|
||||||
|
</Label>
|
||||||
|
<AttachmentSection
|
||||||
|
transactionId={transaction.id}
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<AttachmentFilePicker
|
<AttachmentFilePicker
|
||||||
files={pendingFiles}
|
files={pendingFiles}
|
||||||
onAdd={(file) => setPendingFiles((prev) => [...prev, file])}
|
onAdd={(file) => setPendingFiles((prev) => [...prev, file])}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
deleteTransactionAction,
|
deleteTransactionAction,
|
||||||
deleteTransactionBulkAction,
|
deleteTransactionBulkAction,
|
||||||
toggleTransactionSettlementAction,
|
toggleTransactionSettlementAction,
|
||||||
|
updateTransactionAction,
|
||||||
updateTransactionBulkAction,
|
updateTransactionBulkAction,
|
||||||
|
updateTransactionSplitPairAction,
|
||||||
} from "@/features/transactions/actions";
|
} from "@/features/transactions/actions";
|
||||||
import {
|
import {
|
||||||
confirmAttachmentUploadAction,
|
confirmAttachmentUploadAction,
|
||||||
@@ -31,6 +33,10 @@ import {
|
|||||||
MassAddDialog,
|
MassAddDialog,
|
||||||
type MassAddFormData,
|
type MassAddFormData,
|
||||||
} from "../dialogs/mass-add-dialog";
|
} from "../dialogs/mass-add-dialog";
|
||||||
|
import {
|
||||||
|
SplitPairDialog,
|
||||||
|
type SplitPairScope,
|
||||||
|
} from "../dialogs/split-pair-dialog";
|
||||||
import { TransactionDetailsDialog } from "../dialogs/transaction-details-dialog";
|
import { TransactionDetailsDialog } from "../dialogs/transaction-details-dialog";
|
||||||
import { TransactionDialog } from "../dialogs/transaction-dialog/transaction-dialog";
|
import { TransactionDialog } from "../dialogs/transaction-dialog/transaction-dialog";
|
||||||
import { TransactionsTable } from "../table/transactions-table";
|
import { TransactionsTable } from "../table/transactions-table";
|
||||||
@@ -125,6 +131,26 @@ export function TransactionsPage({
|
|||||||
);
|
);
|
||||||
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
|
const [pendingSplitEditData, setPendingSplitEditData] = useState<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
period: string;
|
||||||
|
transactionType: string;
|
||||||
|
amount: number;
|
||||||
|
condition: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
payerId: string | undefined;
|
||||||
|
accountId: string | undefined;
|
||||||
|
cardId: string | undefined;
|
||||||
|
categoryId: string | undefined;
|
||||||
|
note: string;
|
||||||
|
isSettled: boolean | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
boletoPaymentDate: string | null;
|
||||||
|
pendingDetachIds: string[];
|
||||||
|
pendingUploadFiles: File[];
|
||||||
|
} | null>(null);
|
||||||
const [pendingEditData, setPendingEditData] = useState<{
|
const [pendingEditData, setPendingEditData] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
@@ -394,6 +420,90 @@ export function TransactionsPage({
|
|||||||
setMassAddOpen(true);
|
setMassAddOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSplitEditRequest = (
|
||||||
|
data: NonNullable<typeof pendingSplitEditData>,
|
||||||
|
) => {
|
||||||
|
setPendingSplitEditData(data);
|
||||||
|
setEditOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSplitEdit = async (scope: SplitPairScope) => {
|
||||||
|
if (!pendingSplitEditData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: pendingSplitEditData.id,
|
||||||
|
name: pendingSplitEditData.name,
|
||||||
|
purchaseDate: pendingSplitEditData.purchaseDate,
|
||||||
|
period: pendingSplitEditData.period,
|
||||||
|
transactionType: pendingSplitEditData.transactionType as Parameters<
|
||||||
|
typeof updateTransactionAction
|
||||||
|
>[0]["transactionType"],
|
||||||
|
amount: pendingSplitEditData.amount,
|
||||||
|
condition: pendingSplitEditData.condition as Parameters<
|
||||||
|
typeof updateTransactionAction
|
||||||
|
>[0]["condition"],
|
||||||
|
paymentMethod: pendingSplitEditData.paymentMethod as Parameters<
|
||||||
|
typeof updateTransactionAction
|
||||||
|
>[0]["paymentMethod"],
|
||||||
|
payerId: pendingSplitEditData.payerId ?? null,
|
||||||
|
accountId: pendingSplitEditData.accountId ?? null,
|
||||||
|
cardId: pendingSplitEditData.cardId ?? null,
|
||||||
|
categoryId: pendingSplitEditData.categoryId ?? null,
|
||||||
|
note: pendingSplitEditData.note,
|
||||||
|
isSettled: pendingSplitEditData.isSettled,
|
||||||
|
dueDate: pendingSplitEditData.dueDate ?? undefined,
|
||||||
|
boletoPaymentDate: pendingSplitEditData.boletoPaymentDate ?? undefined,
|
||||||
|
isSplit: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action =
|
||||||
|
scope === "both"
|
||||||
|
? updateTransactionSplitPairAction
|
||||||
|
: updateTransactionAction;
|
||||||
|
const result = await action(payload);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error);
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
pendingSplitEditData.pendingDetachIds.map((attachmentId) =>
|
||||||
|
detachAttachmentBulkAction({
|
||||||
|
attachmentId,
|
||||||
|
transactionId: pendingSplitEditData.id,
|
||||||
|
scope: "current",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
pendingSplitEditData.pendingUploadFiles.map(async (file) => {
|
||||||
|
const presign = await getPresignedUploadUrlAction({
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
fileSize: file.size,
|
||||||
|
transactionId: pendingSplitEditData.id,
|
||||||
|
});
|
||||||
|
if (!presign.success) return;
|
||||||
|
await fetch(presign.presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
});
|
||||||
|
await confirmAttachmentUploadAction({
|
||||||
|
uploadToken: presign.uploadToken,
|
||||||
|
scope: "current",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(result.message);
|
||||||
|
setPendingSplitEditData(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (item: TransactionItem) => {
|
const handleEdit = (item: TransactionItem) => {
|
||||||
setSelectedTransaction(item);
|
setSelectedTransaction(item);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
@@ -557,6 +667,7 @@ export function TransactionsPage({
|
|||||||
transaction={selectedTransaction ?? undefined}
|
transaction={selectedTransaction ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
onBulkEditRequest={handleBulkEditRequest}
|
onBulkEditRequest={handleBulkEditRequest}
|
||||||
|
onSplitEditRequest={handleSplitEditRequest}
|
||||||
maxSizeMb={attachmentMaxSizeMb}
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -626,6 +737,14 @@ export function TransactionsPage({
|
|||||||
onConfirm={handleBulkEdit}
|
onConfirm={handleBulkEdit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SplitPairDialog
|
||||||
|
open={pendingSplitEditData !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPendingSplitEditData(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleSplitEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
{allowCreate && massAddOpen ? (
|
{allowCreate && massAddOpen ? (
|
||||||
<MassAddDialog
|
<MassAddDialog
|
||||||
open={massAddOpen}
|
open={massAddOpen}
|
||||||
|
|||||||
@@ -265,7 +265,8 @@ export function TransactionsFilters({
|
|||||||
searchParams.get("category") ||
|
searchParams.get("category") ||
|
||||||
searchParams.get("accountCard") ||
|
searchParams.get("accountCard") ||
|
||||||
searchParams.get("settled") ||
|
searchParams.get("settled") ||
|
||||||
searchParams.get("hasAttachment");
|
searchParams.get("hasAttachment") ||
|
||||||
|
searchParams.get("isDivided");
|
||||||
|
|
||||||
const handleResetFilters = () => {
|
const handleResetFilters = () => {
|
||||||
handleReset();
|
handleReset();
|
||||||
@@ -628,6 +629,23 @@ export function TransactionsFilters({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label
|
||||||
|
htmlFor="filter-is-divided"
|
||||||
|
className="text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Somente divididos
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
id="filter-is-divided"
|
||||||
|
checked={searchParams.get("isDivided") === "true"}
|
||||||
|
disabled={isPending}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleFilterChange("isDivided", checked ? "true" : null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DrawerFooter>
|
<DrawerFooter>
|
||||||
|
|||||||
@@ -229,8 +229,8 @@ export function TransactionsExport({
|
|||||||
const doc = new jsPDF({ orientation: "landscape" });
|
const doc = new jsPDF({ orientation: "landscape" });
|
||||||
const primaryColor = getPrimaryPdfColor();
|
const primaryColor = getPrimaryPdfColor();
|
||||||
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
|
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
|
||||||
loadExportLogoDataUrl("/images/logo_small.png"),
|
loadExportLogoDataUrl("/images/logo_small.svg"),
|
||||||
loadExportLogoDataUrl("/images/logo_text.png"),
|
loadExportLogoDataUrl("/images/logo_text.svg"),
|
||||||
]);
|
]);
|
||||||
let brandingEndX = 14;
|
let brandingEndX = 14;
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type TransactionItem = {
|
|||||||
isAnticipated: boolean;
|
isAnticipated: boolean;
|
||||||
anticipationId: string | null;
|
anticipationId: string | null;
|
||||||
seriesId: string | null;
|
seriesId: string | null;
|
||||||
|
splitGroupId: string | null;
|
||||||
hasAttachments: boolean;
|
hasAttachments: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type TransactionExportFilters = {
|
|||||||
searchFilter: string | null;
|
searchFilter: string | null;
|
||||||
settledFilter: string | null;
|
settledFilter: string | null;
|
||||||
attachmentFilter: string | null;
|
attachmentFilter: string | null;
|
||||||
|
dividedFilter: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TransactionsExportContext = {
|
export type TransactionsExportContext = {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export type TransactionSearchFilters = {
|
|||||||
searchFilter: string | null;
|
searchFilter: string | null;
|
||||||
settledFilter: string | null;
|
settledFilter: string | null;
|
||||||
attachmentFilter: string | null;
|
attachmentFilter: string | null;
|
||||||
|
dividedFilter: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BaseSluggedOption = {
|
type BaseSluggedOption = {
|
||||||
@@ -134,6 +135,7 @@ export const extractTransactionSearchFilters = (
|
|||||||
searchFilter: getSingleParam(params, "q"),
|
searchFilter: getSingleParam(params, "q"),
|
||||||
settledFilter: getSingleParam(params, "settled"),
|
settledFilter: getSingleParam(params, "settled"),
|
||||||
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
||||||
|
dividedFilter: getSingleParam(params, "isDivided"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resolveTransactionPagination = (
|
export const resolveTransactionPagination = (
|
||||||
@@ -402,6 +404,10 @@ export const buildTransactionWhere = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.dividedFilter === "true") {
|
||||||
|
where.push(eq(transactions.isDivided, true));
|
||||||
|
}
|
||||||
|
|
||||||
const searchPattern = buildSearchPattern(filters.searchFilter);
|
const searchPattern = buildSearchPattern(filters.searchFilter);
|
||||||
if (searchPattern) {
|
if (searchPattern) {
|
||||||
where.push(
|
where.push(
|
||||||
@@ -468,6 +474,7 @@ export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
|
|||||||
isAnticipated: item.isAnticipated ?? false,
|
isAnticipated: item.isAnticipated ?? false,
|
||||||
anticipationId: item.anticipationId ?? null,
|
anticipationId: item.anticipationId ?? null,
|
||||||
seriesId: item.seriesId ?? null,
|
seriesId: item.seriesId ?? null,
|
||||||
|
splitGroupId: item.splitGroupId ?? null,
|
||||||
hasAttachments: item.hasAttachments ?? false,
|
hasAttachments: item.hasAttachments ?? false,
|
||||||
readonly:
|
readonly:
|
||||||
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export type {
|
|||||||
export { CategoryIconBadge } from "./category-icon-badge";
|
export { CategoryIconBadge } from "./category-icon-badge";
|
||||||
export { EstablishmentLogo } from "./establishment-logo";
|
export { EstablishmentLogo } from "./establishment-logo";
|
||||||
export { EstablishmentLogoPicker } from "./establishment-logo-picker";
|
export { EstablishmentLogoPicker } from "./establishment-logo-picker";
|
||||||
|
export { LogoPrefetchProvider } from "./logo-prefetch-provider";
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { type ReactNode, useRef } from "react";
|
||||||
|
import { logoQueryKeys } from "@/shared/lib/logo";
|
||||||
|
import type { LogoPrefetchEntry } from "@/shared/lib/logo/types";
|
||||||
|
|
||||||
|
type LogoPrefetchProviderProps = {
|
||||||
|
mappings: LogoPrefetchEntry[];
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semeia o cache do React Query com mapeamentos de logo já resolvidos
|
||||||
|
* no servidor. Evita que cada `EstablishmentLogo` dispare seu próprio
|
||||||
|
* GET para `/api/logo/mapping` no primeiro render.
|
||||||
|
*/
|
||||||
|
export function LogoPrefetchProvider({
|
||||||
|
mappings,
|
||||||
|
children,
|
||||||
|
}: LogoPrefetchProviderProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const seeded = useRef(false);
|
||||||
|
|
||||||
|
if (!seeded.current) {
|
||||||
|
for (const { nameKey, domain, logoUrl } of mappings) {
|
||||||
|
queryClient.setQueryData(logoQueryKeys.mapping(nameKey), {
|
||||||
|
domain,
|
||||||
|
logoUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seeded.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
18
src/shared/components/logo-icon.tsx
Normal file
18
src/shared/components/logo-icon.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export function LogoIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
role="img"
|
||||||
|
aria-label="OpenMonetis"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/shared/components/logo-text.tsx
Normal file
18
src/shared/components/logo-text.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { LogoIcon } from "@/shared/components/logo-icon";
|
||||||
|
import { LogoText } from "@/shared/components/logo-text";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
@@ -27,75 +28,39 @@ export function Logo({
|
|||||||
if (variant === "compact") {
|
if (variant === "compact") {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center gap-1", className)}>
|
<div className={cn("flex items-center gap-1", className)}>
|
||||||
<div className="relative size-8 shrink-0">
|
<LogoIcon
|
||||||
<Image
|
className={cn(
|
||||||
src="/images/logo_small.png"
|
"size-8 shrink-0",
|
||||||
alt="OpenMonetis"
|
!colorIcon && iconFilterClass,
|
||||||
fill
|
iconClassName,
|
||||||
sizes="32px"
|
)}
|
||||||
className={cn(
|
/>
|
||||||
"object-contain",
|
<LogoText
|
||||||
!colorIcon && iconFilterClass,
|
className={cn(
|
||||||
iconClassName,
|
"hidden h-auto w-[110px] shrink-0 sm:block",
|
||||||
)}
|
invertTextOnDark && "dark:invert",
|
||||||
priority
|
textClassName,
|
||||||
/>
|
)}
|
||||||
</div>
|
/>
|
||||||
<div className="relative hidden h-8 w-[110px] shrink-0 sm:block">
|
|
||||||
<Image
|
|
||||||
src="/images/logo_text.png"
|
|
||||||
alt="OpenMonetis"
|
|
||||||
fill
|
|
||||||
sizes="110px"
|
|
||||||
className={cn(
|
|
||||||
"object-contain",
|
|
||||||
invertTextOnDark && "dark:invert",
|
|
||||||
textClassName,
|
|
||||||
)}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === "small") {
|
if (variant === "small") {
|
||||||
return (
|
return <LogoIcon className={cn("size-8 shrink-0", className)} />;
|
||||||
<div className={cn("relative size-8 shrink-0", className)}>
|
|
||||||
<Image
|
|
||||||
src="/images/logo_small.png"
|
|
||||||
alt="OpenMonetis"
|
|
||||||
fill
|
|
||||||
sizes="32px"
|
|
||||||
className="object-contain"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center gap-1.5 py-4", className)}>
|
<div className={cn("flex items-center gap-1.5 py-4", className)}>
|
||||||
<div className="relative size-7 shrink-0">
|
<LogoIcon
|
||||||
<Image
|
className={cn("size-7 shrink-0", !colorIcon && iconFilterClass)}
|
||||||
src="/images/logo_small.png"
|
/>
|
||||||
alt="OpenMonetis"
|
<LogoText
|
||||||
fill
|
className={cn(
|
||||||
sizes="28px"
|
"h-auto w-[100px] shrink-0",
|
||||||
className={cn("object-contain", !colorIcon && iconFilterClass)}
|
invertTextOnDark && "dark:invert",
|
||||||
priority
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="relative h-8 w-[100px] shrink-0">
|
|
||||||
<Image
|
|
||||||
src="/images/logo_text.png"
|
|
||||||
alt="OpenMonetis"
|
|
||||||
fill
|
|
||||||
sizes="100px"
|
|
||||||
className={cn("object-contain", invertTextOnDark && "dark:invert")}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import {
|
||||||
|
type RemixiconComponentType,
|
||||||
|
RiArrowLeftRightLine,
|
||||||
|
RiArrowRightDownLine,
|
||||||
|
RiArrowRightUpLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import StatusDot from "./status-dot";
|
|
||||||
|
|
||||||
type FinancialKind =
|
type FinancialKind =
|
||||||
| "receita"
|
| "receita"
|
||||||
@@ -26,29 +31,33 @@ type TransactionTypeBadgeProps = {
|
|||||||
type BadgeConfig = {
|
type BadgeConfig = {
|
||||||
label: string;
|
label: string;
|
||||||
className: string;
|
className: string;
|
||||||
dotClassName: string;
|
Icon: RemixiconComponentType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BADGE_CONFIG: Record<FinancialKindKey, BadgeConfig> = {
|
const BADGE_CONFIG: Record<FinancialKindKey, BadgeConfig> = {
|
||||||
receita: {
|
receita: {
|
||||||
label: "Receita",
|
label: "Receita",
|
||||||
className: "bg-success/10 text-success dark:bg-success/10",
|
className:
|
||||||
dotClassName: "bg-success/80",
|
"border-success/30 bg-success/5 text-success dark:saturate-90 dark:border-success/50 dark:bg-transparent",
|
||||||
|
Icon: RiArrowRightDownLine,
|
||||||
},
|
},
|
||||||
despesa: {
|
despesa: {
|
||||||
label: "Despesa",
|
label: "Despesa",
|
||||||
className: "bg-destructive/10 text-destructive dark:bg-destructive/10",
|
className:
|
||||||
dotClassName: "bg-destructive/80",
|
"border-destructive/30 bg-destructive/5 text-destructive dark:saturate-90 dark:border-destructive/50 dark:bg-transparent",
|
||||||
|
Icon: RiArrowRightUpLine,
|
||||||
},
|
},
|
||||||
transferência: {
|
transferência: {
|
||||||
label: "Transferência",
|
label: "Transf.",
|
||||||
className: "bg-info/10 text-info dark:bg-info/10",
|
className:
|
||||||
dotClassName: "bg-info/80",
|
"border-info/30 bg-info/5 text-info dark:saturate-90 dark:border-info/50 dark:bg-transparent",
|
||||||
|
Icon: RiArrowLeftRightLine,
|
||||||
},
|
},
|
||||||
"saldo inicial": {
|
"saldo inicial": {
|
||||||
label: "Saldo Inicial",
|
label: "Saldo Inicial",
|
||||||
className: "bg-success/10 text-success dark:bg-success/10",
|
className:
|
||||||
dotClassName: "bg-success/80",
|
"border-success/30 bg-success/5 text-success dark:saturate-90 dark:border-success/50 dark:bg-transparent",
|
||||||
|
Icon: RiArrowRightDownLine,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,22 +75,20 @@ export function TransactionTypeBadge({
|
|||||||
const normalizedKind = normalizeKind(kind);
|
const normalizedKind = normalizeKind(kind);
|
||||||
const config = normalizedKind ? BADGE_CONFIG[normalizedKind] : null;
|
const config = normalizedKind ? BADGE_CONFIG[normalizedKind] : null;
|
||||||
const label = config?.label ?? kind;
|
const label = config?.label ?? kind;
|
||||||
|
const Icon = config?.Icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
data-kind={normalizedKind ?? "custom"}
|
data-kind={normalizedKind ?? "custom"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 gap-1 border-none rounded-md px-2 py-0 text-xs shadow-none",
|
"h-6 gap-1 rounded-sm border px-2 py-0 text-xs font-medium shadow-xs",
|
||||||
config?.className ??
|
config?.className ??
|
||||||
"bg-muted/30 text-muted-foreground dark:bg-muted/20",
|
"border-muted-foreground/30 bg-muted/20 text-muted-foreground dark:bg-transparent",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<StatusDot
|
{Icon ? <Icon className="size-3.5" /> : null}
|
||||||
color={config?.dotClassName ?? "bg-muted-foreground/60"}
|
|
||||||
className="size-1.5"
|
|
||||||
/>
|
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||||
import { RiCircleLine } from "@remixicon/react";
|
import { RiCheckLine } from "@remixicon/react";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
@@ -26,16 +26,16 @@ function RadioGroupItem({
|
|||||||
<RadioGroupPrimitive.Item
|
<RadioGroupPrimitive.Item
|
||||||
data-slot="radio-group-item"
|
data-slot="radio-group-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<RadioGroupPrimitive.Indicator
|
<RadioGroupPrimitive.Indicator
|
||||||
data-slot="radio-group-indicator"
|
data-slot="radio-group-indicator"
|
||||||
className="relative flex items-center justify-center"
|
className="grid place-content-center text-current transition-none"
|
||||||
>
|
>
|
||||||
<RiCircleLine className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
<RiCheckLine className="size-3.5" />
|
||||||
</RadioGroupPrimitive.Indicator>
|
</RadioGroupPrimitive.Indicator>
|
||||||
</RadioGroupPrimitive.Item>
|
</RadioGroupPrimitive.Item>
|
||||||
);
|
);
|
||||||
|
|||||||
31
src/shared/lib/logo/prefetch-server.ts
Normal file
31
src/shared/lib/logo/prefetch-server.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { fetchEstablishmentLogoMap } from "./establishment-logo-queries";
|
||||||
|
import { toNameKey } from "./index";
|
||||||
|
import { buildLogoDevUrl } from "./server";
|
||||||
|
import type { LogoPrefetchEntry } from "./types";
|
||||||
|
|
||||||
|
export async function prefetchLogoMappings(
|
||||||
|
userId: string,
|
||||||
|
names: string[],
|
||||||
|
): Promise<LogoPrefetchEntry[]> {
|
||||||
|
const uniqueNames = [
|
||||||
|
...new Set(
|
||||||
|
names.filter((n) => typeof n === "string" && n.trim().length > 0),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (uniqueNames.length === 0) return [];
|
||||||
|
|
||||||
|
const map = await fetchEstablishmentLogoMap(userId, uniqueNames);
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const entries: LogoPrefetchEntry[] = [];
|
||||||
|
for (const name of uniqueNames) {
|
||||||
|
const nameKey = toNameKey(name);
|
||||||
|
if (seen.has(nameKey)) continue;
|
||||||
|
seen.add(nameKey);
|
||||||
|
const domain = map.get(nameKey) ?? null;
|
||||||
|
entries.push({ nameKey, domain, logoUrl: buildLogoDevUrl(domain) });
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
5
src/shared/lib/logo/types.ts
Normal file
5
src/shared/lib/logo/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type LogoPrefetchEntry = {
|
||||||
|
nameKey: string;
|
||||||
|
domain: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PAYER_ROLE_ADMIN,
|
PAYER_ROLE_ADMIN,
|
||||||
PAYER_STATUS_OPTIONS,
|
PAYER_STATUS_OPTIONS,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
import { generateShareCode } from "./share-code";
|
||||||
import { normalizeNameFromEmail } from "./utils";
|
import { normalizeNameFromEmail } from "./utils";
|
||||||
|
|
||||||
const DEFAULT_STATUS = PAYER_STATUS_OPTIONS[0];
|
const DEFAULT_STATUS = PAYER_STATUS_OPTIONS[0];
|
||||||
@@ -49,6 +50,7 @@ export async function ensureDefaultPagadorForUser(user: SeedUserLike) {
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
note: null,
|
note: null,
|
||||||
isAutoSend: false,
|
isAutoSend: false,
|
||||||
|
shareCode: generateShareCode(),
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/shared/lib/payers/share-code.ts
Normal file
6
src/shared/lib/payers/share-code.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
export const generateShareCode = (): string => {
|
||||||
|
return randomBytes(18).toString("base64url").slice(0, 24);
|
||||||
|
};
|
||||||
@@ -48,5 +48,16 @@ export async function deleteS3Object(fileKey: string): Promise<void> {
|
|||||||
Bucket: S3_BUCKET,
|
Bucket: S3_BUCKET,
|
||||||
Key: fileKey,
|
Key: fileKey,
|
||||||
});
|
});
|
||||||
await s3.send(command);
|
try {
|
||||||
|
await s3.send(command);
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Error &&
|
||||||
|
"Code" in err &&
|
||||||
|
(err as { Code: string }).Code === "NoSuchKey"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ export function getPrimaryPdfColor(): [number, number, number] {
|
|||||||
return FALLBACK_PRIMARY_COLOR;
|
return FALLBACK_PRIMARY_COLOR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXPORT_LOGO_RENDER_SCALE = 4;
|
||||||
|
|
||||||
export async function loadExportLogoDataUrl(
|
export async function loadExportLogoDataUrl(
|
||||||
logoPath = "/images/logo_text.png",
|
logoPath = "/images/logo_text.svg",
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||||
return null;
|
return null;
|
||||||
@@ -77,13 +79,16 @@ export async function loadExportLogoDataUrl(
|
|||||||
image.crossOrigin = "anonymous";
|
image.crossOrigin = "anonymous";
|
||||||
|
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
const width = image.naturalWidth || image.width;
|
const naturalWidth = image.naturalWidth || image.width;
|
||||||
const height = image.naturalHeight || image.height;
|
const naturalHeight = image.naturalHeight || image.height;
|
||||||
if (!width || !height) {
|
if (!naturalWidth || !naturalHeight) {
|
||||||
resolve(null);
|
resolve(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const width = Math.round(naturalWidth * EXPORT_LOGO_RENDER_SCALE);
|
||||||
|
const height = Math.round(naturalHeight * EXPORT_LOGO_RENDER_SCALE);
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"ignoreDeprecations": "6.0",
|
"target": "ES2022",
|
||||||
"baseUrl": ".",
|
|
||||||
"target": "ES2017",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user