mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-10 11:21:45 +00:00
Compare commits
40 Commits
7dd480284e
...
v2.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20d0c3e0a7 | ||
|
|
71b5a004e3 | ||
|
|
65b1506d75 | ||
|
|
2a458d5a3c | ||
|
|
f418987f47 | ||
|
|
59b4dea071 | ||
|
|
6ce132fe0c | ||
|
|
49731238e4 | ||
|
|
c5df97f7aa | ||
|
|
3476fda4db | ||
|
|
519b673ae5 | ||
|
|
303b8bedd4 | ||
|
|
f2b9b16896 | ||
|
|
6eba35542b | ||
|
|
f5e95ffba6 | ||
|
|
a75bb86eec | ||
|
|
a3b858621f | ||
|
|
fee2a2c9f5 | ||
|
|
839d7d0866 | ||
|
|
7cd7d95245 | ||
|
|
9bd762f7a3 | ||
|
|
9b76db4ce9 | ||
|
|
91457b6490 | ||
|
|
a0a71623d7 | ||
|
|
00e624b8bc | ||
|
|
f82043127a | ||
|
|
32da4f906e | ||
|
|
0bd9d0ac47 | ||
|
|
9f45fd1ecd | ||
|
|
f528e75ee1 | ||
|
|
da32b41bbc | ||
|
|
1e0c93fb6c | ||
|
|
5f70421f5a | ||
|
|
50477fb1be | ||
|
|
60a52b9873 | ||
|
|
c9205f2be9 | ||
|
|
1d36b12109 | ||
|
|
19a1b1e943 | ||
|
|
d3fc81db73 | ||
|
|
80de9501f6 |
@@ -22,6 +22,13 @@ BETTER_AUTH_URL=http://localhost:3000
|
|||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# === S3 Server (Opcional) ===
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_BUCKET=
|
||||||
|
|
||||||
# === Email (Opcional) ===
|
# === Email (Opcional) ===
|
||||||
# Provider: Resend (https://resend.com)
|
# Provider: Resend (https://resend.com)
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
|
|||||||
59
.github/workflows/release.yml
vendored
Normal file
59
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Read version from package.json
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(jq -r '.version' package.json)
|
||||||
|
echo "value=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Check if tag already exists
|
||||||
|
id: tag_check
|
||||||
|
run: |
|
||||||
|
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Extract changelog for this version
|
||||||
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.value }}"
|
||||||
|
# Extrai o bloco entre ## [X.Y.Z] e o próximo ## [
|
||||||
|
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
|
||||||
|
# Remove linhas em branco do início e fim
|
||||||
|
NOTES=$(echo "$NOTES" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba}')
|
||||||
|
{
|
||||||
|
echo "notes<<EOF"
|
||||||
|
echo "$NOTES"
|
||||||
|
echo "EOF"
|
||||||
|
} >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create tag and GitHub Release
|
||||||
|
if: steps.tag_check.outputs.exists == 'false'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.tag }}
|
||||||
|
name: ${{ steps.version.outputs.tag }}
|
||||||
|
body: ${{ steps.changelog.outputs.notes }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -12,7 +12,6 @@
|
|||||||
"**/.next": true,
|
"**/.next": true,
|
||||||
".next": true
|
".next": true
|
||||||
},
|
},
|
||||||
"explorerExclude.backup": {},
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
@@ -25,9 +24,7 @@
|
|||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
"eslint.enable": false,
|
|
||||||
"prettier.enable": false,
|
"prettier.enable": false,
|
||||||
"typescript.preferences.organizeImportsCollation": "ordinal",
|
|
||||||
"editor.fontSize": 15,
|
"editor.fontSize": 15,
|
||||||
"[jsonc]": {
|
"[jsonc]": {
|
||||||
"editor.defaultFormatter": "vscode.json-language-features"
|
"editor.defaultFormatter": "vscode.json-language-features"
|
||||||
|
|||||||
98
CHANGELOG.md
98
CHANGELOG.md
@@ -5,6 +5,104 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
|||||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
||||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.1.2] - 2026-03-30
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Preferências: nova configuração de tamanho máximo por arquivo de anexo (5, 10, 25, 50 ou 100 MB), persistida no banco e respeitada em todos os pontos de upload
|
||||||
|
- Lançamentos: novo escopo `"period"` na ação em lote, que aplica a alteração a todos os lançamentos do período sem sobrescrever o pagador individual de cada um
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Lançamentos: ao editar um lançamento de série, uploads e remoções de anexo agora aguardam a escolha de escopo da ação em lote antes de serem executados, evitando que o anexo fosse aplicado no lançamento errado
|
||||||
|
- Lançamentos: ação em lote com escopo `"period"` não sobrescreve mais o `payerId` individual de cada lançamento ao alterar o pagador
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Configurações: redesign visual da página com separadores entre seções e títulos maiores
|
||||||
|
- Configurações: seção "Extrato e lançamentos" renomeada para "Lançamentos"
|
||||||
|
|
||||||
|
## [2.1.1] - 2026-03-29
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Navbar: novo componente `NavbarShell` que unifica a estrutura da barra de navegação entre o app e a landing page
|
||||||
|
- UI: nova variante `navbar` no componente `Button`, centralizando os estilos de botões usados dentro da navbar
|
||||||
|
- Analytics: integração com Umami self-hosted via script tag no layout raiz
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Navbar: `AnimatedThemeToggler` e `RefreshPageButton` passam a aceitar prop `variant` para adaptar estilos ao contexto (navbar ou sidebar)
|
||||||
|
- Navbar: estilos inline duplicados de `nav-styles.ts` migrados para a variante `navbar` do Button
|
||||||
|
- Logo: prop `showVersion` removida; prop `colorIcon` passa a aplicar filtro de cor também no variant `compact`
|
||||||
|
- Scripts: `mockup` renomeado para `db:seed`; `db:enableExtensions` renomeado para `db:extensions`; script `dev-env` removido
|
||||||
|
- Landing: `MobileNav` simplificado com a remoção da prop `triggerClassName`
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- Navbar: arquivo `nav-styles.ts` removido após migração dos estilos para a variante `navbar`
|
||||||
|
- Dependências: `@vercel/analytics` e `@vercel/speed-insights` removidos (substituídos pelo Umami self-hosted)
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-03-28
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Lançamentos: suporte a anexos em transações com upload direto para storage compatível com S3, persistência em tabelas dedicadas (`anexos` e `lancamento_anexos`) e ações de visualizar/remover no detalhe do lançamento
|
||||||
|
- Infraestrutura: novo workflow `.github/workflows/release.yml` para criar tag e GitHub Release automaticamente a partir da versão do `package.json` e da entrada correspondente no `CHANGELOG.md`
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Anexos: upload agora exige token assinado por arquivo, valida propriedade da transação também na leitura/remoção e confere tamanho/tipo do objeto no storage antes de persistir o vínculo no banco
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Lançamentos: criação de transações no cartão de crédito agora bloqueia períodos cujas faturas já estão pagas, evitando divergência no relatório de análise de parcelas
|
||||||
|
|
||||||
|
## [2.0.3] - 2026-03-26
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Lançamentos: `/transactions` deixa de depender de `crypto.randomUUID()` no carregamento inicial, corrigindo a falha em ambientes self-hosted sem HTTPS ao abrir a página
|
||||||
|
|
||||||
|
## [2.0.2] - 2026-03-25
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Scripts: novo comando `mockup` no `package.json` para executar `scripts/mock-data.ts`
|
||||||
|
- Navbar: novo estado persistido para notificações do sino, permitindo marcar alertas de fatura, boleto e orçamento como lidos ou arquivados por usuário
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Navbar: o snapshot global de notificações deixa de depender do `periodo` da URL atual e passa a usar o período corrente do negócio; itens lidos saem do badge e itens arquivados somem da lista padrão do sino
|
||||||
|
- Navbar: dropdown de notificações agora permite mostrar itens arquivados e reverter ações de leitura e arquivamento diretamente em cada item
|
||||||
|
- Navbar: filtro da lista de notificações no sino foi refinado para um seletor explícito entre `Ativas` e `Arquivadas`, com destaque visual mais forte para a aba ativa
|
||||||
|
- Navbar: componente `notification-bell` foi desmembrado em hook e componentes locais menores, reduzindo acoplamento e facilitando manutenção
|
||||||
|
- Dashboard: detalhamento por categoria agora oculta categorias sem movimentação no período, reduzindo ruído visual no card
|
||||||
|
- UI: arte decorativa do topo da dashboard foi restrita à faixa do cabeçalho de boas-vindas, evitando que o `dot pattern` e o gradiente claro alterem a leitura visual do month picker
|
||||||
|
- Lançamentos em série: a edição em lote agora também permite propagar o status de pagamento (`isSettled`) para transações não feitas no cartão de crédito
|
||||||
|
- Seed de conta vazia: `scripts/mock-data.ts` agora processa `--help` antes de exigir `DATABASE_URL` e só cria categorias/pagador admin depois de validar que a conta está financeiramente vazia
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Navbar: ao desarquivar a última notificação no modo de arquivadas, o dropdown volta automaticamente para a listagem padrão e o toggle deixa de ficar travado
|
||||||
|
- Filtros financeiros: transações de conta com observação nula, como compras parceladas no Pix, deixam de ser ocultadas indevidamente em `/transactions`, dashboard e relatórios quando a conta está configurada para desconsiderar o saldo inicial
|
||||||
|
- Backup: geração do arquivo `*.data.sql.gz` volta a usar a saída correta do `pg_restore`
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- DB: colunas `system_font` e `money_font` da tabela `preferencias_usuario`, que não são mais utilizadas no código
|
||||||
|
|
||||||
|
## [2.0.1] - 2026-03-21
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Inbox: filtro por app em `/inbox` agora monta a lista completa de apps da aba a partir de todos os itens do status atual, sem depender apenas da página carregada, e o SSR deixa de quebrar quando `sourceApps` vier inconsistente
|
||||||
|
- Inbox: notificações de cartões/apps sem logo cadastrado agora exibem `default_icon.png` como fallback visual nos cards
|
||||||
|
- Inbox: select de apps em `/inbox` agora exibe os logos dos apps/cartões, com fallback para `default_icon.png` quando não houver logo mapeado
|
||||||
|
- Inbox: cabeçalhos de data entre grupos de cards agora exibem ícone e tipografia um pouco maior para melhorar a leitura
|
||||||
|
- Versionamento: `/api/health` passa a reportar a versão atual do `package.json`, evitando divergência entre healthcheck, UI e release publicada
|
||||||
|
|
||||||
## [2.0.0] - 2026-03-21
|
## [2.0.0] - 2026-03-21
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
||||||
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
||||||
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
||||||
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog.
|
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json`.
|
||||||
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
||||||
|
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -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/)
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
- [Início Rápido (manual)](#-início-rápido)
|
- [Início Rápido (manual)](#-início-rápido)
|
||||||
- [Scripts Disponíveis](#-scripts-disponíveis)
|
- [Scripts Disponíveis](#-scripts-disponíveis)
|
||||||
- [Docker](#-docker)
|
- [Docker](#-docker)
|
||||||
|
- [Storage S3 Compatível](#-storage-s3-compatível)
|
||||||
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
|
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
|
||||||
- [Arquitetura](#-arquitetura)
|
- [Arquitetura](#-arquitetura)
|
||||||
- [Contribuindo](#-contribuindo)
|
- [Contribuindo](#-contribuindo)
|
||||||
@@ -155,7 +156,7 @@ O script irá:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up db -d
|
docker compose up db -d
|
||||||
pnpm db:enableExtensions
|
pnpm db:extensions
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Execute as migrations e inicie**
|
4. **Execute as migrations e inicie**
|
||||||
@@ -238,6 +239,30 @@ DB_PORT=5433 # Padrão: 5432
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ☁️ Storage S3 Compatível
|
||||||
|
|
||||||
|
O suporte a anexos de lançamentos usa upload direto com URL pré-assinada. Essa configuração é opcional, mas passa a ser necessária se você quiser habilitar anexos no app.
|
||||||
|
|
||||||
|
### Variáveis
|
||||||
|
|
||||||
|
```env
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_BUCKET=
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compatibilidade
|
||||||
|
|
||||||
|
- O código atual espera um provider com API compatível com S3 e suporte a `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` e URLs pré-assinadas.
|
||||||
|
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](/home/ubuntu/github/openmonetis/src/shared/lib/storage/s3-client.ts).
|
||||||
|
- Em geral isso cobre MinIO, Cloudflare R2, Backblaze B2 S3-Compatible, DigitalOcean Spaces e AWS S3. Mas foi testado apenas no Supabase Storage.
|
||||||
|
- Se o seu provider exigir `virtual-hosted-style` em vez de `path-style`, você vai precisar ajustar essa configuração antes de usar anexos.
|
||||||
|
- Se as variáveis de S3 não forem configuradas, mantenha os anexos desabilitados no seu fluxo de uso.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔐 Variáveis de Ambiente
|
## 🔐 Variáveis de Ambiente
|
||||||
|
|
||||||
Copie `.env.example` para `.env` e configure:
|
Copie `.env.example` para `.env` e configure:
|
||||||
@@ -258,6 +283,13 @@ POSTGRES_USER=openmonetis
|
|||||||
POSTGRES_PASSWORD=openmonetis_dev_password
|
POSTGRES_PASSWORD=openmonetis_dev_password
|
||||||
POSTGRES_DB=openmonetis_db
|
POSTGRES_DB=openmonetis_db
|
||||||
|
|
||||||
|
# S3 Server (opcional, necessario para anexos)
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_BUCKET=
|
||||||
|
|
||||||
# Multi-domínio (landing-only no domínio público)
|
# Multi-domínio (landing-only no domínio público)
|
||||||
# PUBLIC_DOMAIN=openmonetis.com
|
# PUBLIC_DOMAIN=openmonetis.com
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ name: openmonetis
|
|||||||
# MODOS DE USO:
|
# MODOS DE USO:
|
||||||
# 1. Banco LOCAL (PostgreSQL em container):
|
# 1. Banco LOCAL (PostgreSQL em container):
|
||||||
# - Configure DATABASE_URL com host "db" no .env
|
# - Configure DATABASE_URL com host "db" no .env
|
||||||
# - Execute: docker compose up --build
|
# - Execute: docker compose up
|
||||||
#
|
#
|
||||||
# 2. Banco REMOTO (ex: Supabase):
|
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
|
||||||
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
||||||
# - Execute: docker compose up app --build (apenas o serviço app)
|
# - Execute: docker compose up app (apenas o serviço app)
|
||||||
#
|
#
|
||||||
# 3. Para parar todos os serviços:
|
# 3. Para parar todos os serviços:
|
||||||
# - Execute: docker compose down
|
# - Execute: docker compose down
|
||||||
@@ -29,22 +29,21 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
||||||
# Garante que os dados ficam no volume montado (evita perda após down/up)
|
|
||||||
PGDATA: /var/lib/postgresql/data
|
PGDATA: /var/lib/postgresql/data
|
||||||
# Configurações de performance
|
|
||||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
# Mapeia porta 5432 do container para 5432 do host
|
|
||||||
# Útil para conectar com ferramentas externas (ex: DBeaver, pgAdmin)
|
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Volume nomeado para persistência de dados
|
|
||||||
# Os dados sobrevivem ao restart do container
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
# Script de inicialização (cria extensão pgcrypto automaticamente)
|
|
||||||
- ./scripts/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
# Cria extensão pgcrypto inline (necessária para gen_random_bytes no schema)
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
echo 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' > /docker-entrypoint-initdb.d/init.sql
|
||||||
|
exec docker-entrypoint.sh postgres
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
@@ -57,80 +56,65 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
networks:
|
# Para ativar logs de queries (debug), adicione ao command acima:
|
||||||
- openmonetis_network
|
# exec docker-entrypoint.sh postgres -c log_statement=all
|
||||||
|
|
||||||
# Descomentar para ativar logs de queries (debug)
|
|
||||||
# command: ["postgres", "-c", "log_statement=all"]
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Serviço: Aplicação Next.js
|
# Serviço: Aplicação Next.js
|
||||||
# ============================================
|
# ============================================
|
||||||
app:
|
app:
|
||||||
build:
|
image: felipegcoutinho/openmonetis:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
|
|
||||||
container_name: openmonetis_app
|
container_name: openmonetis_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
# Mapeia porta 3000 do container para 3000 do host
|
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
# Variáveis de ambiente da aplicação
|
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|
||||||
# DATABASE_URL do .env
|
# Banco local: use host "db" | Banco remoto: URL completa do provider
|
||||||
# Banco local: use host "db" (serviço Docker)
|
|
||||||
# Banco remoto: use a URL completa do provider
|
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
|
||||||
# Outras variáveis de ambiente necessárias
|
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||||
|
|
||||||
# Configurações de email (se usar)
|
# Email (opcional)
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
||||||
|
|
||||||
# Configurações de OAuth (se usar)
|
# OAuth (opcional)
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||||
|
|
||||||
# Configurações de AI providers (se usar)
|
# AI providers (opcional)
|
||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
|
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
|
||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||||
|
|
||||||
# Só depende do 'db' se estiver usando banco local
|
# Só depende do 'db' se estiver usando banco local
|
||||||
# Para banco remoto, comente a linha abaixo ou suba apenas: docker compose up app
|
# Para banco remoto, comente as linhas abaixo
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
networks:
|
|
||||||
- openmonetis_network
|
|
||||||
|
|
||||||
# Script de inicialização: roda migrations antes de iniciar o app
|
# Script de inicialização: roda migrations antes de iniciar o app
|
||||||
# ATENÇÃO: Em produção, considere rodar migrations separadamente por segurança
|
|
||||||
entrypoint: ["/bin/sh", "-c"]
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
command:
|
command:
|
||||||
- |
|
- |
|
||||||
echo "🚀 Aguardando banco de dados..."
|
echo "Aguardando banco de dados..."
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|
||||||
echo "📦 Rodando migrations..."
|
echo "Rodando migrations..."
|
||||||
pnpm db:push || echo "⚠️ Migrations falharam ou já estão atualizadas"
|
pnpm db:push || echo "Migrations falharam ou já estão atualizadas"
|
||||||
|
|
||||||
echo "✅ Iniciando aplicação Next.js..."
|
echo "Iniciando aplicação Next.js..."
|
||||||
node server.js
|
node server.js
|
||||||
|
|
||||||
# Healthcheck da aplicação
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
@@ -151,13 +135,4 @@ services:
|
|||||||
# ============================================
|
# ============================================
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
name: openmonetis_postgres_data
|
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Networks
|
|
||||||
# ============================================
|
|
||||||
networks:
|
|
||||||
openmonetis_network:
|
|
||||||
name: openmonetis_network
|
|
||||||
driver: bridge
|
|
||||||
|
|||||||
1
drizzle/0010_lame_psynapse.sql
Normal file
1
drizzle/0010_lame_psynapse.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
-- placeholder: migration aplicada via db:push, arquivo original não preservado
|
||||||
37
drizzle/0023_sturdy_wolfpack.sql
Normal file
37
drizzle/0023_sturdy_wolfpack.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE "anexos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"chave_arquivo" text NOT NULL,
|
||||||
|
"nome_arquivo" text NOT NULL,
|
||||||
|
"tamanho_bytes" integer NOT NULL,
|
||||||
|
"mime_type" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "anexos_chave_arquivo_unique" UNIQUE("chave_arquivo")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "dashboard_notification_states" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"notification_key" text NOT NULL,
|
||||||
|
"fingerprint" text NOT NULL,
|
||||||
|
"read_at" timestamp with time zone,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "lancamento_anexos" (
|
||||||
|
"lancamento_id" uuid NOT NULL,
|
||||||
|
"anexo_id" uuid NOT NULL,
|
||||||
|
CONSTRAINT "lancamento_anexos_lancamento_id_anexo_id_pk" PRIMARY KEY("lancamento_id","anexo_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "anexos" ADD CONSTRAINT "anexos_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboard_notification_states" ADD CONSTRAINT "dashboard_notification_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lancamento_anexos" ADD CONSTRAINT "lancamento_anexos_lancamento_id_lancamentos_id_fk" FOREIGN KEY ("lancamento_id") REFERENCES "public"."lancamentos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lancamento_anexos" ADD CONSTRAINT "lancamento_anexos_anexo_id_anexos_id_fk" FOREIGN KEY ("anexo_id") REFERENCES "public"."anexos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "dashboard_notification_states_user_id_key_unique" ON "dashboard_notification_states" USING btree ("user_id","notification_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "dashboard_notification_states_user_id_archived_idx" ON "dashboard_notification_states" USING btree ("user_id","archived_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "lancamento_anexos_anexo_id_idx" ON "lancamento_anexos" USING btree ("anexo_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint
|
||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font";
|
||||||
1
drizzle/0024_petite_lucky_pierre.sql
Normal file
1
drizzle/0024_petite_lucky_pierre.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "preferencias_usuario" ADD COLUMN "attachment_max_size_mb" integer DEFAULT 50 NOT NULL;
|
||||||
2
drizzle/0026_next_blue_blade.sql
Normal file
2
drizzle/0026_next_blue_blade.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "system_font";--> statement-breakpoint
|
||||||
|
ALTER TABLE "preferencias_usuario" DROP COLUMN "money_font";
|
||||||
14
drizzle/0027_glorious_mindworm.sql
Normal file
14
drizzle/0027_glorious_mindworm.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE "dashboard_notification_states" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"notification_key" text NOT NULL,
|
||||||
|
"fingerprint" text NOT NULL,
|
||||||
|
"read_at" timestamp with time zone,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboard_notification_states" ADD CONSTRAINT "dashboard_notification_states_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "dashboard_notification_states_user_id_key_unique" ON "dashboard_notification_states" USING btree ("user_id","notification_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "dashboard_notification_states_user_id_archived_idx" ON "dashboard_notification_states" USING btree ("user_id","archived_at");
|
||||||
@@ -93,12 +93,8 @@
|
|||||||
"name": "account_userId_user_id_fk",
|
"name": "account_userId_user_id_fk",
|
||||||
"tableFrom": "account",
|
"tableFrom": "account",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["userId"],
|
||||||
"userId"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -213,12 +209,8 @@
|
|||||||
"name": "tokens_api_user_id_user_id_fk",
|
"name": "tokens_api_user_id_user_id_fk",
|
||||||
"tableFrom": "tokens_api",
|
"tableFrom": "tokens_api",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -327,12 +319,8 @@
|
|||||||
"name": "orcamentos_user_id_user_id_fk",
|
"name": "orcamentos_user_id_user_id_fk",
|
||||||
"tableFrom": "orcamentos",
|
"tableFrom": "orcamentos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -340,12 +328,8 @@
|
|||||||
"name": "orcamentos_categoria_id_categorias_id_fk",
|
"name": "orcamentos_categoria_id_categorias_id_fk",
|
||||||
"tableFrom": "orcamentos",
|
"tableFrom": "orcamentos",
|
||||||
"tableTo": "categorias",
|
"tableTo": "categorias",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["categoria_id"],
|
||||||
"categoria_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -463,12 +447,8 @@
|
|||||||
"name": "cartoes_user_id_user_id_fk",
|
"name": "cartoes_user_id_user_id_fk",
|
||||||
"tableFrom": "cartoes",
|
"tableFrom": "cartoes",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -476,12 +456,8 @@
|
|||||||
"name": "cartoes_conta_id_contas_id_fk",
|
"name": "cartoes_conta_id_contas_id_fk",
|
||||||
"tableFrom": "cartoes",
|
"tableFrom": "cartoes",
|
||||||
"tableTo": "contas",
|
"tableTo": "contas",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["conta_id"],
|
||||||
"conta_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -563,12 +539,8 @@
|
|||||||
"name": "categorias_user_id_user_id_fk",
|
"name": "categorias_user_id_user_id_fk",
|
||||||
"tableFrom": "categorias",
|
"tableFrom": "categorias",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -683,12 +655,8 @@
|
|||||||
"name": "contas_user_id_user_id_fk",
|
"name": "contas_user_id_user_id_fk",
|
||||||
"tableFrom": "contas",
|
"tableFrom": "contas",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -847,12 +815,8 @@
|
|||||||
"name": "pre_lancamentos_user_id_user_id_fk",
|
"name": "pre_lancamentos_user_id_user_id_fk",
|
||||||
"tableFrom": "pre_lancamentos",
|
"tableFrom": "pre_lancamentos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -860,12 +824,8 @@
|
|||||||
"name": "pre_lancamentos_lancamento_id_lancamentos_id_fk",
|
"name": "pre_lancamentos_lancamento_id_lancamentos_id_fk",
|
||||||
"tableFrom": "pre_lancamentos",
|
"tableFrom": "pre_lancamentos",
|
||||||
"tableTo": "lancamentos",
|
"tableTo": "lancamentos",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["lancamento_id"],
|
||||||
"lancamento_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "set null",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1005,12 +965,8 @@
|
|||||||
"name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk",
|
"name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "lancamentos",
|
"tableTo": "lancamentos",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["lancamento_id"],
|
||||||
"lancamento_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1018,12 +974,8 @@
|
|||||||
"name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk",
|
"name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "pagadores",
|
"tableTo": "pagadores",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["pagador_id"],
|
||||||
"pagador_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1031,12 +983,8 @@
|
|||||||
"name": "antecipacoes_parcelas_categoria_id_categorias_id_fk",
|
"name": "antecipacoes_parcelas_categoria_id_categorias_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "categorias",
|
"tableTo": "categorias",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["categoria_id"],
|
||||||
"categoria_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1044,12 +992,8 @@
|
|||||||
"name": "antecipacoes_parcelas_user_id_user_id_fk",
|
"name": "antecipacoes_parcelas_user_id_user_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1179,12 +1123,8 @@
|
|||||||
"name": "faturas_user_id_user_id_fk",
|
"name": "faturas_user_id_user_id_fk",
|
||||||
"tableFrom": "faturas",
|
"tableFrom": "faturas",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1192,12 +1132,8 @@
|
|||||||
"name": "faturas_cartao_id_cartoes_id_fk",
|
"name": "faturas_cartao_id_cartoes_id_fk",
|
||||||
"tableFrom": "faturas",
|
"tableFrom": "faturas",
|
||||||
"tableTo": "cartoes",
|
"tableTo": "cartoes",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["cartao_id"],
|
||||||
"cartao_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -1271,12 +1207,8 @@
|
|||||||
"name": "anotacoes_user_id_user_id_fk",
|
"name": "anotacoes_user_id_user_id_fk",
|
||||||
"tableFrom": "anotacoes",
|
"tableFrom": "anotacoes",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1364,12 +1296,8 @@
|
|||||||
"name": "passkey_userId_user_id_fk",
|
"name": "passkey_userId_user_id_fk",
|
||||||
"tableFrom": "passkey",
|
"tableFrom": "passkey",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["userId"],
|
||||||
"userId"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1452,12 +1380,8 @@
|
|||||||
"name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk",
|
"name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk",
|
||||||
"tableFrom": "compartilhamentos_pagador",
|
"tableFrom": "compartilhamentos_pagador",
|
||||||
"tableTo": "pagadores",
|
"tableTo": "pagadores",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["pagador_id"],
|
||||||
"pagador_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1465,12 +1389,8 @@
|
|||||||
"name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk",
|
"name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk",
|
||||||
"tableFrom": "compartilhamentos_pagador",
|
"tableFrom": "compartilhamentos_pagador",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["shared_with_user_id"],
|
||||||
"shared_with_user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1478,12 +1398,8 @@
|
|||||||
"name": "compartilhamentos_pagador_created_by_user_id_user_id_fk",
|
"name": "compartilhamentos_pagador_created_by_user_id_user_id_fk",
|
||||||
"tableFrom": "compartilhamentos_pagador",
|
"tableFrom": "compartilhamentos_pagador",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["created_by_user_id"],
|
||||||
"created_by_user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1639,12 +1555,8 @@
|
|||||||
"name": "pagadores_user_id_user_id_fk",
|
"name": "pagadores_user_id_user_id_fk",
|
||||||
"tableFrom": "pagadores",
|
"tableFrom": "pagadores",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1733,12 +1645,8 @@
|
|||||||
"name": "insights_salvos_user_id_user_id_fk",
|
"name": "insights_salvos_user_id_user_id_fk",
|
||||||
"tableFrom": "insights_salvos",
|
"tableFrom": "insights_salvos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1808,12 +1716,8 @@
|
|||||||
"name": "session_userId_user_id_fk",
|
"name": "session_userId_user_id_fk",
|
||||||
"tableFrom": "session",
|
"tableFrom": "session",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["userId"],
|
||||||
"userId"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1823,9 +1727,7 @@
|
|||||||
"session_token_unique": {
|
"session_token_unique": {
|
||||||
"name": "session_token_unique",
|
"name": "session_token_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["token"]
|
||||||
"token"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
@@ -2228,12 +2130,8 @@
|
|||||||
"name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk",
|
"name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "antecipacoes_parcelas",
|
"tableTo": "antecipacoes_parcelas",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["antecipacao_id"],
|
||||||
"antecipacao_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "set null",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -2241,12 +2139,8 @@
|
|||||||
"name": "lancamentos_user_id_user_id_fk",
|
"name": "lancamentos_user_id_user_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -2254,12 +2148,8 @@
|
|||||||
"name": "lancamentos_cartao_id_cartoes_id_fk",
|
"name": "lancamentos_cartao_id_cartoes_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "cartoes",
|
"tableTo": "cartoes",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["cartao_id"],
|
||||||
"cartao_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
},
|
},
|
||||||
@@ -2267,12 +2157,8 @@
|
|||||||
"name": "lancamentos_conta_id_contas_id_fk",
|
"name": "lancamentos_conta_id_contas_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "contas",
|
"tableTo": "contas",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["conta_id"],
|
||||||
"conta_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
},
|
},
|
||||||
@@ -2280,12 +2166,8 @@
|
|||||||
"name": "lancamentos_categoria_id_categorias_id_fk",
|
"name": "lancamentos_categoria_id_categorias_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "categorias",
|
"tableTo": "categorias",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["categoria_id"],
|
||||||
"categoria_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
},
|
},
|
||||||
@@ -2293,12 +2175,8 @@
|
|||||||
"name": "lancamentos_pagador_id_pagadores_id_fk",
|
"name": "lancamentos_pagador_id_pagadores_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "pagadores",
|
"tableTo": "pagadores",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["pagador_id"],
|
||||||
"pagador_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -2363,9 +2241,7 @@
|
|||||||
"user_email_unique": {
|
"user_email_unique": {
|
||||||
"name": "user_email_unique",
|
"name": "user_email_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["email"]
|
||||||
"email"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
@@ -2443,12 +2319,8 @@
|
|||||||
"name": "preferencias_usuario_user_id_user_id_fk",
|
"name": "preferencias_usuario_user_id_user_id_fk",
|
||||||
"tableFrom": "preferencias_usuario",
|
"tableFrom": "preferencias_usuario",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -2458,9 +2330,7 @@
|
|||||||
"preferencias_usuario_user_id_unique": {
|
"preferencias_usuario_user_id_unique": {
|
||||||
"name": "preferencias_usuario_user_id_unique",
|
"name": "preferencias_usuario_user_id_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["user_id"]
|
||||||
"user_id"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|||||||
@@ -93,12 +93,8 @@
|
|||||||
"name": "account_userId_user_id_fk",
|
"name": "account_userId_user_id_fk",
|
||||||
"tableFrom": "account",
|
"tableFrom": "account",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["userId"],
|
||||||
"userId"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -213,12 +209,8 @@
|
|||||||
"name": "tokens_api_user_id_user_id_fk",
|
"name": "tokens_api_user_id_user_id_fk",
|
||||||
"tableFrom": "tokens_api",
|
"tableFrom": "tokens_api",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -327,12 +319,8 @@
|
|||||||
"name": "orcamentos_user_id_user_id_fk",
|
"name": "orcamentos_user_id_user_id_fk",
|
||||||
"tableFrom": "orcamentos",
|
"tableFrom": "orcamentos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -340,12 +328,8 @@
|
|||||||
"name": "orcamentos_categoria_id_categorias_id_fk",
|
"name": "orcamentos_categoria_id_categorias_id_fk",
|
||||||
"tableFrom": "orcamentos",
|
"tableFrom": "orcamentos",
|
||||||
"tableTo": "categorias",
|
"tableTo": "categorias",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["categoria_id"],
|
||||||
"categoria_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -463,12 +447,8 @@
|
|||||||
"name": "cartoes_user_id_user_id_fk",
|
"name": "cartoes_user_id_user_id_fk",
|
||||||
"tableFrom": "cartoes",
|
"tableFrom": "cartoes",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -476,12 +456,8 @@
|
|||||||
"name": "cartoes_conta_id_contas_id_fk",
|
"name": "cartoes_conta_id_contas_id_fk",
|
||||||
"tableFrom": "cartoes",
|
"tableFrom": "cartoes",
|
||||||
"tableTo": "contas",
|
"tableTo": "contas",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["conta_id"],
|
||||||
"conta_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -563,12 +539,8 @@
|
|||||||
"name": "categorias_user_id_user_id_fk",
|
"name": "categorias_user_id_user_id_fk",
|
||||||
"tableFrom": "categorias",
|
"tableFrom": "categorias",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -683,12 +655,8 @@
|
|||||||
"name": "contas_user_id_user_id_fk",
|
"name": "contas_user_id_user_id_fk",
|
||||||
"tableFrom": "contas",
|
"tableFrom": "contas",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -847,12 +815,8 @@
|
|||||||
"name": "pre_lancamentos_user_id_user_id_fk",
|
"name": "pre_lancamentos_user_id_user_id_fk",
|
||||||
"tableFrom": "pre_lancamentos",
|
"tableFrom": "pre_lancamentos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -860,12 +824,8 @@
|
|||||||
"name": "pre_lancamentos_lancamento_id_lancamentos_id_fk",
|
"name": "pre_lancamentos_lancamento_id_lancamentos_id_fk",
|
||||||
"tableFrom": "pre_lancamentos",
|
"tableFrom": "pre_lancamentos",
|
||||||
"tableTo": "lancamentos",
|
"tableTo": "lancamentos",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["lancamento_id"],
|
||||||
"lancamento_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "set null",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1005,12 +965,8 @@
|
|||||||
"name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk",
|
"name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "lancamentos",
|
"tableTo": "lancamentos",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["lancamento_id"],
|
||||||
"lancamento_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1018,12 +974,8 @@
|
|||||||
"name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk",
|
"name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "pagadores",
|
"tableTo": "pagadores",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["pagador_id"],
|
||||||
"pagador_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1031,12 +983,8 @@
|
|||||||
"name": "antecipacoes_parcelas_categoria_id_categorias_id_fk",
|
"name": "antecipacoes_parcelas_categoria_id_categorias_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "categorias",
|
"tableTo": "categorias",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["categoria_id"],
|
||||||
"categoria_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1044,12 +992,8 @@
|
|||||||
"name": "antecipacoes_parcelas_user_id_user_id_fk",
|
"name": "antecipacoes_parcelas_user_id_user_id_fk",
|
||||||
"tableFrom": "antecipacoes_parcelas",
|
"tableFrom": "antecipacoes_parcelas",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1179,12 +1123,8 @@
|
|||||||
"name": "faturas_user_id_user_id_fk",
|
"name": "faturas_user_id_user_id_fk",
|
||||||
"tableFrom": "faturas",
|
"tableFrom": "faturas",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1192,12 +1132,8 @@
|
|||||||
"name": "faturas_cartao_id_cartoes_id_fk",
|
"name": "faturas_cartao_id_cartoes_id_fk",
|
||||||
"tableFrom": "faturas",
|
"tableFrom": "faturas",
|
||||||
"tableTo": "cartoes",
|
"tableTo": "cartoes",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["cartao_id"],
|
||||||
"cartao_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -1271,12 +1207,8 @@
|
|||||||
"name": "anotacoes_user_id_user_id_fk",
|
"name": "anotacoes_user_id_user_id_fk",
|
||||||
"tableFrom": "anotacoes",
|
"tableFrom": "anotacoes",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1364,12 +1296,8 @@
|
|||||||
"name": "passkey_userId_user_id_fk",
|
"name": "passkey_userId_user_id_fk",
|
||||||
"tableFrom": "passkey",
|
"tableFrom": "passkey",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["userId"],
|
||||||
"userId"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1452,12 +1380,8 @@
|
|||||||
"name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk",
|
"name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk",
|
||||||
"tableFrom": "compartilhamentos_pagador",
|
"tableFrom": "compartilhamentos_pagador",
|
||||||
"tableTo": "pagadores",
|
"tableTo": "pagadores",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["pagador_id"],
|
||||||
"pagador_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1465,12 +1389,8 @@
|
|||||||
"name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk",
|
"name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk",
|
||||||
"tableFrom": "compartilhamentos_pagador",
|
"tableFrom": "compartilhamentos_pagador",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["shared_with_user_id"],
|
||||||
"shared_with_user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -1478,12 +1398,8 @@
|
|||||||
"name": "compartilhamentos_pagador_created_by_user_id_user_id_fk",
|
"name": "compartilhamentos_pagador_created_by_user_id_user_id_fk",
|
||||||
"tableFrom": "compartilhamentos_pagador",
|
"tableFrom": "compartilhamentos_pagador",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["created_by_user_id"],
|
||||||
"created_by_user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1639,12 +1555,8 @@
|
|||||||
"name": "pagadores_user_id_user_id_fk",
|
"name": "pagadores_user_id_user_id_fk",
|
||||||
"tableFrom": "pagadores",
|
"tableFrom": "pagadores",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1733,12 +1645,8 @@
|
|||||||
"name": "insights_salvos_user_id_user_id_fk",
|
"name": "insights_salvos_user_id_user_id_fk",
|
||||||
"tableFrom": "insights_salvos",
|
"tableFrom": "insights_salvos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1808,12 +1716,8 @@
|
|||||||
"name": "session_userId_user_id_fk",
|
"name": "session_userId_user_id_fk",
|
||||||
"tableFrom": "session",
|
"tableFrom": "session",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["userId"],
|
||||||
"userId"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -1823,9 +1727,7 @@
|
|||||||
"session_token_unique": {
|
"session_token_unique": {
|
||||||
"name": "session_token_unique",
|
"name": "session_token_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["token"]
|
||||||
"token"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
@@ -2228,12 +2130,8 @@
|
|||||||
"name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk",
|
"name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "antecipacoes_parcelas",
|
"tableTo": "antecipacoes_parcelas",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["antecipacao_id"],
|
||||||
"antecipacao_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "set null",
|
"onDelete": "set null",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -2241,12 +2139,8 @@
|
|||||||
"name": "lancamentos_user_id_user_id_fk",
|
"name": "lancamentos_user_id_user_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -2254,12 +2148,8 @@
|
|||||||
"name": "lancamentos_cartao_id_cartoes_id_fk",
|
"name": "lancamentos_cartao_id_cartoes_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "cartoes",
|
"tableTo": "cartoes",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["cartao_id"],
|
||||||
"cartao_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
},
|
},
|
||||||
@@ -2267,12 +2157,8 @@
|
|||||||
"name": "lancamentos_conta_id_contas_id_fk",
|
"name": "lancamentos_conta_id_contas_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "contas",
|
"tableTo": "contas",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["conta_id"],
|
||||||
"conta_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
},
|
},
|
||||||
@@ -2280,12 +2166,8 @@
|
|||||||
"name": "lancamentos_categoria_id_categorias_id_fk",
|
"name": "lancamentos_categoria_id_categorias_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "categorias",
|
"tableTo": "categorias",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["categoria_id"],
|
||||||
"categoria_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
},
|
},
|
||||||
@@ -2293,12 +2175,8 @@
|
|||||||
"name": "lancamentos_pagador_id_pagadores_id_fk",
|
"name": "lancamentos_pagador_id_pagadores_id_fk",
|
||||||
"tableFrom": "lancamentos",
|
"tableFrom": "lancamentos",
|
||||||
"tableTo": "pagadores",
|
"tableTo": "pagadores",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["pagador_id"],
|
||||||
"pagador_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "cascade"
|
"onUpdate": "cascade"
|
||||||
}
|
}
|
||||||
@@ -2363,9 +2241,7 @@
|
|||||||
"user_email_unique": {
|
"user_email_unique": {
|
||||||
"name": "user_email_unique",
|
"name": "user_email_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["email"]
|
||||||
"email"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
@@ -2443,12 +2319,8 @@
|
|||||||
"name": "preferencias_usuario_user_id_user_id_fk",
|
"name": "preferencias_usuario_user_id_user_id_fk",
|
||||||
"tableFrom": "preferencias_usuario",
|
"tableFrom": "preferencias_usuario",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -2458,9 +2330,7 @@
|
|||||||
"preferencias_usuario_user_id_unique": {
|
"preferencias_usuario_user_id_unique": {
|
||||||
"name": "preferencias_usuario_user_id_unique",
|
"name": "preferencias_usuario_user_id_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["user_id"]
|
||||||
"user_id"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
@@ -2552,12 +2422,8 @@
|
|||||||
"name": "import_category_mappings_user_id_user_id_fk",
|
"name": "import_category_mappings_user_id_user_id_fk",
|
||||||
"tableFrom": "import_category_mappings",
|
"tableFrom": "import_category_mappings",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
@@ -2565,12 +2431,8 @@
|
|||||||
"name": "import_category_mappings_category_id_categorias_id_fk",
|
"name": "import_category_mappings_category_id_categorias_id_fk",
|
||||||
"tableFrom": "import_category_mappings",
|
"tableFrom": "import_category_mappings",
|
||||||
"tableTo": "categorias",
|
"tableTo": "categorias",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["category_id"],
|
||||||
"category_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -2578,10 +2440,7 @@
|
|||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"import_category_mappings_user_id_description_key_pk": {
|
"import_category_mappings_user_id_description_key_pk": {
|
||||||
"name": "import_category_mappings_user_id_description_key_pk",
|
"name": "import_category_mappings_user_id_description_key_pk",
|
||||||
"columns": [
|
"columns": ["user_id", "description_key"]
|
||||||
"user_id",
|
|
||||||
"description_key"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
|
|||||||
2704
drizzle/meta/0023_snapshot.json
Normal file
2704
drizzle/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2873
drizzle/meta/0024_snapshot.json
Normal file
2873
drizzle/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -162,6 +162,20 @@
|
|||||||
"when": 1748000000000,
|
"when": 1748000000000,
|
||||||
"tag": "0022_import-category-mappings",
|
"tag": "0022_import-category-mappings",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774529878374,
|
||||||
|
"tag": "0023_sturdy_wolfpack",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774891206703,
|
||||||
|
"tag": "0024_petite_lucky_pierre",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
34
package.json
34
package.json
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.0.0",
|
"version": "2.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"dev-env": "tsx scripts/dev.ts",
|
"db:seed": "tsx scripts/mock-data.ts",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"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:enableExtensions": "tsx scripts/postgres/enable-extensions.ts",
|
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"docker:up": "docker compose up --build",
|
"docker:up": "docker compose up --build",
|
||||||
"docker:up:db": "docker compose up -d db",
|
"docker:up:db": "docker compose up -d db",
|
||||||
@@ -28,10 +28,12 @@
|
|||||||
"backup": "bash scripts/backup.sh"
|
"backup": "bash scripts/backup.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.62",
|
"@ai-sdk/anthropic": "^3.0.64",
|
||||||
"@ai-sdk/google": "^3.0.51",
|
"@ai-sdk/google": "^3.0.53",
|
||||||
"@ai-sdk/openai": "^3.0.46",
|
"@ai-sdk/openai": "^3.0.48",
|
||||||
"@better-auth/passkey": "^1.5.5",
|
"@aws-sdk/client-s3": "^3.1019.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1019.0",
|
||||||
|
"@better-auth/passkey": "^1.5.6",
|
||||||
"@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",
|
||||||
@@ -58,16 +60,14 @@
|
|||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@tanstack/react-virtual": "^3.13.23",
|
||||||
"@vercel/analytics": "^2.0.1",
|
"ai": "^6.0.141",
|
||||||
"@vercel/speed-insights": "^2.0.0",
|
"better-auth": "1.5.6",
|
||||||
"ai": "^6.0.127",
|
|
||||||
"better-auth": "1.5.5",
|
|
||||||
"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",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.2",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.1",
|
||||||
"resend": "^6.9.4",
|
"resend": "^6.9.4",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
@@ -86,17 +86,17 @@
|
|||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.8",
|
"@biomejs/biome": "2.4.9",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.2.2",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "25.5.0",
|
"@types/node": "25.5.0",
|
||||||
"@types/pg": "^8.18.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"tailwindcss": "4.2.1",
|
"tailwindcss": "4.2.2",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "5.9.3"
|
"typescript": "6.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1687
pnpm-lock.yaml
generated
1687
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -81,7 +81,7 @@ fi
|
|||||||
|
|
||||||
# Extrai dados puros do dump custom (sem nova conexão ao banco)
|
# Extrai dados puros do dump custom (sem nova conexão ao banco)
|
||||||
pg_restore --data-only --schema=public --no-owner --no-privileges \
|
pg_restore --data-only --schema=public --no-owner --no-privileges \
|
||||||
"$DUMP_FILE" | gzip > "$DATA_FILE"
|
-f - "$DUMP_FILE" | gzip > "$DATA_FILE"
|
||||||
|
|
||||||
log "Dump concluído: $(du -sh "$DUMP_FILE" | cut -f1) (.dump) | $(du -sh "$SQL_FILE" | cut -f1) (.sql.gz) | $(du -sh "$DATA_FILE" | cut -f1) (.data.sql.gz)"
|
log "Dump concluído: $(du -sh "$DUMP_FILE" | cut -f1) (.dump) | $(du -sh "$SQL_FILE" | cut -f1) (.sql.gz) | $(du -sh "$DATA_FILE" | cut -f1) (.data.sql.gz)"
|
||||||
|
|
||||||
|
|||||||
@@ -135,11 +135,11 @@ type SeedSummary = {
|
|||||||
function printUsage() {
|
function printUsage() {
|
||||||
console.log(`
|
console.log(`
|
||||||
Uso:
|
Uso:
|
||||||
pnpm seed:empty-account -- --userId=<id> --startPeriod=YYYY-MM [--months=${DEFAULT_MONTHS}]
|
pnpm mockup -- --userId=<id> --startPeriod=YYYY-MM [--months=${DEFAULT_MONTHS}]
|
||||||
|
|
||||||
Exemplos:
|
Exemplos:
|
||||||
pnpm seed:empty-account -- --userId=user_123 --startPeriod=2026-01
|
pnpm mockup -- --userId=user_123 --startPeriod=2026-01
|
||||||
pnpm seed:empty-account -- --userId=user_123 --startPeriod=2025-10 --months=8
|
pnpm mockup -- --userId=user_123 --startPeriod=2025-10 --months=8
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,11 +766,12 @@ async function seedInvoicesForCards(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
if (!process.env.DATABASE_URL) {
|
if (!process.env.DATABASE_URL) {
|
||||||
throw new Error("DATABASE_URL não está configurada no ambiente.");
|
throw new Error("DATABASE_URL não está configurada no ambiente.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = parseArgs(process.argv.slice(2));
|
|
||||||
const logoOptions = await loadLogoOptions();
|
const logoOptions = await loadLogoOptions();
|
||||||
const avatarOptions = await loadAvatarOptions();
|
const avatarOptions = await loadAvatarOptions();
|
||||||
const businessToday = getBusinessTodayInfo();
|
const businessToday = getBusinessTodayInfo();
|
||||||
@@ -794,9 +795,8 @@ async function main() {
|
|||||||
throw new Error(`Usuário ${options.userId} não foi encontrado.`);
|
throw new Error(`Usuário ${options.userId} não foi encontrado.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureCategories(targetUser.id);
|
|
||||||
const adminPayer = await ensureAdminPayer(targetUser);
|
|
||||||
await assertFinancialSpaceIsEmpty(targetUser.id);
|
await assertFinancialSpaceIsEmpty(targetUser.id);
|
||||||
|
const adminPayer = await ensureAdminPayer(targetUser);
|
||||||
|
|
||||||
const categoriesByName = await ensureCategories(targetUser.id);
|
const categoriesByName = await ensureCategories(targetUser.id);
|
||||||
|
|
||||||
|
|||||||
39
setup.mjs
39
setup.mjs
@@ -21,6 +21,7 @@ const c = {
|
|||||||
red: "\x1b[31m",
|
red: "\x1b[31m",
|
||||||
yellow: "\x1b[33m",
|
yellow: "\x1b[33m",
|
||||||
cyan: "\x1b[36m",
|
cyan: "\x1b[36m",
|
||||||
|
orange: "\x1b[38;5;214m",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sym = {
|
const sym = {
|
||||||
@@ -81,10 +82,38 @@ function abort(msg) {
|
|||||||
|
|
||||||
// ─── Header ──────────────────────────────────────────────────────────────────
|
// ─── Header ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
console.log(`
|
const logoLines = [
|
||||||
${c.bold}${c.cyan} OpenMonetis — Setup${c.reset}
|
".............................+@@@@@@@@@@=.............................",
|
||||||
${c.dim}Gestão financeira self-hosted${c.reset}
|
".............................@@@@@@@@@@@:.............................",
|
||||||
`);
|
"...................+@@@@@@*-:@@@@@@@@@@%...=@@@@@@-...................",
|
||||||
|
"..................@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%..................",
|
||||||
|
"................=@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+................",
|
||||||
|
"...................-=+%@@@@@@@@@@@@@@@@@@@@@*:........................",
|
||||||
|
".......................#@@@@@@@@@@@@@@@@@@@@@@@+......................",
|
||||||
|
"....................%@@@@@@@@@@@@@%#@@@@@@@@@@@@*.....................",
|
||||||
|
"....................+@@@@@@@@@@@......*@@@@@@#........................",
|
||||||
|
".........................:#@@=...........+#...........................",
|
||||||
|
];
|
||||||
|
|
||||||
|
const nameLines = [
|
||||||
|
" ___ __ __ _ _ ",
|
||||||
|
" / _ \\ _ __ ___ _ __ | \\/ | ___ _ __ ___| |_(_)___ ",
|
||||||
|
" | | | | '_ \\ / _ \\ '_ \\| |\\/| |/ _ \\| '_ \\ / _ \\ __| / __|",
|
||||||
|
" | |_| | |_) | __/ | | | | | | (_) | | | | __/ |_| \\__ \\",
|
||||||
|
" \\___/| .__/ \\___|_| |_|_| |_|\\___/|_| |_|\\___|\\__|_|___/",
|
||||||
|
" |_| ",
|
||||||
|
];
|
||||||
|
|
||||||
|
const nameStart = Math.floor((logoLines.length - nameLines.length) / 2);
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
for (let i = 0; i < logoLines.length; i++) {
|
||||||
|
const logoCol = c.orange + logoLines[i].replaceAll(".", " ").substring(14, 56).padEnd(42) + c.reset;
|
||||||
|
const nameIdx = i - nameStart;
|
||||||
|
const nameCol = nameIdx >= 0 && nameIdx < nameLines.length ? nameLines[nameIdx] : "";
|
||||||
|
console.log(logoCol + " " + nameCol);
|
||||||
|
}
|
||||||
|
console.log(`\n${" ".repeat(46)}${c.dim}Gestão financeira · self-hosted${c.reset}\n`);
|
||||||
|
|
||||||
// ─── ETAPA 1: Verificações do sistema ────────────────────────────────────────
|
// ─── ETAPA 1: Verificações do sistema ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -329,7 +358,7 @@ if (useLocalDocker) {
|
|||||||
// Extensões
|
// Extensões
|
||||||
s = spinner("Habilitando extensões do banco...");
|
s = spinner("Habilitando extensões do banco...");
|
||||||
try {
|
try {
|
||||||
run("pnpm db:enableExtensions", { cwd: targetDir });
|
run("pnpm db:extensions", { cwd: targetDir });
|
||||||
s.stop("Extensões habilitadas");
|
s.stop("Extensões habilitadas");
|
||||||
} catch {
|
} catch {
|
||||||
s.fail("Falha ao habilitar extensões");
|
s.fail("Falha ao habilitar extensões");
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
allowCreate={false}
|
allowCreate={false}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
allowCreate
|
allowCreate
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
defaultCardId={card.id}
|
defaultCardId={card.id}
|
||||||
defaultPaymentMethod="Cartão de crédito"
|
defaultPaymentMethod="Cartão de crédito"
|
||||||
lockCardSelection
|
lockCardSelection
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
allowCreate={true}
|
allowCreate={true}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { InboxPage } from "@/features/inbox/components/inbox-page";
|
import { InboxPage } from "@/features/inbox/components/inbox-page";
|
||||||
import {
|
import {
|
||||||
type ResolvedInboxSearchParams,
|
type ResolvedInboxSearchParams,
|
||||||
|
resolveInboxApp,
|
||||||
resolveInboxPagination,
|
resolveInboxPagination,
|
||||||
resolveInboxStatus,
|
resolveInboxStatus,
|
||||||
} from "@/features/inbox/page-helpers";
|
} from "@/features/inbox/page-helpers";
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
fetchAppLogoMap,
|
fetchAppLogoMap,
|
||||||
fetchInboxDialogData,
|
fetchInboxDialogData,
|
||||||
fetchInboxItemsPage,
|
fetchInboxItemsPage,
|
||||||
|
fetchInboxSourceApps,
|
||||||
fetchInboxStatusCounts,
|
fetchInboxStatusCounts,
|
||||||
} from "@/features/inbox/queries";
|
} from "@/features/inbox/queries";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
@@ -32,21 +34,31 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||||
const activeStatus = resolveInboxStatus(resolvedSearchParams);
|
const activeStatus = resolveInboxStatus(resolvedSearchParams);
|
||||||
|
const activeApp = resolveInboxApp(resolvedSearchParams);
|
||||||
const paginationInput = resolveInboxPagination(resolvedSearchParams);
|
const paginationInput = resolveInboxPagination(resolvedSearchParams);
|
||||||
|
|
||||||
const [itemsPage, counts, dialogData, appLogoMap] = await Promise.all([
|
const [itemsPage, counts, sourceApps, dialogData, appLogoMap] =
|
||||||
fetchInboxItemsPage(userId, activeStatus, paginationInput),
|
await Promise.all([
|
||||||
|
fetchInboxItemsPage(userId, activeStatus, {
|
||||||
|
...paginationInput,
|
||||||
|
sourceApp: activeApp,
|
||||||
|
}),
|
||||||
fetchInboxStatusCounts(userId),
|
fetchInboxStatusCounts(userId),
|
||||||
|
fetchInboxSourceApps(userId, activeStatus).catch(() => [] as string[]),
|
||||||
activeStatus === "pending"
|
activeStatus === "pending"
|
||||||
? fetchInboxDialogData(userId)
|
? fetchInboxDialogData(userId)
|
||||||
: Promise.resolve(EMPTY_DIALOG_DATA),
|
: Promise.resolve(EMPTY_DIALOG_DATA),
|
||||||
fetchAppLogoMap(userId),
|
fetchAppLogoMap(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const normalizedSourceApps = Array.isArray(sourceApps) ? sourceApps : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col items-start gap-6">
|
<main className="flex flex-col items-start gap-6">
|
||||||
<InboxPage
|
<InboxPage
|
||||||
activeStatus={activeStatus}
|
activeStatus={activeStatus}
|
||||||
|
activeApp={activeApp}
|
||||||
|
sourceApps={normalizedSourceApps}
|
||||||
items={itemsPage.items}
|
items={itemsPage.items}
|
||||||
counts={counts}
|
counts={counts}
|
||||||
pagination={itemsPage.pagination}
|
pagination={itemsPage.pagination}
|
||||||
|
|||||||
@@ -3,33 +3,14 @@ import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
|
|||||||
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
||||||
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
||||||
import { getUserSession } from "@/shared/lib/auth/server";
|
import { getUserSession } from "@/shared/lib/auth/server";
|
||||||
import { parsePeriodParam } from "@/shared/utils/period";
|
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
searchParams,
|
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
||||||
}>) {
|
}>) {
|
||||||
const session = await getUserSession();
|
const session = await getUserSession();
|
||||||
|
const navbarData = await fetchDashboardNavbarData(session.user.id);
|
||||||
// Buscar notificações para o período atual
|
|
||||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
|
||||||
const periodoParam = resolvedSearchParams?.periodo;
|
|
||||||
const singlePeriodoParam =
|
|
||||||
typeof periodoParam === "string"
|
|
||||||
? periodoParam
|
|
||||||
: Array.isArray(periodoParam)
|
|
||||||
? periodoParam[0]
|
|
||||||
: null;
|
|
||||||
const { period: currentPeriod } = parsePeriodParam(
|
|
||||||
singlePeriodoParam ?? null,
|
|
||||||
);
|
|
||||||
const navbarData = await fetchDashboardNavbarData(
|
|
||||||
session.user.id,
|
|
||||||
currentPeriod,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrivacyProvider>
|
<PrivacyProvider>
|
||||||
@@ -40,7 +21,7 @@ export default async function DashboardLayout({
|
|||||||
notificationsSnapshot={navbarData.notificationsSnapshot}
|
notificationsSnapshot={navbarData.notificationsSnapshot}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex flex-1 flex-col pt-16">
|
<div className="relative flex flex-1 flex-col pt-16">
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-32 overflow-hidden md:h-36">
|
||||||
<DotPattern
|
<DotPattern
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
allowCreate={canEdit}
|
allowCreate={canEdit}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
||||||
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
|
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
|
||||||
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RiArrowRightSLine } from "@remixicon/react";
|
import { RiAndroidLine, RiArrowRightSLine } from "@remixicon/react";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import { UpdateNameForm } from "@/features/settings/components/update-name-form"
|
|||||||
import { UpdatePasswordForm } from "@/features/settings/components/update-password-form";
|
import { UpdatePasswordForm } from "@/features/settings/components/update-password-form";
|
||||||
import { fetchSettingsPageData } from "@/features/settings/queries";
|
import { fetchSettingsPageData } from "@/features/settings/queries";
|
||||||
import { Card } from "@/shared/components/ui/card";
|
import { Card } from "@/shared/components/ui/card";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
@@ -64,12 +65,13 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1">Preferências</h2>
|
<h2 className="text-xl font-bold mb-1">Preferências</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Personalize sua experiência no OpenMonetis ajustando as
|
Personalize sua experiência no OpenMonetis ajustando as
|
||||||
configurações de acordo com suas necessidades.
|
configurações de acordo com suas necessidades.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<PreferencesForm
|
<PreferencesForm
|
||||||
statementNoteAsColumn={
|
statementNoteAsColumn={
|
||||||
userPreferences?.statementNoteAsColumn ?? false
|
userPreferences?.statementNoteAsColumn ?? false
|
||||||
@@ -77,25 +79,46 @@ export default async function Page() {
|
|||||||
transactionsColumnOrder={
|
transactionsColumnOrder={
|
||||||
userPreferences?.transactionsColumnOrder ?? null
|
userPreferences?.transactionsColumnOrder ?? null
|
||||||
}
|
}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="companion" className="mt-4">
|
<TabsContent value="companion" className="mt-4">
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h2 className="text-xl font-bold">OpenMonetis Companion</h2>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
|
||||||
|
<RiAndroidLine className="h-3 w-3" />
|
||||||
|
Android
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Capture notificações de transações dos seus apps de banco
|
||||||
|
(Nubank, Itaú, Bradesco, Inter, C6 e outros) e envie para sua
|
||||||
|
caixa de entrada.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
<CompanionTab tokens={userApiTokens} />
|
<CompanionTab tokens={userApiTokens} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="nome" className="mt-4">
|
<TabsContent value="nome" className="mt-4">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1">Alterar nome</h2>
|
<h2 className="text-xl font-bold mb-1">Alterar nome</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
|
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
|
||||||
ser exibido em diferentes seções do app e em comunicações.
|
ser exibido em diferentes seções do app e em comunicações.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<UpdateNameForm currentName={userName} />
|
<UpdateNameForm currentName={userName} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -105,12 +128,13 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1">Alterar senha</h2>
|
<h2 className="text-xl font-bold mb-1">Alterar senha</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Defina uma nova senha para sua conta. Guarde-a em local
|
Defina uma nova senha para sua conta. Guarde-a em local
|
||||||
seguro.
|
seguro.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<UpdatePasswordForm authProvider={authProvider} />
|
<UpdatePasswordForm authProvider={authProvider} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -120,12 +144,13 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1">Passkeys</h2>
|
<h2 className="text-xl font-bold mb-1">Passkeys</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Passkeys permitem login sem senha, usando biometria (Face ID,
|
Passkeys permitem login sem senha, usando biometria (Face ID,
|
||||||
Touch ID, Windows Hello) ou chaves de segurança.
|
Touch ID, Windows Hello) ou chaves de segurança.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<PasskeysForm />
|
<PasskeysForm />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -135,13 +160,14 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
|
<h2 className="text-xl font-bold mb-1">Alterar e-mail</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Atualize o e-mail associado à sua conta. Você precisará
|
Atualize o e-mail associado à sua conta. Você precisará
|
||||||
confirmar os links enviados para o novo e também para o e-mail
|
confirmar os links enviados para o novo e também para o e-mail
|
||||||
atual (quando aplicável) para concluir a alteração.
|
atual (quando aplicável) para concluir a alteração.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<UpdateEmailForm
|
<UpdateEmailForm
|
||||||
currentEmail={userEmail}
|
currentEmail={userEmail}
|
||||||
authProvider={authProvider}
|
authProvider={authProvider}
|
||||||
@@ -154,14 +180,15 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold mb-1 text-destructive">
|
<h2 className="text-xl font-bold mb-1 text-destructive">
|
||||||
Ações perigosas
|
Ações perigosas
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
Você pode zerar os dados do OpenMonetis e manter seu acesso,
|
Você pode zerar os dados do OpenMonetis e manter seu acesso,
|
||||||
ou excluir sua conta inteira de forma irreversível.
|
ou excluir sua conta inteira de forma irreversível.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Separator />
|
||||||
<DeleteAccountForm />
|
<DeleteAccountForm />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import { ImportPage } from "@/features/transactions/components/import/import-page";
|
import { ImportPage } from "@/features/transactions/components/import/import-page";
|
||||||
|
import {
|
||||||
|
buildOptionSets,
|
||||||
|
buildSluggedFilters,
|
||||||
|
} from "@/features/transactions/page-helpers";
|
||||||
import { fetchTransactionFilterSources } from "@/features/transactions/queries";
|
import { fetchTransactionFilterSources } from "@/features/transactions/queries";
|
||||||
import { buildOptionSets, buildSluggedFilters } from "@/features/transactions/page-helpers";
|
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const userId = await getUserId();
|
const userId = await getUserId();
|
||||||
const filterSources = await fetchTransactionFilterSources(userId);
|
const filterSources = await fetchTransactionFilterSources(userId);
|
||||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
const { payerOptions, accountOptions, cardOptions, categoryOptions, defaultPayerId } =
|
const {
|
||||||
buildOptionSets({ ...sluggedFilters, payerRows: filterSources.payerRows });
|
payerOptions,
|
||||||
|
accountOptions,
|
||||||
|
cardOptions,
|
||||||
|
categoryOptions,
|
||||||
|
defaultPayerId,
|
||||||
|
} = buildOptionSets({
|
||||||
|
...sluggedFilters,
|
||||||
|
payerRows: filterSources.payerRows,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-6">
|
<main className="flex flex-col gap-6">
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
}}
|
}}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
extraFeatures,
|
extraFeatures,
|
||||||
getMetricsItems,
|
getMetricsItems,
|
||||||
mainFeatures,
|
mainFeatures,
|
||||||
navbarActionClassName,
|
|
||||||
navLinks,
|
navLinks,
|
||||||
pwaHighlights,
|
pwaHighlights,
|
||||||
stackItems,
|
stackItems,
|
||||||
@@ -27,6 +26,7 @@ import { landingImages } from "@/features/landing/images";
|
|||||||
import { fetchGitHubStats } from "@/features/landing/queries";
|
import { fetchGitHubStats } from "@/features/landing/queries";
|
||||||
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
||||||
import { Logo } from "@/shared/components/logo";
|
import { Logo } from "@/shared/components/logo";
|
||||||
|
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
@@ -50,10 +50,7 @@ export default async function Page() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center bg-primary">
|
<NavbarShell>
|
||||||
<div className="relative z-10 max-w-8xl mx-auto px-4 w-full flex h-full items-center justify-between">
|
|
||||||
<Logo variant="compact" invertTextOnDark={false} />
|
|
||||||
|
|
||||||
{/* Center Navigation Links */}
|
{/* Center Navigation Links */}
|
||||||
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
|
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
|
||||||
{navLinks.map(({ href, label }) => (
|
{navLinks.map(({ href, label }) => (
|
||||||
@@ -67,8 +64,8 @@ export default async function Page() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<nav className="flex items-center gap-2 md:gap-3">
|
<nav className="ml-auto flex items-center gap-2 md:gap-3">
|
||||||
<AnimatedThemeToggler className={navbarActionClassName} />
|
<AnimatedThemeToggler variant="navbar" />
|
||||||
{!isPublicDomain &&
|
{!isPublicDomain &&
|
||||||
(session?.user ? (
|
(session?.user ? (
|
||||||
<Link prefetch href="/dashboard" className="hidden md:block">
|
<Link prefetch href="/dashboard" className="hidden md:block">
|
||||||
@@ -104,11 +101,9 @@ export default async function Page() {
|
|||||||
<MobileNav
|
<MobileNav
|
||||||
isPublicDomain={isPublicDomain}
|
isPublicDomain={isPublicDomain}
|
||||||
isLoggedIn={!!session?.user}
|
isLoggedIn={!!session?.user}
|
||||||
triggerClassName="border border-black/10 text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20"
|
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</NavbarShell>
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { version as APP_VERSION } from "@/package.json";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
|
||||||
const APP_VERSION = "1.0.0";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Health check endpoint para Docker, monitoring e OpenMonetis Companion
|
* Health check endpoint para Docker, monitoring e OpenMonetis Companion
|
||||||
* GET /api/health
|
* GET /api/health
|
||||||
|
|||||||
@@ -86,12 +86,10 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { items } = inboxBatchSchema.parse(body);
|
const { items } = inboxBatchSchema.parse(body);
|
||||||
|
|
||||||
// Processar cada item
|
// Processar todos os itens em paralelo
|
||||||
const results: BatchResult[] = [];
|
const settled = await Promise.allSettled(
|
||||||
|
items.map((item) =>
|
||||||
for (const item of items) {
|
db
|
||||||
try {
|
|
||||||
const [inserted] = await db
|
|
||||||
.insert(inboxItems)
|
.insert(inboxItems)
|
||||||
.values({
|
.values({
|
||||||
userId: tokenRecord.userId,
|
userId: tokenRecord.userId,
|
||||||
@@ -104,22 +102,26 @@ export async function POST(request: Request) {
|
|||||||
parsedAmount: item.parsedAmount?.toString(),
|
parsedAmount: item.parsedAmount?.toString(),
|
||||||
status: "pending",
|
status: "pending",
|
||||||
})
|
})
|
||||||
.returning({ id: inboxItems.id });
|
.returning({ id: inboxItems.id }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
results.push({
|
const results: BatchResult[] = settled.map((result, i) => {
|
||||||
clientId: item.clientId,
|
const item = items[i];
|
||||||
serverId: inserted.id,
|
if (result.status === "fulfilled") {
|
||||||
|
return {
|
||||||
|
clientId: item?.clientId,
|
||||||
|
serverId: result.value[0]?.id,
|
||||||
success: true,
|
success: true,
|
||||||
});
|
};
|
||||||
} catch (error) {
|
}
|
||||||
console.error("[API] Error processing batch item:", error);
|
console.error("[API] Error processing batch item:", result.reason);
|
||||||
results.push({
|
return {
|
||||||
clientId: item.clientId,
|
clientId: item?.clientId,
|
||||||
success: false,
|
success: false,
|
||||||
error: "Erro ao processar notificação",
|
error: "Erro ao processar notificação",
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atualizar último uso do token
|
// Atualizar último uso do token
|
||||||
const clientIp =
|
const clientIp =
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Analytics } from "@vercel/analytics/next";
|
|
||||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
||||||
import { Toaster } from "@/shared/components/ui/sonner";
|
import { Toaster } from "@/shared/components/ui/sonner";
|
||||||
@@ -28,14 +26,18 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
|
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="https://umami.felipecoutinho.com/script.js"
|
||||||
|
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
|
||||||
|
data-domains="openmonetis.com"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className="subpixel-antialiased" suppressHydrationWarning>
|
<body className="subpixel-antialiased" suppressHydrationWarning>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light">
|
<ThemeProvider attribute="class" defaultTheme="light">
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<Analytics />
|
|
||||||
<SpeedInsights />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
104
src/db/schema.ts
104
src/db/schema.ts
@@ -132,11 +132,10 @@ export const userPreferences = pgTable("preferencias_usuario", {
|
|||||||
statementNoteAsColumn: boolean("extrato_note_as_column")
|
statementNoteAsColumn: boolean("extrato_note_as_column")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
systemFont: text("system_font").notNull().default("ai-sans"),
|
|
||||||
moneyFont: text("money_font").notNull().default("ai-sans"),
|
|
||||||
transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
|
transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
|
||||||
string[] | null
|
string[] | null
|
||||||
>(),
|
>(),
|
||||||
|
attachmentMaxSizeMb: integer("attachment_max_size_mb").notNull().default(50),
|
||||||
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
||||||
order: string[];
|
order: string[];
|
||||||
hidden: string[];
|
hidden: string[];
|
||||||
@@ -527,6 +526,40 @@ export const inboxItems = pgTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const dashboardNotificationStates = pgTable(
|
||||||
|
"dashboard_notification_states",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
notificationKey: text("notification_key").notNull(),
|
||||||
|
fingerprint: text("fingerprint").notNull(),
|
||||||
|
readAt: timestamp("read_at", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
archivedAt: timestamp("archived_at", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdNotificationKeyUnique: uniqueIndex(
|
||||||
|
"dashboard_notification_states_user_id_key_unique",
|
||||||
|
).on(table.userId, table.notificationKey),
|
||||||
|
userIdArchivedAtIdx: index(
|
||||||
|
"dashboard_notification_states_user_id_archived_idx",
|
||||||
|
).on(table.userId, table.archivedAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const installmentAnticipations = pgTable(
|
export const installmentAnticipations = pgTable(
|
||||||
"antecipacoes_parcelas",
|
"antecipacoes_parcelas",
|
||||||
{
|
{
|
||||||
@@ -815,7 +848,9 @@ export const inboxItemsRelations = relations(inboxItems, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const transactionsRelations = relations(transactions, ({ one }) => ({
|
export const transactionsRelations = relations(
|
||||||
|
transactions,
|
||||||
|
({ one, many }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [transactions.userId],
|
fields: [transactions.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
@@ -840,7 +875,9 @@ export const transactionsRelations = relations(transactions, ({ one }) => ({
|
|||||||
fields: [transactions.anticipationId],
|
fields: [transactions.anticipationId],
|
||||||
references: [installmentAnticipations.id],
|
references: [installmentAnticipations.id],
|
||||||
}),
|
}),
|
||||||
}));
|
transactionAttachments: many(transactionAttachments),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const installmentAnticipationsRelations = relations(
|
export const installmentAnticipationsRelations = relations(
|
||||||
installmentAnticipations,
|
installmentAnticipations,
|
||||||
@@ -864,6 +901,40 @@ export const installmentAnticipationsRelations = relations(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ===================== ATTACHMENTS =====================
|
||||||
|
|
||||||
|
export const attachments = pgTable("anexos", {
|
||||||
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
fileKey: text("chave_arquivo").notNull().unique(),
|
||||||
|
fileName: text("nome_arquivo").notNull(),
|
||||||
|
fileSize: integer("tamanho_bytes").notNull(),
|
||||||
|
mimeType: text("mime_type").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const transactionAttachments = pgTable(
|
||||||
|
"lancamento_anexos",
|
||||||
|
{
|
||||||
|
transactionId: uuid("lancamento_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => transactions.id, { onDelete: "cascade" }),
|
||||||
|
attachmentId: uuid("anexo_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => attachments.id, { onDelete: "cascade" }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.transactionId, table.attachmentId] }),
|
||||||
|
attachmentIdIdx: index("lancamento_anexos_anexo_id_idx").on(
|
||||||
|
table.attachmentId,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const importCategoryMappings = pgTable(
|
export const importCategoryMappings = pgTable(
|
||||||
"import_category_mappings",
|
"import_category_mappings",
|
||||||
{
|
{
|
||||||
@@ -907,3 +978,28 @@ export type NewApiToken = typeof apiTokens.$inferInsert;
|
|||||||
export type InboxItem = typeof inboxItems.$inferSelect;
|
export type InboxItem = typeof inboxItems.$inferSelect;
|
||||||
export type NewInboxItem = typeof inboxItems.$inferInsert;
|
export type NewInboxItem = typeof inboxItems.$inferInsert;
|
||||||
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect;
|
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect;
|
||||||
|
|
||||||
|
export const attachmentsRelations = relations(attachments, ({ one, many }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [attachments.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
transactionAttachments: many(transactionAttachments),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const transactionAttachmentsRelations = relations(
|
||||||
|
transactionAttachments,
|
||||||
|
({ one }) => ({
|
||||||
|
transaction: one(transactions, {
|
||||||
|
fields: [transactionAttachments.transactionId],
|
||||||
|
references: [transactions.id],
|
||||||
|
}),
|
||||||
|
attachment: one(attachments, {
|
||||||
|
fields: [transactionAttachments.attachmentId],
|
||||||
|
references: [attachments.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Attachment = typeof attachments.$inferSelect;
|
||||||
|
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ const accountBaseSchema = z.object({
|
|||||||
.string({ message: "Selecione um logo." })
|
.string({ message: "Selecione um logo." })
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, "Selecione um logo."),
|
.min(1, "Selecione um logo."),
|
||||||
initialBalance: z
|
initialBalance: z.union([
|
||||||
.union([
|
|
||||||
z.number(),
|
z.number(),
|
||||||
z
|
z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ export function LoginForm({ className, ...props }: DivProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (passkeyError) {
|
if (passkeyError) {
|
||||||
setError(passkeyError.message || "Erro ao entrar com passkey.");
|
setError(
|
||||||
|
(passkeyError.message as string) || "Erro ao entrar com passkey.",
|
||||||
|
);
|
||||||
setLoadingPasskey(false);
|
setLoadingPasskey(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,10 +111,11 @@ export function buildCategoryBreakdownData({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
categories.sort((a, b) => b.currentAmount - a.currentAmount);
|
const filtered = categories.filter((c) => c.currentAmount > 0);
|
||||||
|
filtered.sort((a, b) => b.currentAmount - a.currentAmount);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categories,
|
categories: filtered,
|
||||||
currentTotal,
|
currentTotal,
|
||||||
previousTotal,
|
previousTotal,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export async function fetchCategoryDetails(
|
|||||||
sanitizedNote,
|
sanitizedNote,
|
||||||
eq(transactions.period, previousPeriod),
|
eq(transactions.period, previousPeriod),
|
||||||
or(
|
or(
|
||||||
|
isNull(transactions.note),
|
||||||
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
||||||
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
||||||
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { payers } from "@/db/schema";
|
|||||||
import { fetchPendingInboxCount } from "@/features/inbox/queries";
|
import { fetchPendingInboxCount } from "@/features/inbox/queries";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
import { getBusinessDateString } from "@/shared/utils/date";
|
||||||
import {
|
import {
|
||||||
type DashboardNotificationsSnapshot,
|
type DashboardNotificationsSnapshot,
|
||||||
fetchDashboardNotifications,
|
fetchDashboardNotifications,
|
||||||
@@ -36,8 +37,8 @@ async function fetchAdminPayerAvatarUrl(
|
|||||||
|
|
||||||
async function fetchDashboardNavbarDataInternal(
|
async function fetchDashboardNavbarDataInternal(
|
||||||
userId: string,
|
userId: string,
|
||||||
currentPeriod: string,
|
|
||||||
): Promise<DashboardNavbarData> {
|
): Promise<DashboardNavbarData> {
|
||||||
|
const currentPeriod = getBusinessDateString().slice(0, 7);
|
||||||
const [pagadorAvatarUrl, notificationsSnapshot, preLancamentosCount] =
|
const [pagadorAvatarUrl, notificationsSnapshot, preLancamentosCount] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchAdminPayerAvatarUrl(userId),
|
fetchAdminPayerAvatarUrl(userId),
|
||||||
@@ -52,12 +53,11 @@ async function fetchDashboardNavbarDataInternal(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDashboardNavbarData(
|
export function fetchDashboardNavbarData(userId: string) {
|
||||||
userId: string,
|
const currentPeriod = getBusinessDateString().slice(0, 7);
|
||||||
currentPeriod: string,
|
|
||||||
) {
|
|
||||||
return unstable_cache(
|
return unstable_cache(
|
||||||
() => fetchDashboardNavbarDataInternal(userId, currentPeriod),
|
() => fetchDashboardNavbarDataInternal(userId),
|
||||||
[`dashboard-navbar-${userId}-${currentPeriod}`],
|
[`dashboard-navbar-${userId}-${currentPeriod}`],
|
||||||
{
|
{
|
||||||
tags: [`dashboard-${userId}`],
|
tags: [`dashboard-${userId}`],
|
||||||
|
|||||||
252
src/features/dashboard/notifications-actions.ts
Normal file
252
src/features/dashboard/notifications-actions.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { dashboardNotificationStates } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
handleActionError,
|
||||||
|
revalidateForEntity,
|
||||||
|
} from "@/shared/lib/actions/helpers";
|
||||||
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
|
||||||
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
|
|
||||||
|
const notificationStateSchema = z.object({
|
||||||
|
notificationKey: z
|
||||||
|
.string({ message: "Chave da notificação inválida." })
|
||||||
|
.trim()
|
||||||
|
.min(1, "Chave da notificação inválida."),
|
||||||
|
fingerprint: z
|
||||||
|
.string({ message: "Fingerprint da notificação inválido." })
|
||||||
|
.trim()
|
||||||
|
.min(1, "Fingerprint da notificação inválido."),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DashboardNotificationStateInput = z.infer<typeof notificationStateSchema>;
|
||||||
|
|
||||||
|
function revalidateNotifications(userId: string) {
|
||||||
|
revalidateForEntity("notifications", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExistingNotificationState(
|
||||||
|
userId: string,
|
||||||
|
notificationKey: string,
|
||||||
|
) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({
|
||||||
|
id: dashboardNotificationStates.id,
|
||||||
|
archivedAt: dashboardNotificationStates.archivedAt,
|
||||||
|
})
|
||||||
|
.from(dashboardNotificationStates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardNotificationStates.userId, userId),
|
||||||
|
eq(dashboardNotificationStates.notificationKey, notificationKey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return existing ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markDashboardNotificationAsReadAction(
|
||||||
|
input: DashboardNotificationStateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = notificationStateSchema.parse(input);
|
||||||
|
const now = new Date();
|
||||||
|
const existing = await getExistingNotificationState(
|
||||||
|
user.id,
|
||||||
|
data.notificationKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(dashboardNotificationStates)
|
||||||
|
.set({
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
readAt: now,
|
||||||
|
archivedAt: existing.archivedAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardNotificationStates.userId, user.id),
|
||||||
|
eq(
|
||||||
|
dashboardNotificationStates.notificationKey,
|
||||||
|
data.notificationKey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await db.insert(dashboardNotificationStates).values({
|
||||||
|
userId: user.id,
|
||||||
|
notificationKey: data.notificationKey,
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
readAt: now,
|
||||||
|
archivedAt: null,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateNotifications(user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Notificação marcada como lida." };
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotificationStatesTableMissing(error)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markDashboardNotificationAsUnreadAction(
|
||||||
|
input: DashboardNotificationStateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = notificationStateSchema.parse(input);
|
||||||
|
const now = new Date();
|
||||||
|
const existing = await getExistingNotificationState(
|
||||||
|
user.id,
|
||||||
|
data.notificationKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { success: true, message: "Notificação marcada como não lida." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(dashboardNotificationStates)
|
||||||
|
.set({
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: existing.archivedAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardNotificationStates.userId, user.id),
|
||||||
|
eq(dashboardNotificationStates.notificationKey, data.notificationKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidateNotifications(user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Notificação marcada como não lida." };
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotificationStatesTableMissing(error)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveDashboardNotificationAction(
|
||||||
|
input: DashboardNotificationStateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = notificationStateSchema.parse(input);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(dashboardNotificationStates)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
notificationKey: data.notificationKey,
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
readAt: now,
|
||||||
|
archivedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
dashboardNotificationStates.userId,
|
||||||
|
dashboardNotificationStates.notificationKey,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
readAt: now,
|
||||||
|
archivedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateNotifications(user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Notificação arquivada." };
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotificationStatesTableMissing(error)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unarchiveDashboardNotificationAction(
|
||||||
|
input: DashboardNotificationStateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = notificationStateSchema.parse(input);
|
||||||
|
const now = new Date();
|
||||||
|
const existing = await getExistingNotificationState(
|
||||||
|
user.id,
|
||||||
|
data.notificationKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Notificação não encontrada para restaurar.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(dashboardNotificationStates)
|
||||||
|
.set({
|
||||||
|
fingerprint: data.fingerprint,
|
||||||
|
archivedAt: null,
|
||||||
|
readAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardNotificationStates.userId, user.id),
|
||||||
|
eq(dashboardNotificationStates.notificationKey, data.notificationKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidateNotifications(user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Notificação restaurada." };
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotificationStatesTableMissing(error)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"A migration das notificações ainda não foi aplicada. Rode pnpm run db:migrate para ativar a persistência.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, eq, lt, ne, sql } from "drizzle-orm";
|
import { and, eq, inArray, lt, ne, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
budgets,
|
budgets,
|
||||||
cards,
|
cards,
|
||||||
categories,
|
categories,
|
||||||
|
dashboardNotificationStates,
|
||||||
invoices,
|
invoices,
|
||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
|
import { buildInvoiceDetailsHref } from "@/features/dashboard/invoices-helpers";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||||
|
import { isNotificationStatesTableMissing } from "@/shared/lib/notifications/is-table-missing";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
import type {
|
||||||
|
BudgetNotification,
|
||||||
|
DashboardNotification,
|
||||||
|
DashboardNotificationsSnapshot,
|
||||||
|
} from "@/shared/lib/types/notifications";
|
||||||
import {
|
import {
|
||||||
buildDateOnlyStringFromPeriodDay,
|
buildDateOnlyStringFromPeriodDay,
|
||||||
getBusinessDateString,
|
getBusinessDateString,
|
||||||
@@ -19,41 +27,65 @@ import {
|
|||||||
toDateOnlyString,
|
toDateOnlyString,
|
||||||
} from "@/shared/utils/date";
|
} from "@/shared/utils/date";
|
||||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||||
|
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||||
|
|
||||||
export type NotificationType = "overdue" | "due_soon";
|
export type {
|
||||||
|
BudgetNotification,
|
||||||
export type DashboardNotification = {
|
BudgetStatus,
|
||||||
id: string;
|
DashboardNotification,
|
||||||
type: "invoice" | "boleto";
|
DashboardNotificationsSnapshot,
|
||||||
name: string;
|
NotificationType,
|
||||||
dueDate: string;
|
} from "@/shared/lib/types/notifications";
|
||||||
status: NotificationType;
|
|
||||||
amount: number;
|
|
||||||
period?: string;
|
|
||||||
showAmount: boolean;
|
|
||||||
cardLogo?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BudgetStatus = "exceeded" | "critical";
|
|
||||||
|
|
||||||
export type BudgetNotification = {
|
|
||||||
id: string;
|
|
||||||
categoryName: string;
|
|
||||||
budgetAmount: number;
|
|
||||||
spentAmount: number;
|
|
||||||
usedPercentage: number;
|
|
||||||
status: BudgetStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DashboardNotificationsSnapshot = {
|
|
||||||
notifications: DashboardNotification[];
|
|
||||||
totalCount: number;
|
|
||||||
budgetNotifications: BudgetNotification[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||||
const BUDGET_CRITICAL_THRESHOLD = 80;
|
const BUDGET_CRITICAL_THRESHOLD = 80;
|
||||||
|
|
||||||
|
type PersistedNotificationState = {
|
||||||
|
notificationKey: string;
|
||||||
|
fingerprint: string;
|
||||||
|
readAt: Date | null;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildInvoiceNotificationKey = (cardId: string, period: string) =>
|
||||||
|
`invoice-${cardId}-${period}`;
|
||||||
|
|
||||||
|
const buildBoletoNotificationKey = (transactionId: string) =>
|
||||||
|
`boleto-${transactionId}`;
|
||||||
|
|
||||||
|
const buildBudgetNotificationKey = (
|
||||||
|
categoryId: string | null,
|
||||||
|
budgetId: string,
|
||||||
|
period: string,
|
||||||
|
) => (categoryId ? `budget-${categoryId}-${period}` : `budget-${budgetId}`);
|
||||||
|
|
||||||
|
function mergeNotificationState<
|
||||||
|
T extends {
|
||||||
|
notificationKey: string;
|
||||||
|
fingerprint: string;
|
||||||
|
isRead: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
readAt: Date | null;
|
||||||
|
archivedAt: Date | null;
|
||||||
|
},
|
||||||
|
>(items: T[], stateByKey: Map<string, PersistedNotificationState>): T[] {
|
||||||
|
return items.map((item) => {
|
||||||
|
const persisted = stateByKey.get(item.notificationKey);
|
||||||
|
|
||||||
|
if (!persisted || persisted.fingerprint !== item.fingerprint) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
isRead: persisted.readAt !== null,
|
||||||
|
isArchived: persisted.archivedAt !== null,
|
||||||
|
readAt: persisted.readAt,
|
||||||
|
archivedAt: persisted.archivedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Busca todas as notificações do dashboard:
|
* Busca todas as notificações do dashboard:
|
||||||
* - Faturas de cartão atrasadas ou com vencimento próximo
|
* - Faturas de cartão atrasadas ou com vencimento próximo
|
||||||
@@ -188,7 +220,9 @@ export async function fetchDashboardNotifications(
|
|||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
orcamentoId: budgets.id,
|
orcamentoId: budgets.id,
|
||||||
|
categoryId: budgets.categoryId,
|
||||||
budgetAmount: budgets.amount,
|
budgetAmount: budgets.amount,
|
||||||
|
period: budgets.period,
|
||||||
categoriaName: categories.name,
|
categoriaName: categories.name,
|
||||||
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
|
spentAmount: sql<number>`COALESCE(SUM(ABS(${transactions.amount})), 0)`,
|
||||||
})
|
})
|
||||||
@@ -216,12 +250,12 @@ export async function fetchDashboardNotifications(
|
|||||||
);
|
);
|
||||||
if (!dueDate) continue;
|
if (!dueDate) continue;
|
||||||
const amount = toNumber(invoice.totalAmount);
|
const amount = toNumber(invoice.totalAmount);
|
||||||
const notificationId = invoice.invoiceId
|
const notificationKey = buildInvoiceNotificationKey(
|
||||||
? `invoice-${invoice.invoiceId}`
|
invoice.cardId,
|
||||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
invoice.period,
|
||||||
|
);
|
||||||
|
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: notificationId,
|
|
||||||
type: "invoice",
|
type: "invoice",
|
||||||
name: invoice.cardName,
|
name: invoice.cardName,
|
||||||
dueDate,
|
dueDate,
|
||||||
@@ -230,6 +264,13 @@ export async function fetchDashboardNotifications(
|
|||||||
period: invoice.period,
|
period: invoice.period,
|
||||||
showAmount: true,
|
showAmount: true,
|
||||||
cardLogo: invoice.cardLogo,
|
cardLogo: invoice.cardLogo,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: "overdue",
|
||||||
|
href: buildInvoiceDetailsHref(invoice.cardId, invoice.period),
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,20 +302,28 @@ export async function fetchDashboardNotifications(
|
|||||||
);
|
);
|
||||||
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
|
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
|
||||||
|
|
||||||
const notificationId = invoice.invoiceId
|
const notificationStatus = invoiceIsOverdue ? "overdue" : "due_soon";
|
||||||
? `invoice-${invoice.invoiceId}`
|
const notificationKey = buildInvoiceNotificationKey(
|
||||||
: `invoice-${invoice.cardId}-${invoice.period}`;
|
invoice.cardId,
|
||||||
|
invoice.period,
|
||||||
|
);
|
||||||
|
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: notificationId,
|
|
||||||
type: "invoice",
|
type: "invoice",
|
||||||
name: invoice.cardName,
|
name: invoice.cardName,
|
||||||
dueDate,
|
dueDate,
|
||||||
status: invoiceIsOverdue ? "overdue" : "due_soon",
|
status: notificationStatus,
|
||||||
amount: Math.abs(amount),
|
amount: Math.abs(amount),
|
||||||
period: invoice.period,
|
period: invoice.period,
|
||||||
showAmount: invoiceIsOverdue,
|
showAmount: invoiceIsOverdue,
|
||||||
cardLogo: invoice.cardLogo,
|
cardLogo: invoice.cardLogo,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: notificationStatus,
|
||||||
|
href: buildInvoiceDetailsHref(invoice.cardId, invoice.period),
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,10 +341,11 @@ export async function fetchDashboardNotifications(
|
|||||||
const isOldPeriod = boleto.period < currentPeriod;
|
const isOldPeriod = boleto.period < currentPeriod;
|
||||||
const isCurrentPeriod = boleto.period === currentPeriod;
|
const isCurrentPeriod = boleto.period === currentPeriod;
|
||||||
const amount = toNumber(boleto.amount);
|
const amount = toNumber(boleto.amount);
|
||||||
|
const href = `/transactions?periodo=${formatPeriodForUrl(boleto.period)}`;
|
||||||
|
const notificationKey = buildBoletoNotificationKey(boleto.id);
|
||||||
|
|
||||||
if (isOldPeriod) {
|
if (isOldPeriod) {
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: `boleto-${boleto.id}`,
|
|
||||||
type: "boleto",
|
type: "boleto",
|
||||||
name: boleto.name,
|
name: boleto.name,
|
||||||
dueDate,
|
dueDate,
|
||||||
@@ -303,17 +353,32 @@ export async function fetchDashboardNotifications(
|
|||||||
amount: Math.abs(amount),
|
amount: Math.abs(amount),
|
||||||
period: boleto.period,
|
period: boleto.period,
|
||||||
showAmount: true,
|
showAmount: true,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: "overdue",
|
||||||
|
href,
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
});
|
});
|
||||||
} else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
|
} else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
|
||||||
|
const notificationStatus = boletoIsOverdue ? "overdue" : "due_soon";
|
||||||
|
|
||||||
notifications.push({
|
notifications.push({
|
||||||
id: `boleto-${boleto.id}`,
|
|
||||||
type: "boleto",
|
type: "boleto",
|
||||||
name: boleto.name,
|
name: boleto.name,
|
||||||
dueDate,
|
dueDate,
|
||||||
status: boletoIsOverdue ? "overdue" : "due_soon",
|
status: notificationStatus,
|
||||||
amount: Math.abs(amount),
|
amount: Math.abs(amount),
|
||||||
period: boleto.period,
|
period: boleto.period,
|
||||||
showAmount: boletoIsOverdue,
|
showAmount: boletoIsOverdue,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: notificationStatus,
|
||||||
|
href,
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,14 +400,26 @@ export async function fetchDashboardNotifications(
|
|||||||
|
|
||||||
const usedPercentage = (spentAmount / budgetAmount) * 100;
|
const usedPercentage = (spentAmount / budgetAmount) * 100;
|
||||||
if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue;
|
if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue;
|
||||||
|
const notificationStatus = usedPercentage >= 100 ? "exceeded" : "critical";
|
||||||
|
const notificationKey = buildBudgetNotificationKey(
|
||||||
|
row.categoryId,
|
||||||
|
row.orcamentoId,
|
||||||
|
row.period,
|
||||||
|
);
|
||||||
|
|
||||||
budgetNotifications.push({
|
budgetNotifications.push({
|
||||||
id: `budget-${row.orcamentoId}`,
|
|
||||||
categoryName: row.categoriaName,
|
categoryName: row.categoriaName,
|
||||||
budgetAmount,
|
budgetAmount,
|
||||||
spentAmount,
|
spentAmount,
|
||||||
usedPercentage,
|
usedPercentage,
|
||||||
status: usedPercentage >= 100 ? "exceeded" : "critical",
|
status: notificationStatus,
|
||||||
|
notificationKey,
|
||||||
|
fingerprint: notificationStatus,
|
||||||
|
href: `/budgets?periodo=${formatPeriodForUrl(row.period)}`,
|
||||||
|
isRead: false,
|
||||||
|
isArchived: false,
|
||||||
|
readAt: null,
|
||||||
|
archivedAt: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,9 +430,68 @@ export async function fetchDashboardNotifications(
|
|||||||
return b.usedPercentage - a.usedPercentage;
|
return b.usedPercentage - a.usedPercentage;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const notificationKeys = [
|
||||||
notifications,
|
...notifications.map((notification) => notification.notificationKey),
|
||||||
totalCount: notifications.length,
|
...budgetNotifications.map((notification) => notification.notificationKey),
|
||||||
|
];
|
||||||
|
|
||||||
|
let persistedStates: PersistedNotificationState[] = [];
|
||||||
|
|
||||||
|
if (notificationKeys.length > 0) {
|
||||||
|
try {
|
||||||
|
persistedStates = await db
|
||||||
|
.select({
|
||||||
|
notificationKey: dashboardNotificationStates.notificationKey,
|
||||||
|
fingerprint: dashboardNotificationStates.fingerprint,
|
||||||
|
readAt: dashboardNotificationStates.readAt,
|
||||||
|
archivedAt: dashboardNotificationStates.archivedAt,
|
||||||
|
})
|
||||||
|
.from(dashboardNotificationStates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardNotificationStates.userId, userId),
|
||||||
|
inArray(
|
||||||
|
dashboardNotificationStates.notificationKey,
|
||||||
|
notificationKeys,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (isNotificationStatesTableMissing(error)) {
|
||||||
|
console.warn(
|
||||||
|
"[DashboardNotifications] Tabela dashboard_notification_states ainda não existe. Voltando ao modo sem persistência.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateByKey = new Map(
|
||||||
|
persistedStates.map((state) => [state.notificationKey, state]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mergedNotifications = mergeNotificationState(notifications, stateByKey);
|
||||||
|
const mergedBudgetNotifications = mergeNotificationState(
|
||||||
budgetNotifications,
|
budgetNotifications,
|
||||||
|
stateByKey,
|
||||||
|
);
|
||||||
|
const visibleNotifications = mergedNotifications.filter(
|
||||||
|
(notification) => !notification.isArchived,
|
||||||
|
);
|
||||||
|
const visibleBudgetNotifications = mergedBudgetNotifications.filter(
|
||||||
|
(notification) => !notification.isArchived,
|
||||||
|
);
|
||||||
|
const unreadCount = [
|
||||||
|
...visibleNotifications,
|
||||||
|
...visibleBudgetNotifications,
|
||||||
|
].filter((notification) => !notification.isRead).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: mergedNotifications,
|
||||||
|
budgetNotifications: mergedBudgetNotifications,
|
||||||
|
unreadCount,
|
||||||
|
visibleCount:
|
||||||
|
visibleNotifications.length + visibleBudgetNotifications.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const excludeAutoGeneratedEntryNotes = () =>
|
|||||||
|
|
||||||
export const excludeInitialBalanceWhenConfigured = () =>
|
export const excludeInitialBalanceWhenConfigured = () =>
|
||||||
or(
|
or(
|
||||||
|
isNull(transactions.note),
|
||||||
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
||||||
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
||||||
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { memo } from "react";
|
||||||
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 { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
@@ -20,16 +21,15 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/shared/components/ui/card";
|
} from "@/shared/components/ui/card";
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
import type { InboxItem } from "./types";
|
import type { InboxItem } from "./types";
|
||||||
|
|
||||||
// O timestamp vem do app Android em horário local mas salvo como UTC.
|
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
|
||||||
// Adicionamos o offset de Brasília para corrigir o cálculo de "há X tempo".
|
|
||||||
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
function adjustToBrasilia(date: Date): Date {
|
|
||||||
return new Date(date.getTime() + BRASILIA_OFFSET_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMatchingLogo(
|
function findMatchingLogo(
|
||||||
sourceAppName: string | null,
|
sourceAppName: string | null,
|
||||||
@@ -63,7 +63,7 @@ interface InboxCardProps {
|
|||||||
onSelectToggle?: (id: string) => void;
|
onSelectToggle?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InboxCard({
|
export const InboxCard = memo(function InboxCard({
|
||||||
item,
|
item,
|
||||||
readonly,
|
readonly,
|
||||||
appLogoMap,
|
appLogoMap,
|
||||||
@@ -78,17 +78,19 @@ export function InboxCard({
|
|||||||
const matchedLogo = appLogoMap
|
const matchedLogo = appLogoMap
|
||||||
? findMatchingLogo(item.sourceAppName, appLogoMap)
|
? findMatchingLogo(item.sourceAppName, appLogoMap)
|
||||||
: null;
|
: null;
|
||||||
|
const displayLogo = matchedLogo ?? DEFAULT_INBOX_APP_LOGO;
|
||||||
|
|
||||||
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
|
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
|
||||||
|
|
||||||
const rawDate = new Date(item.notificationTimestamp);
|
const createdAtDate = new Date(item.createdAt);
|
||||||
const notificationDate = adjustToBrasilia(rawDate);
|
|
||||||
|
|
||||||
const timeAgo = formatDistanceToNow(notificationDate, {
|
const timeAgo = formatDistanceToNow(createdAtDate, {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
locale: ptBR,
|
locale: ptBR,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fullDate = format(createdAtDate, "PPpp", { locale: ptBR });
|
||||||
|
|
||||||
const statusDate =
|
const statusDate =
|
||||||
item.status === "processed"
|
item.status === "processed"
|
||||||
? item.processedAt
|
? item.processedAt
|
||||||
@@ -107,21 +109,32 @@ export function InboxCard({
|
|||||||
<CardHeader className="pt-4">
|
<CardHeader className="pt-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<CardTitle className="flex min-w-0 items-center gap-1.5 text-sm">
|
<CardTitle className="flex min-w-0 items-center gap-1.5 text-sm">
|
||||||
{matchedLogo && (
|
{onSelectToggle && (
|
||||||
<Image
|
<Checkbox
|
||||||
src={matchedLogo}
|
checked={!!selected}
|
||||||
alt=""
|
onCheckedChange={() => onSelectToggle(item.id)}
|
||||||
width={24}
|
aria-label="Selecionar item"
|
||||||
height={24}
|
className="shrink-0"
|
||||||
className="shrink-0 rounded-full"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Image
|
||||||
|
src={displayLogo}
|
||||||
|
alt=""
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="shrink-0 rounded-full"
|
||||||
|
/>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{item.sourceAppName || item.sourceApp}
|
{item.sourceAppName || item.sourceApp}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-xs font-normal text-muted-foreground">
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="shrink-0 cursor-default text-xs font-normal text-muted-foreground underline decoration-dotted underline-offset-2">
|
||||||
{timeAgo}
|
{timeAgo}
|
||||||
</span>
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{fullDate}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{amount !== null && (
|
{amount !== null && (
|
||||||
<MoneyValues amount={amount} className="shrink-0 text-sm" />
|
<MoneyValues amount={amount} className="shrink-0 text-sm" />
|
||||||
@@ -174,13 +187,6 @@ export function InboxCard({
|
|||||||
<RiDeleteBinLine className="size-4" />
|
<RiDeleteBinLine className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onSelectToggle && (
|
|
||||||
<Checkbox
|
|
||||||
checked={!!selected}
|
|
||||||
onCheckedChange={() => onSelectToggle(item.id)}
|
|
||||||
aria-label="Selecionar item"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
) : (
|
) : (
|
||||||
@@ -213,15 +219,8 @@ export function InboxCard({
|
|||||||
>
|
>
|
||||||
<RiDeleteBinLine className="size-4" />
|
<RiDeleteBinLine className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{onSelectToggle && (
|
|
||||||
<Checkbox
|
|
||||||
checked={!!selected}
|
|
||||||
onCheckedChange={() => onSelectToggle(item.id)}
|
|
||||||
aria-label="Selecionar item"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -52,7 +52,14 @@ export function InboxDetailsDialog({
|
|||||||
<div className="grid gap-2 text-sm">
|
<div className="grid gap-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">App</span>
|
<span className="text-muted-foreground">App</span>
|
||||||
|
<div className="flex flex-col items-end gap-0.5">
|
||||||
<span>{item.sourceAppName || item.sourceApp}</span>
|
<span>{item.sourceAppName || item.sourceApp}</span>
|
||||||
|
{item.sourceAppName && (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{item.sourceApp}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,6 +116,11 @@ export function InboxDetailsDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
{isPending && onProcess && (
|
{isPending && onProcess && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -120,11 +132,6 @@ export function InboxDetailsDialog({
|
|||||||
Processar
|
Processar
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="outline">
|
|
||||||
Fechar
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -6,10 +6,20 @@ import {
|
|||||||
RiArrowRightDoubleLine,
|
RiArrowRightDoubleLine,
|
||||||
RiArrowRightSLine,
|
RiArrowRightSLine,
|
||||||
RiAtLine,
|
RiAtLine,
|
||||||
|
RiCalendarEventLine,
|
||||||
RiDeleteBinLine,
|
RiDeleteBinLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import Image from "next/image";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useTransition,
|
||||||
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
bulkDeleteInboxItemsAction,
|
bulkDeleteInboxItemsAction,
|
||||||
@@ -42,6 +52,7 @@ import {
|
|||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@/shared/components/ui/tabs";
|
} from "@/shared/components/ui/tabs";
|
||||||
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
import { InboxCard } from "./inbox-card";
|
import { InboxCard } from "./inbox-card";
|
||||||
import { InboxDetailsDialog } from "./inbox-details-dialog";
|
import { InboxDetailsDialog } from "./inbox-details-dialog";
|
||||||
import type {
|
import type {
|
||||||
@@ -52,8 +63,80 @@ import type {
|
|||||||
SelectOption,
|
SelectOption,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
|
||||||
|
|
||||||
|
// O Companion envia hora local de Brasília com 'Z' literal (não converte para UTC).
|
||||||
|
// Por isso, o timestamp armazenado no DB já tem a data correta de Brasília como componente UTC.
|
||||||
|
// Basta fatiar o ISO string sem nenhum ajuste para obter a data de Brasília do item.
|
||||||
|
function getItemDateKey(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Para "hoje" e "ontem", precisamos da data real de Brasília (UTC-3).
|
||||||
|
function getBrasiliaDateKey(date: Date): string {
|
||||||
|
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||||
|
return new Date(date.getTime() - BRASILIA_OFFSET_MS)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupLabel(dateKey: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const todayKey = getBrasiliaDateKey(now);
|
||||||
|
const yesterdayKey = getBrasiliaDateKey(
|
||||||
|
new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
);
|
||||||
|
if (dateKey === todayKey) return "Hoje";
|
||||||
|
if (dateKey === yesterdayKey) return "Ontem";
|
||||||
|
const [year, month, day] = dateKey.split("-").map(Number);
|
||||||
|
return format(new Date(year, month - 1, day), "d 'de' MMMM", {
|
||||||
|
locale: ptBR,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupItemsByDay(
|
||||||
|
items: InboxItem[],
|
||||||
|
): { label: string; items: InboxItem[] }[] {
|
||||||
|
const groups = new Map<string, InboxItem[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = getItemDateKey(new Date(item.notificationTimestamp));
|
||||||
|
const group = groups.get(key);
|
||||||
|
if (group) {
|
||||||
|
group.push(item);
|
||||||
|
} else {
|
||||||
|
groups.set(key, [item]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sortedKeys = [...groups.keys()].sort((a, b) => b.localeCompare(a));
|
||||||
|
return sortedKeys.map((key) => ({
|
||||||
|
label: getGroupLabel(key),
|
||||||
|
items: groups.get(key) ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatchingLogo(
|
||||||
|
sourceAppName: string | null,
|
||||||
|
appLogoMap: Record<string, string>,
|
||||||
|
): string | null {
|
||||||
|
if (!sourceAppName) return null;
|
||||||
|
|
||||||
|
const appName = sourceAppName.toLowerCase();
|
||||||
|
|
||||||
|
if (appLogoMap[appName]) return resolveLogoSrc(appLogoMap[appName]);
|
||||||
|
|
||||||
|
for (const [name, logo] of Object.entries(appLogoMap)) {
|
||||||
|
if (name.includes(appName) || appName.includes(name)) {
|
||||||
|
return resolveLogoSrc(logo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
interface InboxPageProps {
|
interface InboxPageProps {
|
||||||
activeStatus: InboxStatus;
|
activeStatus: InboxStatus;
|
||||||
|
activeApp: string | null;
|
||||||
|
sourceApps: string[];
|
||||||
items: InboxItem[];
|
items: InboxItem[];
|
||||||
counts: InboxStatusCounts;
|
counts: InboxStatusCounts;
|
||||||
pagination: InboxPaginationState;
|
pagination: InboxPaginationState;
|
||||||
@@ -69,6 +152,8 @@ interface InboxPageProps {
|
|||||||
|
|
||||||
export function InboxPage({
|
export function InboxPage({
|
||||||
activeStatus,
|
activeStatus,
|
||||||
|
activeApp,
|
||||||
|
sourceApps = [],
|
||||||
items,
|
items,
|
||||||
counts,
|
counts,
|
||||||
pagination,
|
pagination,
|
||||||
@@ -111,6 +196,38 @@ export function InboxPage({
|
|||||||
const [selectionBulkStatus, setSelectionBulkStatus] =
|
const [selectionBulkStatus, setSelectionBulkStatus] =
|
||||||
useState<InboxStatus>("pending");
|
useState<InboxStatus>("pending");
|
||||||
|
|
||||||
|
const normalizedSourceApps = useMemo(() => {
|
||||||
|
if (!Array.isArray(sourceApps)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueApps = new Set<string>();
|
||||||
|
for (const app of sourceApps) {
|
||||||
|
if (typeof app !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedApp = app.trim();
|
||||||
|
if (!trimmedApp) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqueApps.add(trimmedApp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...uniqueApps].sort((left, right) =>
|
||||||
|
left.localeCompare(right, "pt-BR"),
|
||||||
|
);
|
||||||
|
}, [sourceApps]);
|
||||||
|
|
||||||
|
const appFilterOptions =
|
||||||
|
activeApp && !normalizedSourceApps.includes(activeApp)
|
||||||
|
? [activeApp, ...normalizedSourceApps]
|
||||||
|
: normalizedSourceApps;
|
||||||
|
|
||||||
|
const getAppLogo = (appName: string | null) =>
|
||||||
|
findMatchingLogo(appName, appLogoMap) ?? DEFAULT_INBOX_APP_LOGO;
|
||||||
|
|
||||||
const handleProcessOpenChange = (open: boolean) => {
|
const handleProcessOpenChange = (open: boolean) => {
|
||||||
setProcessOpen(open);
|
setProcessOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -132,20 +249,20 @@ export function InboxPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProcessRequest = (item: InboxItem) => {
|
const handleProcessRequest = useCallback((item: InboxItem) => {
|
||||||
setItemToProcess(item);
|
setItemToProcess(item);
|
||||||
setProcessOpen(true);
|
setProcessOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDetailsRequest = (item: InboxItem) => {
|
const handleDetailsRequest = useCallback((item: InboxItem) => {
|
||||||
setItemDetails(item);
|
setItemDetails(item);
|
||||||
setDetailsOpen(true);
|
setDetailsOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDiscardRequest = (item: InboxItem) => {
|
const handleDiscardRequest = useCallback((item: InboxItem) => {
|
||||||
setItemToDiscard(item);
|
setItemToDiscard(item);
|
||||||
setDiscardOpen(true);
|
setDiscardOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDiscardConfirm = async () => {
|
const handleDiscardConfirm = async () => {
|
||||||
if (!itemToDiscard) return;
|
if (!itemToDiscard) return;
|
||||||
@@ -170,10 +287,10 @@ export function InboxPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRequest = (item: InboxItem) => {
|
const handleDeleteRequest = useCallback((item: InboxItem) => {
|
||||||
setItemToDelete(item);
|
setItemToDelete(item);
|
||||||
setDeleteOpen(true);
|
setDeleteOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!itemToDelete) return;
|
if (!itemToDelete) return;
|
||||||
@@ -198,10 +315,10 @@ export function InboxPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestoreRequest = (item: InboxItem) => {
|
const handleRestoreRequest = useCallback((item: InboxItem) => {
|
||||||
setItemToRestore(item);
|
setItemToRestore(item);
|
||||||
setRestoreOpen(true);
|
setRestoreOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleRestoreToPendingConfirm = async () => {
|
const handleRestoreToPendingConfirm = async () => {
|
||||||
if (!itemToRestore) return;
|
if (!itemToRestore) return;
|
||||||
@@ -224,13 +341,13 @@ export function InboxPage({
|
|||||||
setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
|
setSelectedIds((current) => current.filter((id) => visibleIds.has(id)));
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
const toggleSelection = (id: string) => {
|
const toggleSelection = useCallback((id: string) => {
|
||||||
setSelectedIds((current) =>
|
setSelectedIds((current) =>
|
||||||
current.includes(id)
|
current.includes(id)
|
||||||
? current.filter((value) => value !== id)
|
? current.filter((value) => value !== id)
|
||||||
: [...current, id],
|
: [...current, id],
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const allSelected = items.length > 0 && selectedIds.length === items.length;
|
const allSelected = items.length > 0 && selectedIds.length === items.length;
|
||||||
|
|
||||||
@@ -239,7 +356,6 @@ export function InboxPage({
|
|||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedIds(items.map((item) => item.id));
|
setSelectedIds(items.map((item) => item.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -276,8 +392,42 @@ export function InboxPage({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAppChange = (nextApp: string) => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
if (nextApp === "all") {
|
||||||
|
nextParams.delete("app");
|
||||||
|
} else {
|
||||||
|
nextParams.set("app", nextApp);
|
||||||
|
}
|
||||||
|
nextParams.delete("page");
|
||||||
|
startTransition(() => {
|
||||||
|
const target = nextParams.toString()
|
||||||
|
? `${pathname}?${nextParams.toString()}`
|
||||||
|
: pathname;
|
||||||
|
router.replace(target, { scroll: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleTabChange = (nextStatus: string) => {
|
const handleTabChange = (nextStatus: string) => {
|
||||||
updateUrl(nextStatus as InboxStatus, 1, pagination.pageSize);
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
nextParams.delete("app");
|
||||||
|
if (nextStatus === "pending") {
|
||||||
|
nextParams.delete("status");
|
||||||
|
} else {
|
||||||
|
nextParams.set("status", nextStatus);
|
||||||
|
}
|
||||||
|
nextParams.delete("page");
|
||||||
|
if (pagination.pageSize === INBOX_DEFAULT_PAGE_SIZE) {
|
||||||
|
nextParams.delete("pageSize");
|
||||||
|
} else {
|
||||||
|
nextParams.set("pageSize", pagination.pageSize.toString());
|
||||||
|
}
|
||||||
|
startTransition(() => {
|
||||||
|
const target = nextParams.toString()
|
||||||
|
? `${pathname}?${nextParams.toString()}`
|
||||||
|
: pathname;
|
||||||
|
router.replace(target, { scroll: false });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectionBulkRequest = (status: InboxStatus) => {
|
const handleSelectionBulkRequest = (status: InboxStatus) => {
|
||||||
@@ -401,16 +551,30 @@ export function InboxPage({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderGrid = (list: InboxItem[], readonly?: boolean) =>
|
const renderGroupedGrid = (list: InboxItem[], readonly?: boolean) => {
|
||||||
list.length === 0 ? (
|
if (list.length === 0) {
|
||||||
renderEmptyState(
|
if (activeApp) {
|
||||||
|
return renderEmptyState("Nenhuma notificação deste app");
|
||||||
|
}
|
||||||
|
return renderEmptyState(
|
||||||
readonly
|
readonly
|
||||||
? "Nenhuma notificação nesta aba"
|
? "Nenhuma notificação nesta aba"
|
||||||
: "Nenhum pré-lançamento pendente",
|
: "Nenhum pré-lançamento pendente",
|
||||||
)
|
);
|
||||||
) : (
|
}
|
||||||
|
|
||||||
|
const groups = groupItemsByDay(list);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
<div className="mb-3 flex items-center gap-1 text-muted-foreground">
|
||||||
|
<RiCalendarEventLine className="size-3.5 shrink-0" />
|
||||||
|
<p className="text-sm font-medium">{group.label}</p>
|
||||||
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{list.map((item) => (
|
{group.items.map((item) => (
|
||||||
<InboxCard
|
<InboxCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -420,13 +584,72 @@ export function InboxPage({
|
|||||||
onDiscard={readonly ? undefined : handleDiscardRequest}
|
onDiscard={readonly ? undefined : handleDiscardRequest}
|
||||||
onViewDetails={readonly ? undefined : handleDetailsRequest}
|
onViewDetails={readonly ? undefined : handleDetailsRequest}
|
||||||
onDelete={readonly ? handleDeleteRequest : undefined}
|
onDelete={readonly ? handleDeleteRequest : undefined}
|
||||||
onRestoreToPending={readonly ? handleRestoreRequest : undefined}
|
onRestoreToPending={
|
||||||
|
readonly ? handleRestoreRequest : undefined
|
||||||
|
}
|
||||||
selected={selectedIds.includes(item.id)}
|
selected={selectedIds.includes(item.id)}
|
||||||
onSelectToggle={toggleSelection}
|
onSelectToggle={toggleSelection}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAppFilter = () => {
|
||||||
|
if (appFilterOptions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={activeApp ?? "all"} onValueChange={handleAppChange}>
|
||||||
|
<SelectTrigger className="w-[190px]">
|
||||||
|
<SelectValue>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<Image
|
||||||
|
src={activeApp ? getAppLogo(activeApp) : DEFAULT_INBOX_APP_LOGO}
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="shrink-0 rounded-full"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{activeApp ?? "Todos"}</span>
|
||||||
|
</span>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Image
|
||||||
|
src={DEFAULT_INBOX_APP_LOGO}
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="shrink-0 rounded-full"
|
||||||
|
/>
|
||||||
|
<span>Todos</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{appFilterOptions.map((app) => (
|
||||||
|
<SelectItem key={app} value={app}>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<Image
|
||||||
|
src={getAppLogo(app)}
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="shrink-0 rounded-full"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{app}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -463,9 +686,17 @@ export function InboxPage({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="pending" className="mt-4">
|
<TabsContent value="pending" className="mt-4">
|
||||||
{activeStatus === "pending" && items.length > 0 && (
|
{activeStatus === "pending" &&
|
||||||
<div className="mb-4 flex items-center justify-end gap-2">
|
(appFilterOptions.length > 0 || items.length > 0) && (
|
||||||
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
{renderAppFilter()}
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleSelectAll}
|
||||||
|
>
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
||||||
</Button>
|
</Button>
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
@@ -479,13 +710,23 @@ export function InboxPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeStatus === "pending" ? renderGrid(items, false) : null}
|
{activeStatus === "pending" ? renderGroupedGrid(items, false) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="processed" className="mt-4">
|
<TabsContent value="processed" className="mt-4">
|
||||||
{activeStatus === "processed" && items.length > 0 && (
|
{activeStatus === "processed" &&
|
||||||
<div className="mb-4 flex items-center justify-end gap-2">
|
(appFilterOptions.length > 0 || items.length > 0) && (
|
||||||
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
{renderAppFilter()}
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleSelectAll}
|
||||||
|
>
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
||||||
</Button>
|
</Button>
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
@@ -507,13 +748,23 @@ export function InboxPage({
|
|||||||
Limpar processados
|
Limpar processados
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeStatus === "processed" ? renderGrid(items, true) : null}
|
{activeStatus === "processed" ? renderGroupedGrid(items, true) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="discarded" className="mt-4">
|
<TabsContent value="discarded" className="mt-4">
|
||||||
{activeStatus === "discarded" && items.length > 0 && (
|
{activeStatus === "discarded" &&
|
||||||
<div className="mb-4 flex items-center justify-end gap-2">
|
(appFilterOptions.length > 0 || items.length > 0) && (
|
||||||
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
{renderAppFilter()}
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleSelectAll}
|
||||||
|
>
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
||||||
</Button>
|
</Button>
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
@@ -535,8 +786,10 @@ export function InboxPage({
|
|||||||
Limpar descartados
|
Limpar descartados
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeStatus === "discarded" ? renderGrid(items, true) : null}
|
{activeStatus === "discarded" ? renderGroupedGrid(items, true) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export const resolveInboxStatus = (
|
|||||||
: "pending";
|
: "pending";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveInboxApp = (
|
||||||
|
params: ResolvedInboxSearchParams,
|
||||||
|
): string | null => getSingleParam(params, "app");
|
||||||
|
|
||||||
export const resolveInboxPagination = (
|
export const resolveInboxPagination = (
|
||||||
params: ResolvedInboxSearchParams,
|
params: ResolvedInboxSearchParams,
|
||||||
): Pick<InboxPaginationState, "page" | "pageSize"> => {
|
): Pick<InboxPaginationState, "page" | "pageSize"> => {
|
||||||
|
|||||||
@@ -39,18 +39,26 @@ export async function fetchInboxItemsPage(
|
|||||||
{
|
{
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
sourceApp,
|
||||||
}: {
|
}: {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
sourceApp?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
items: InboxItem[];
|
items: InboxItem[];
|
||||||
pagination: InboxPaginationState;
|
pagination: InboxPaginationState;
|
||||||
}> {
|
}> {
|
||||||
|
const where = and(
|
||||||
|
eq(inboxItems.userId, userId),
|
||||||
|
eq(inboxItems.status, status),
|
||||||
|
sourceApp ? eq(inboxItems.sourceAppName, sourceApp) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const [countRow] = await db
|
const [countRow] = await db
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(inboxItems)
|
.from(inboxItems)
|
||||||
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)));
|
.where(where);
|
||||||
|
|
||||||
const totalItems = Number(countRow?.total ?? 0);
|
const totalItems = Number(countRow?.total ?? 0);
|
||||||
const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1);
|
const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1);
|
||||||
@@ -60,7 +68,7 @@ export async function fetchInboxItemsPage(
|
|||||||
const items = await db
|
const items = await db
|
||||||
.select()
|
.select()
|
||||||
.from(inboxItems)
|
.from(inboxItems)
|
||||||
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)))
|
.where(where)
|
||||||
.orderBy(desc(inboxItems.notificationTimestamp), desc(inboxItems.createdAt))
|
.orderBy(desc(inboxItems.notificationTimestamp), desc(inboxItems.createdAt))
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
@@ -76,6 +84,21 @@ export async function fetchInboxItemsPage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchInboxSourceApps(
|
||||||
|
userId: string,
|
||||||
|
status: InboxStatus,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rows = await db
|
||||||
|
.selectDistinct({ name: inboxItems.sourceAppName })
|
||||||
|
.from(inboxItems)
|
||||||
|
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)));
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((row) => row.name)
|
||||||
|
.filter((name): name is string => name !== null)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchInboxStatusCounts(
|
export async function fetchInboxStatusCounts(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<InboxStatusCounts> {
|
): Promise<InboxStatusCounts> {
|
||||||
|
|||||||
@@ -24,24 +24,18 @@ const navLinks = [
|
|||||||
interface MobileNavProps {
|
interface MobileNavProps {
|
||||||
isPublicDomain: boolean;
|
isPublicDomain: boolean;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
triggerClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileNav({
|
export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
|
||||||
isPublicDomain,
|
|
||||||
isLoggedIn,
|
|
||||||
triggerClassName,
|
|
||||||
}: MobileNavProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="navbar"
|
||||||
size="icon"
|
size="icon-sm"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
aria-label="Abrir menu"
|
aria-label="Abrir menu"
|
||||||
className={triggerClassName}
|
|
||||||
>
|
>
|
||||||
<RiMenuLine className="size-5" />
|
<RiMenuLine className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
RiDatabase2Line,
|
RiDatabase2Line,
|
||||||
RiDeviceLine,
|
RiDeviceLine,
|
||||||
RiDownloadCloudLine,
|
RiDownloadCloudLine,
|
||||||
|
RiErrorWarningLine,
|
||||||
RiEyeOffLine,
|
RiEyeOffLine,
|
||||||
RiFileTextLine,
|
RiFileTextLine,
|
||||||
RiFlashlightLine,
|
RiFlashlightLine,
|
||||||
@@ -34,9 +35,6 @@ export type FeatureItem = {
|
|||||||
colorVar: string;
|
colorVar: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const navbarActionClassName =
|
|
||||||
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
|
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{ href: "#telas", label: "conheça as telas" },
|
{ href: "#telas", label: "conheça as telas" },
|
||||||
{ href: "#funcionalidades", label: "funcionalidades" },
|
{ href: "#funcionalidades", label: "funcionalidades" },
|
||||||
@@ -260,6 +258,20 @@ export const whoIsItForItems: FeatureItem[] = [
|
|||||||
"Você vai precisar configurar as coisas, conectar suas contas e ajustar o sistema para o seu jeito de usar.",
|
"Você vai precisar configurar as coisas, conectar suas contas e ajustar o sistema para o seu jeito de usar.",
|
||||||
colorVar: "var(--data-4)",
|
colorVar: "var(--data-4)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: RiShieldCheckLine,
|
||||||
|
title: "Não é para qualquer um",
|
||||||
|
description:
|
||||||
|
"Não é uma empresa, não é um SaaS, não é uma plataforma. É um projeto pessoal.",
|
||||||
|
colorVar: "var(--data-1)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: RiErrorWarningLine,
|
||||||
|
title: "Não sou responsável por nada",
|
||||||
|
description:
|
||||||
|
"Não sou responsável por nada que aconteça com você ou com seus dados.",
|
||||||
|
colorVar: "var(--data-9)",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getMetricsItems(stars: number, forks: number) {
|
export function getMetricsItems(stars: number, forks: number) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const baseSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.email("Informe um e-mail válido.")
|
.email("Informe um e-mail válido.")
|
||||||
.optional()
|
.nullish()
|
||||||
.transform((value) => normalizeOptionalString(value)),
|
.transform((value) => normalizeOptionalString(value)),
|
||||||
status: statusEnum,
|
status: statusEnum,
|
||||||
note: noteSchema,
|
note: noteSchema,
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export async function fetchTopEstablishmentsData(
|
|||||||
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
|
not(ilike(transactions.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
|
||||||
),
|
),
|
||||||
or(
|
or(
|
||||||
|
isNull(transactions.note),
|
||||||
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
||||||
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
||||||
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const resetAccountSchema = z.object({
|
|||||||
const updatePreferencesSchema = z.object({
|
const updatePreferencesSchema = z.object({
|
||||||
statementNoteAsColumn: z.boolean(),
|
statementNoteAsColumn: z.boolean(),
|
||||||
transactionsColumnOrder: z.array(z.string()).nullable(),
|
transactionsColumnOrder: z.array(z.string()).nullable(),
|
||||||
|
attachmentMaxSizeMb: z.number().int().min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ResettableUser = {
|
type ResettableUser = {
|
||||||
@@ -561,6 +562,7 @@ export async function updatePreferencesAction(
|
|||||||
.set({
|
.set({
|
||||||
statementNoteAsColumn: validated.statementNoteAsColumn,
|
statementNoteAsColumn: validated.statementNoteAsColumn,
|
||||||
transactionsColumnOrder: validated.transactionsColumnOrder,
|
transactionsColumnOrder: validated.transactionsColumnOrder,
|
||||||
|
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(schema.userPreferences.userId, session.user.id));
|
.where(eq(schema.userPreferences.userId, session.user.id));
|
||||||
@@ -570,6 +572,7 @@ export async function updatePreferencesAction(
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
statementNoteAsColumn: validated.statementNoteAsColumn,
|
statementNoteAsColumn: validated.statementNoteAsColumn,
|
||||||
transactionsColumnOrder: validated.transactionsColumnOrder,
|
transactionsColumnOrder: validated.transactionsColumnOrder,
|
||||||
|
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RiAndroidLine,
|
|
||||||
RiDownload2Line,
|
RiDownload2Line,
|
||||||
RiExternalLinkLine,
|
RiExternalLinkLine,
|
||||||
RiNotification3Line,
|
RiNotification3Line,
|
||||||
@@ -9,7 +8,6 @@ import {
|
|||||||
RiShieldCheckLine,
|
RiShieldCheckLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Card } from "@/shared/components/ui/card";
|
|
||||||
import { ApiTokensForm } from "./api-tokens-form";
|
import { ApiTokensForm } from "./api-tokens-form";
|
||||||
|
|
||||||
interface ApiToken {
|
interface ApiToken {
|
||||||
@@ -69,24 +67,7 @@ const steps: {
|
|||||||
|
|
||||||
export function CompanionTab({ tokens }: CompanionTabProps) {
|
export function CompanionTab({ tokens }: CompanionTabProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-6">
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h2 className="text-lg font-bold">OpenMonetis Companion</h2>
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
|
|
||||||
<RiAndroidLine className="h-3 w-3" />
|
|
||||||
Android
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Capture notificações de transações dos seus apps de banco (Nubank,
|
|
||||||
Itaú, Bradesco, Inter, C6 e outros) e envie para sua caixa de
|
|
||||||
entrada.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Steps */}
|
{/* Steps */}
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
@@ -106,12 +87,8 @@ export function CompanionTab({ tokens }: CompanionTabProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-t" />
|
|
||||||
|
|
||||||
{/* Devices */}
|
{/* Devices */}
|
||||||
<ApiTokensForm tokens={tokens} />
|
<ApiTokensForm tokens={tokens} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ export function PasskeysForm() {
|
|||||||
const { data, error: fetchError } =
|
const { data, error: fetchError } =
|
||||||
await authClient.passkey.listUserPasskeys();
|
await authClient.passkey.listUserPasskeys();
|
||||||
if (fetchError) {
|
if (fetchError) {
|
||||||
setError(fetchError.message || "Erro ao carregar passkeys.");
|
setError(
|
||||||
|
(fetchError.message as string) || "Erro ao carregar passkeys.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPasskeys(
|
setPasskeys(
|
||||||
@@ -111,7 +113,7 @@ export function PasskeysForm() {
|
|||||||
name: addName.trim() || undefined,
|
name: addName.trim() || undefined,
|
||||||
});
|
});
|
||||||
if (addError) {
|
if (addError) {
|
||||||
setError(addError.message || "Erro ao registrar passkey.");
|
setError((addError.message as string) || "Erro ao registrar passkey.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAddName("");
|
setAddName("");
|
||||||
@@ -134,7 +136,9 @@ export function PasskeysForm() {
|
|||||||
name: editName.trim(),
|
name: editName.trim(),
|
||||||
});
|
});
|
||||||
if (renameError) {
|
if (renameError) {
|
||||||
setError(renameError.message || "Erro ao renomear passkey.");
|
setError(
|
||||||
|
(renameError.message as string) || "Erro ao renomear passkey.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@@ -156,7 +160,7 @@ export function PasskeysForm() {
|
|||||||
id: deleteId,
|
id: deleteId,
|
||||||
});
|
});
|
||||||
if (deleteError) {
|
if (deleteError) {
|
||||||
setError(deleteError.message || "Erro ao remover passkey.");
|
setError((deleteError.message as string) || "Erro ao remover passkey.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDeleteId(null);
|
setDeleteId(null);
|
||||||
|
|||||||
@@ -21,17 +21,27 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updatePreferencesAction } from "@/features/settings/actions";
|
import { updatePreferencesAction } from "@/features/settings/actions";
|
||||||
|
import {
|
||||||
|
ATTACHMENT_SIZE_OPTIONS,
|
||||||
|
type AttachmentSizeOption,
|
||||||
|
} from "@/features/transactions/attachments-config";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANCAMENTOS_COLUMN_ORDER,
|
DEFAULT_LANCAMENTOS_COLUMN_ORDER,
|
||||||
LANCAMENTOS_COLUMN_LABELS,
|
LANCAMENTOS_COLUMN_LABELS,
|
||||||
} from "@/features/transactions/column-order";
|
} from "@/features/transactions/column-order";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import { Switch } from "@/shared/components/ui/switch";
|
import { Switch } from "@/shared/components/ui/switch";
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@/shared/components/ui/toggle-group";
|
||||||
|
|
||||||
interface PreferencesFormProps {
|
interface PreferencesFormProps {
|
||||||
statementNoteAsColumn: boolean;
|
statementNoteAsColumn: boolean;
|
||||||
transactionsColumnOrder: string[] | null;
|
transactionsColumnOrder: string[] | null;
|
||||||
|
attachmentMaxSizeMb: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableColumnItem({ id }: { id: string }) {
|
function SortableColumnItem({ id }: { id: string }) {
|
||||||
@@ -74,6 +84,7 @@ function SortableColumnItem({ id }: { id: string }) {
|
|||||||
export function PreferencesForm({
|
export function PreferencesForm({
|
||||||
statementNoteAsColumn: initialExtratoNoteAsColumn,
|
statementNoteAsColumn: initialExtratoNoteAsColumn,
|
||||||
transactionsColumnOrder: initialColumnOrder,
|
transactionsColumnOrder: initialColumnOrder,
|
||||||
|
attachmentMaxSizeMb: initialAttachmentMaxSizeMb,
|
||||||
}: PreferencesFormProps) {
|
}: PreferencesFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@@ -85,6 +96,14 @@ export function PreferencesForm({
|
|||||||
? initialColumnOrder
|
? initialColumnOrder
|
||||||
: DEFAULT_LANCAMENTOS_COLUMN_ORDER,
|
: DEFAULT_LANCAMENTOS_COLUMN_ORDER,
|
||||||
);
|
);
|
||||||
|
const [attachmentMaxSizeMb, setAttachmentMaxSizeMb] =
|
||||||
|
useState<AttachmentSizeOption>(
|
||||||
|
(ATTACHMENT_SIZE_OPTIONS.includes(
|
||||||
|
initialAttachmentMaxSizeMb as AttachmentSizeOption,
|
||||||
|
)
|
||||||
|
? initialAttachmentMaxSizeMb
|
||||||
|
: 50) as AttachmentSizeOption,
|
||||||
|
);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
@@ -109,6 +128,7 @@ export function PreferencesForm({
|
|||||||
const result = await updatePreferencesAction({
|
const result = await updatePreferencesAction({
|
||||||
statementNoteAsColumn,
|
statementNoteAsColumn,
|
||||||
transactionsColumnOrder: columnOrder,
|
transactionsColumnOrder: columnOrder,
|
||||||
|
attachmentMaxSizeMb,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -122,19 +142,18 @@ export function PreferencesForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
|
||||||
{/* Seção: Extrato / Lançamentos */}
|
{/* Seção: Lançamentos */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold">Extrato e lançamentos</h3>
|
<h3 className="text-base font-semibold">Lançamentos</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Como exibir anotações e a ordem das colunas na tabela de
|
Configurações de exibição da tabela de movimentações.
|
||||||
movimentações.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md">
|
<section className="flex items-center justify-between max-w-md">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="extrato-note-column" className="text-base">
|
<Label htmlFor="extrato-note-column" className="text-sm">
|
||||||
Anotações em coluna
|
Anotações em coluna
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -149,10 +168,12 @@ export function PreferencesForm({
|
|||||||
onCheckedChange={setExtratoNoteAsColumn}
|
onCheckedChange={setExtratoNoteAsColumn}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="space-y-2 max-w-md">
|
<Separator />
|
||||||
<Label className="text-base">Ordem das colunas</Label>
|
|
||||||
|
<section className="space-y-2 max-w-md">
|
||||||
|
<Label className="text-sm">Ordem das colunas</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Arraste os itens para definir a ordem em que as colunas aparecem na
|
Arraste os itens para definir a ordem em que as colunas aparecem na
|
||||||
tabela do extrato e dos lançamentos.
|
tabela do extrato e dos lançamentos.
|
||||||
@@ -173,8 +194,44 @@ export function PreferencesForm({
|
|||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<Label className="text-sm">Anexos</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configurações de upload de arquivos nos lançamentos.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-w-md mt-4">
|
||||||
|
<Label>Tamanho máximo por arquivo</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Limite aplicado ao upload de PDFs e imagens.
|
||||||
|
</p>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={String(attachmentMaxSizeMb)}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (val)
|
||||||
|
setAttachmentMaxSizeMb(Number(val) as AttachmentSizeOption);
|
||||||
|
}}
|
||||||
|
className="flex flex-wrap gap-2 justify-start"
|
||||||
|
>
|
||||||
|
{ATTACHMENT_SIZE_OPTIONS.map((size) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={size}
|
||||||
|
value={String(size)}
|
||||||
|
aria-label={`${size} MB`}
|
||||||
|
className="min-w-14"
|
||||||
|
>
|
||||||
|
{size} MB
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={isPending} className="w-fit">
|
<Button type="submit" disabled={isPending} className="w-fit">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { db, schema } from "@/shared/lib/db";
|
|||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
statementNoteAsColumn: boolean;
|
statementNoteAsColumn: boolean;
|
||||||
transactionsColumnOrder: string[] | null;
|
transactionsColumnOrder: string[] | null;
|
||||||
|
attachmentMaxSizeMb: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiToken {
|
export interface ApiToken {
|
||||||
@@ -32,6 +33,7 @@ export async function fetchUserPreferences(
|
|||||||
.select({
|
.select({
|
||||||
statementNoteAsColumn: schema.userPreferences.statementNoteAsColumn,
|
statementNoteAsColumn: schema.userPreferences.statementNoteAsColumn,
|
||||||
transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder,
|
transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder,
|
||||||
|
attachmentMaxSizeMb: schema.userPreferences.attachmentMaxSizeMb,
|
||||||
})
|
})
|
||||||
.from(schema.userPreferences)
|
.from(schema.userPreferences)
|
||||||
.where(eq(schema.userPreferences.userId, userId))
|
.where(eq(schema.userPreferences.userId, userId))
|
||||||
|
|||||||
554
src/features/transactions/actions/attachments.ts
Normal file
554
src/features/transactions/actions/attachments.ts
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import crypto, { randomUUID } from "node:crypto";
|
||||||
|
import { and, count, eq, inArray } from "drizzle-orm";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
ALLOWED_MIME_TYPES,
|
||||||
|
MAX_FILE_SIZE,
|
||||||
|
} from "@/features/transactions/attachments-config";
|
||||||
|
import {
|
||||||
|
handleActionError,
|
||||||
|
revalidateForEntity,
|
||||||
|
} from "@/shared/lib/actions/helpers";
|
||||||
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import {
|
||||||
|
createPresignedGetUrl,
|
||||||
|
createPresignedPutUrl,
|
||||||
|
deleteS3Object,
|
||||||
|
headS3Object,
|
||||||
|
} from "@/shared/lib/storage/presign";
|
||||||
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
|
|
||||||
|
const UPLOAD_TOKEN_EXPIRY_SECONDS = 10 * 60;
|
||||||
|
|
||||||
|
const presignSchema = z.object({
|
||||||
|
fileName: z.string().min(1),
|
||||||
|
mimeType: z.enum(ALLOWED_MIME_TYPES),
|
||||||
|
fileSize: z.number().max(MAX_FILE_SIZE, "Arquivo deve ter no máximo 50MB."),
|
||||||
|
transactionId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmSchema = z.object({
|
||||||
|
uploadToken: z.string().min(1),
|
||||||
|
scope: z.enum(["current", "period", "future", "all"]).default("current"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const detachSchema = z.object({
|
||||||
|
attachmentId: z.string().uuid(),
|
||||||
|
transactionId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type PresignResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
presignedUrl: string;
|
||||||
|
fileKey: string;
|
||||||
|
uploadToken: string;
|
||||||
|
}
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
type UploadTokenPayload = {
|
||||||
|
userId: string;
|
||||||
|
transactionId: string;
|
||||||
|
fileKey: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: (typeof ALLOWED_MIME_TYPES)[number];
|
||||||
|
fileSize: number;
|
||||||
|
exp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getUploadTokenSecret(): string {
|
||||||
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error(
|
||||||
|
"BETTER_AUTH_SECRET is required. Set it in your .env file.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(value: string): string {
|
||||||
|
return Buffer.from(value)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlDecode(value: string): string {
|
||||||
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const pad = normalized.length % 4;
|
||||||
|
const padded = pad ? normalized + "=".repeat(4 - pad) : normalized;
|
||||||
|
return Buffer.from(padded, "base64").toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function signUploadToken(payload: UploadTokenPayload): string {
|
||||||
|
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac("sha256", getUploadTokenSecret())
|
||||||
|
.update(encodedPayload)
|
||||||
|
.digest("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "");
|
||||||
|
|
||||||
|
return `${encodedPayload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyUploadToken(token: string): UploadTokenPayload | null {
|
||||||
|
try {
|
||||||
|
const [encodedPayload, signature] = token.split(".");
|
||||||
|
if (!encodedPayload || !signature) return null;
|
||||||
|
|
||||||
|
const expectedSignature = crypto
|
||||||
|
.createHmac("sha256", getUploadTokenSecret())
|
||||||
|
.update(encodedPayload)
|
||||||
|
.digest("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "");
|
||||||
|
|
||||||
|
if (
|
||||||
|
!crypto.timingSafeEqual(
|
||||||
|
Buffer.from(signature),
|
||||||
|
Buffer.from(expectedSignature),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.parse(
|
||||||
|
base64UrlDecode(encodedPayload),
|
||||||
|
) as UploadTokenPayload;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
if (payload.exp < now) return null;
|
||||||
|
if (!payload.fileKey.startsWith(`${payload.userId}/`)) return null;
|
||||||
|
if (!ALLOWED_MIME_TYPES.includes(payload.mimeType)) return null;
|
||||||
|
if (payload.fileSize <= 0 || payload.fileSize > MAX_FILE_SIZE) return null;
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPresignedUploadUrlAction(input: {
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileSize: number;
|
||||||
|
transactionId: string;
|
||||||
|
}): Promise<PresignResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = presignSchema.parse(input);
|
||||||
|
|
||||||
|
const [transaction] = await db
|
||||||
|
.select({ id: transactions.id })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.id, data.transactionId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = data.fileName.split(".").pop()?.toLowerCase() ?? "bin";
|
||||||
|
const fileKey = `${user.id}/${randomUUID()}.${ext}`;
|
||||||
|
const presignedUrl = await createPresignedPutUrl(fileKey, data.mimeType);
|
||||||
|
const uploadToken = signUploadToken({
|
||||||
|
userId: user.id,
|
||||||
|
transactionId: data.transactionId,
|
||||||
|
fileKey,
|
||||||
|
fileName: data.fileName,
|
||||||
|
mimeType: data.mimeType,
|
||||||
|
fileSize: data.fileSize,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + UPLOAD_TOKEN_EXPIRY_SECONDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, presignedUrl, fileKey, uploadToken };
|
||||||
|
} catch (error) {
|
||||||
|
const result = handleActionError(error);
|
||||||
|
if (!result.success) return { success: false, error: result.error };
|
||||||
|
return { success: false, error: "Erro inesperado." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmAttachmentUploadAction(input: {
|
||||||
|
uploadToken: string;
|
||||||
|
scope?: "current" | "period" | "future" | "all";
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = confirmSchema.parse(input);
|
||||||
|
const uploadPayload = verifyUploadToken(data.uploadToken);
|
||||||
|
|
||||||
|
if (!uploadPayload || uploadPayload.userId !== user.id) {
|
||||||
|
return { success: false, error: "Upload de anexo inválido ou expirado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [transaction] = await db
|
||||||
|
.select({
|
||||||
|
id: transactions.id,
|
||||||
|
seriesId: transactions.seriesId,
|
||||||
|
period: transactions.period,
|
||||||
|
})
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.id, uploadPayload.transactionId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectMetadata = await headS3Object(uploadPayload.fileKey);
|
||||||
|
|
||||||
|
if (!objectMetadata.contentLength || objectMetadata.contentLength <= 0) {
|
||||||
|
return { success: false, error: "Arquivo enviado não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectMetadata.contentLength > MAX_FILE_SIZE) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "O arquivo enviado excede o limite permitido de 50MB.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectMetadata.contentLength !== uploadPayload.fileSize) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"O tamanho do arquivo enviado não confere com o upload autorizado.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectMetadata.contentType !== uploadPayload.mimeType) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "O tipo do arquivo enviado não confere com o upload autorizado.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [attachment] = await db
|
||||||
|
.insert(attachments)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
fileKey: uploadPayload.fileKey,
|
||||||
|
fileName: uploadPayload.fileName,
|
||||||
|
fileSize: uploadPayload.fileSize,
|
||||||
|
mimeType: uploadPayload.mimeType,
|
||||||
|
})
|
||||||
|
.returning({ id: attachments.id });
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
return { success: false, error: "Erro ao salvar o anexo." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let transactionIds: string[] = [uploadPayload.transactionId];
|
||||||
|
|
||||||
|
if (data.scope !== "current" && transaction.seriesId) {
|
||||||
|
const seriesRows = await db
|
||||||
|
.select({ id: transactions.id, period: transactions.period })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.seriesId, transaction.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.scope === "period") {
|
||||||
|
transactionIds = seriesRows
|
||||||
|
.filter((r) => r.period === transaction.period)
|
||||||
|
.map((r) => r.id);
|
||||||
|
} else if (data.scope === "future") {
|
||||||
|
transactionIds = seriesRows
|
||||||
|
.filter((r) => (r.period ?? "") >= (transaction.period ?? ""))
|
||||||
|
.map((r) => r.id);
|
||||||
|
} else {
|
||||||
|
transactionIds = seriesRows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(transactionAttachments).values(
|
||||||
|
transactionIds.map((tid) => ({
|
||||||
|
transactionId: tid,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidateForEntity("transactions", user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Anexo salvo com sucesso." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detachTransactionAttachmentAction(input: {
|
||||||
|
attachmentId: string;
|
||||||
|
transactionId: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = detachSchema.parse(input);
|
||||||
|
|
||||||
|
const [transaction] = await db
|
||||||
|
.select({ id: transactions.id })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.id, data.transactionId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [attachment] = await db
|
||||||
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
||||||
|
.from(attachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(attachments.id, data.attachmentId),
|
||||||
|
eq(attachments.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
return { success: false, error: "Anexo não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(transactionAttachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactionAttachments.transactionId, data.transactionId),
|
||||||
|
eq(transactionAttachments.attachmentId, data.attachmentId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [remaining] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.where(eq(transactionAttachments.attachmentId, data.attachmentId));
|
||||||
|
|
||||||
|
if (!remaining || remaining.total === 0) {
|
||||||
|
await deleteS3Object(attachment.fileKey);
|
||||||
|
await db.delete(attachments).where(eq(attachments.id, data.attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateForEntity("transactions", user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Anexo removido com sucesso." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
attachmentId: z.string().uuid(),
|
||||||
|
transactionId: z.string().uuid(),
|
||||||
|
scope: z.enum(["current", "period", "future", "all"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function detachAttachmentBulkAction(input: {
|
||||||
|
attachmentId: string;
|
||||||
|
transactionId: string;
|
||||||
|
scope: "current" | "period" | "future" | "all";
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = detachBulkSchema.parse(input);
|
||||||
|
|
||||||
|
const [baseTransaction] = await db
|
||||||
|
.select({
|
||||||
|
id: transactions.id,
|
||||||
|
seriesId: transactions.seriesId,
|
||||||
|
period: transactions.period,
|
||||||
|
})
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.id, data.transactionId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!baseTransaction) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [attachment] = await db
|
||||||
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
||||||
|
.from(attachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(attachments.id, data.attachmentId),
|
||||||
|
eq(attachments.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
return { success: false, error: "Anexo não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetTransactionIds: string[];
|
||||||
|
|
||||||
|
if (data.scope === "current" || !baseTransaction.seriesId) {
|
||||||
|
targetTransactionIds = [data.transactionId];
|
||||||
|
} else {
|
||||||
|
const seriesRows = await db
|
||||||
|
.select({ id: transactions.id, period: transactions.period })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.seriesId, baseTransaction.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.scope === "period") {
|
||||||
|
targetTransactionIds = seriesRows
|
||||||
|
.filter((r) => r.period === baseTransaction.period)
|
||||||
|
.map((r) => r.id);
|
||||||
|
} else if (data.scope === "future") {
|
||||||
|
targetTransactionIds = seriesRows
|
||||||
|
.filter((r) => (r.period ?? "") >= (baseTransaction.period ?? ""))
|
||||||
|
.map((r) => r.id);
|
||||||
|
} else {
|
||||||
|
targetTransactionIds = seriesRows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetTransactionIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(transactionAttachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(transactionAttachments.transactionId, targetTransactionIds),
|
||||||
|
eq(transactionAttachments.attachmentId, data.attachmentId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [remaining] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.where(eq(transactionAttachments.attachmentId, data.attachmentId));
|
||||||
|
|
||||||
|
if (!remaining || remaining.total === 0) {
|
||||||
|
await deleteS3Object(attachment.fileKey);
|
||||||
|
await db.delete(attachments).where(eq(attachments.id, data.attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateForEntity("transactions", user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Anexo removido com sucesso." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */
|
||||||
|
export async function cleanupAttachmentsAfterTransactionDelete(
|
||||||
|
attachmentData: Array<{ id: string; fileKey: string }>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (attachmentData.length === 0) return;
|
||||||
|
|
||||||
|
const uniqueIds = [...new Set(attachmentData.map((a) => a.id))];
|
||||||
|
|
||||||
|
const remaining = await db
|
||||||
|
.select({
|
||||||
|
attachmentId: transactionAttachments.attachmentId,
|
||||||
|
total: count(),
|
||||||
|
})
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.where(inArray(transactionAttachments.attachmentId, uniqueIds))
|
||||||
|
.groupBy(transactionAttachments.attachmentId);
|
||||||
|
|
||||||
|
const remainingMap = new Map(remaining.map((r) => [r.attachmentId, r.total]));
|
||||||
|
|
||||||
|
for (const att of attachmentData) {
|
||||||
|
if (!remainingMap.has(att.id) || (remainingMap.get(att.id) ?? 0) === 0) {
|
||||||
|
await deleteS3Object(att.fileKey);
|
||||||
|
await db.delete(attachments).where(eq(attachments.id, att.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||||
import { transactions } from "@/db/schema";
|
import { transactions } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
@@ -80,6 +80,24 @@ export async function deleteTransactionBulkAction(
|
|||||||
return { success: true, message: "Lançamento removido com sucesso." };
|
return { success: true, message: "Lançamento removido com sucesso." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.scope === "period") {
|
||||||
|
await db
|
||||||
|
.delete(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
eq(transactions.period, existing.period ?? ""),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Todos os lançamentos do período foram removidos.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (data.scope === "future") {
|
if (data.scope === "future") {
|
||||||
await db
|
await db
|
||||||
.delete(transactions)
|
.delete(transactions)
|
||||||
@@ -147,6 +165,7 @@ export async function updateTransactionBulkAction(
|
|||||||
condition: true,
|
condition: true,
|
||||||
transactionType: true,
|
transactionType: true,
|
||||||
purchaseDate: true,
|
purchaseDate: true,
|
||||||
|
payerId: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
@@ -169,9 +188,11 @@ export async function updateTransactionBulkAction(
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
categoryId: data.categoryId ?? null,
|
categoryId: data.categoryId ?? null,
|
||||||
note: data.note ?? null,
|
note: data.note ?? null,
|
||||||
payerId: data.payerId ?? null,
|
// "period" atualiza todos os pagadores do mês — preserva o payerId de cada linha
|
||||||
|
...(data.scope !== "period" && { payerId: data.payerId ?? null }),
|
||||||
accountId: data.accountId ?? null,
|
accountId: data.accountId ?? null,
|
||||||
cardId: data.cardId ?? null,
|
cardId: data.cardId ?? null,
|
||||||
|
...(data.isSettled !== undefined && { isSettled: data.isSettled }),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.amount !== undefined) {
|
if (data.amount !== undefined) {
|
||||||
@@ -308,6 +329,42 @@ export async function updateTransactionBulkAction(
|
|||||||
return { success: true, message: "Lançamento atualizado com sucesso." };
|
return { success: true, message: "Lançamento atualizado com sucesso." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.scope === "period") {
|
||||||
|
if (!existing.period) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Período do lançamento não encontrado.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodLancamentos = await db.query.transactions.findMany({
|
||||||
|
columns: { id: true, purchaseDate: true },
|
||||||
|
where: and(
|
||||||
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
eq(transactions.period, existing.period),
|
||||||
|
),
|
||||||
|
orderBy: asc(transactions.purchaseDate),
|
||||||
|
});
|
||||||
|
|
||||||
|
await applyUpdates(
|
||||||
|
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
|
||||||
|
id: item.id,
|
||||||
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Todos os lançamentos do período foram atualizados.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payerIdFilter = existing.payerId
|
||||||
|
? eq(transactions.payerId, existing.payerId)
|
||||||
|
: isNull(transactions.payerId);
|
||||||
|
|
||||||
if (data.scope === "future") {
|
if (data.scope === "future") {
|
||||||
const futureLancamentos = await db.query.transactions.findMany({
|
const futureLancamentos = await db.query.transactions.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
@@ -318,6 +375,7 @@ export async function updateTransactionBulkAction(
|
|||||||
eq(transactions.seriesId, existing.seriesId),
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
eq(transactions.userId, user.id),
|
eq(transactions.userId, user.id),
|
||||||
sql`${transactions.period} >= ${existing.period}`,
|
sql`${transactions.period} >= ${existing.period}`,
|
||||||
|
payerIdFilter,
|
||||||
),
|
),
|
||||||
orderBy: asc(transactions.purchaseDate),
|
orderBy: asc(transactions.purchaseDate),
|
||||||
});
|
});
|
||||||
@@ -345,6 +403,7 @@ export async function updateTransactionBulkAction(
|
|||||||
where: and(
|
where: and(
|
||||||
eq(transactions.seriesId, existing.seriesId),
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
eq(transactions.userId, user.id),
|
eq(transactions.userId, user.id),
|
||||||
|
payerIdFilter,
|
||||||
),
|
),
|
||||||
orderBy: asc(transactions.purchaseDate),
|
orderBy: asc(transactions.purchaseDate),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { normalizeDescriptionKey } from "@/features/transactions/lib/import-util
|
|||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
|
||||||
|
|
||||||
// Retorna um map de descriptionKey → categoryId para as descrições fornecidas
|
// Retorna um map de descriptionKey → categoryId para as descrições fornecidas
|
||||||
export async function fetchCategoryMappings(
|
export async function fetchCategoryMappings(
|
||||||
descriptions: string[],
|
descriptions: string[],
|
||||||
@@ -53,7 +52,10 @@ export async function saveCategoryMappings(
|
|||||||
.insert(importCategoryMappings)
|
.insert(importCategoryMappings)
|
||||||
.values(toUpsert)
|
.values(toUpsert)
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [importCategoryMappings.userId, importCategoryMappings.descriptionKey],
|
target: [
|
||||||
|
importCategoryMappings.userId,
|
||||||
|
importCategoryMappings.descriptionKey,
|
||||||
|
],
|
||||||
set: {
|
set: {
|
||||||
categoryId: sql`excluded.category_id`,
|
categoryId: sql`excluded.category_id`,
|
||||||
updatedAt: sql`excluded.updated_at`,
|
updatedAt: sql`excluded.updated_at`,
|
||||||
|
|||||||
@@ -664,7 +664,7 @@ export const buildLancamentoRecords = ({
|
|||||||
|
|
||||||
export const deleteBulkSchema = z.object({
|
export const deleteBulkSchema = z.object({
|
||||||
id: uuidSchema("Lançamento"),
|
id: uuidSchema("Lançamento"),
|
||||||
scope: z.enum(["current", "future", "all"], {
|
scope: z.enum(["current", "period", "future", "all"], {
|
||||||
message: "Escopo de ação inválido.",
|
message: "Escopo de ação inválido.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -673,7 +673,7 @@ export type DeleteBulkInput = z.infer<typeof deleteBulkSchema>;
|
|||||||
|
|
||||||
export const updateBulkSchema = z.object({
|
export const updateBulkSchema = z.object({
|
||||||
id: uuidSchema("Lançamento"),
|
id: uuidSchema("Lançamento"),
|
||||||
scope: z.enum(["current", "future", "all"], {
|
scope: z.enum(["current", "period", "future", "all"], {
|
||||||
message: "Escopo de ação inválido.",
|
message: "Escopo de ação inválido.",
|
||||||
}),
|
}),
|
||||||
name: z
|
name: z
|
||||||
@@ -705,6 +705,7 @@ export const updateBulkSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.nullable(),
|
||||||
|
isSettled: z.boolean().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateBulkInput = z.infer<typeof updateBulkSchema>;
|
export type UpdateBulkInput = z.infer<typeof updateBulkSchema>;
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ const importSchema = z.object({
|
|||||||
accountId: uuidSchema("FinancialAccount").nullable().optional(),
|
accountId: uuidSchema("FinancialAccount").nullable().optional(),
|
||||||
cardId: uuidSchema("Cartão").nullable().optional(),
|
cardId: uuidSchema("Cartão").nullable().optional(),
|
||||||
paymentMethod: z.string().min(1),
|
paymentMethod: z.string().min(1),
|
||||||
invoicePeriod: z.string().regex(/^\d{4}-\d{2}$/, "Período inválido.").nullable().optional(),
|
invoicePeriod: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}$/, "Período inválido.")
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ImportRow = z.infer<typeof importRowSchema>;
|
export type ImportRow = z.infer<typeof importRowSchema>;
|
||||||
@@ -51,10 +55,7 @@ export async function checkDuplicateFitIds(
|
|||||||
.select({ ofxFitId: transactions.ofxFitId })
|
.select({ ofxFitId: transactions.ofxFitId })
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(transactions.userId, userId), inArray(transactions.ofxFitId, ids)),
|
||||||
eq(transactions.userId, userId),
|
|
||||||
inArray(transactions.ofxFitId, ids),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null);
|
return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null);
|
||||||
@@ -67,10 +68,14 @@ export async function importTransactionsAction(
|
|||||||
const parsed = importSchema.safeParse(input);
|
const parsed = importSchema.safeParse(input);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { success: false, error: parsed.error.issues[0]?.message ?? "Dados inválidos." };
|
return {
|
||||||
|
success: false,
|
||||||
|
error: parsed.error.issues[0]?.message ?? "Dados inválidos.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } = parsed.data;
|
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
|
||||||
|
parsed.data;
|
||||||
|
|
||||||
// Valida ownership
|
// Valida ownership
|
||||||
const [payerOk, accountOk, cardOk] = await Promise.all([
|
const [payerOk, accountOk, cardOk] = await Promise.all([
|
||||||
@@ -94,14 +99,19 @@ export async function importTransactionsAction(
|
|||||||
|
|
||||||
const records = rows.map((row) => {
|
const records = rows.map((row) => {
|
||||||
const purchaseDate = parseLocalDateString(row.date);
|
const purchaseDate = parseLocalDateString(row.date);
|
||||||
const period = invoicePeriod ?? `${purchaseDate.getFullYear()}-${String(purchaseDate.getMonth() + 1).padStart(2, "0")}`;
|
const period =
|
||||||
|
invoicePeriod ??
|
||||||
|
`${purchaseDate.getFullYear()}-${String(purchaseDate.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: row.description,
|
name: row.description,
|
||||||
transactionType: row.transactionType === "income" ? "Receita" : "Despesa",
|
transactionType: row.transactionType === "income" ? "Receita" : "Despesa",
|
||||||
condition: "À vista" as const,
|
condition: "À vista" as const,
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
amount: (row.transactionType === "expense" ? -row.amount : row.amount).toFixed(2),
|
amount: (row.transactionType === "expense"
|
||||||
|
? -row.amount
|
||||||
|
: row.amount
|
||||||
|
).toFixed(2),
|
||||||
purchaseDate,
|
purchaseDate,
|
||||||
period,
|
period,
|
||||||
isSettled,
|
isSettled,
|
||||||
@@ -143,10 +153,7 @@ export async function deleteTransactionByFitId(
|
|||||||
await db
|
await db
|
||||||
.delete(transactions)
|
.delete(transactions)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(transactions.userId, userId), eq(transactions.ofxFitId, fitId)),
|
||||||
eq(transactions.userId, userId),
|
|
||||||
eq(transactions.ofxFitId, fitId),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await revalidateForEntity("transactions", userId);
|
await revalidateForEntity("transactions", userId);
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { financialAccounts, transactions } from "@/db/schema";
|
import {
|
||||||
|
attachments,
|
||||||
|
financialAccounts,
|
||||||
|
invoices,
|
||||||
|
transactionAttachments,
|
||||||
|
transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
import { handleActionError } from "@/shared/lib/actions/helpers";
|
import { handleActionError } from "@/shared/lib/actions/helpers";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||||
import {
|
import {
|
||||||
buildEntriesByPayer,
|
buildEntriesByPayer,
|
||||||
sendPayerAutoEmails,
|
sendPayerAutoEmails,
|
||||||
@@ -16,6 +23,8 @@ import {
|
|||||||
getBusinessTodayDate,
|
getBusinessTodayDate,
|
||||||
parseLocalDateString,
|
parseLocalDateString,
|
||||||
} from "@/shared/utils/date";
|
} from "@/shared/utils/date";
|
||||||
|
import { MONTH_NAMES } from "@/shared/utils/period";
|
||||||
|
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
||||||
import {
|
import {
|
||||||
buildLancamentoRecords,
|
buildLancamentoRecords,
|
||||||
buildShares,
|
buildShares,
|
||||||
@@ -37,7 +46,7 @@ import {
|
|||||||
|
|
||||||
export async function createTransactionAction(
|
export async function createTransactionAction(
|
||||||
input: CreateInput,
|
input: CreateInput,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult<{ ids: string[] }>> {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const data = createSchema.parse(input);
|
const data = createSchema.parse(input);
|
||||||
@@ -102,7 +111,42 @@ export async function createTransactionAction(
|
|||||||
throw new Error("Não foi possível criar os lançamentos solicitados.");
|
throw new Error("Não foi possível criar os lançamentos solicitados.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(transactions).values(records);
|
if (data.cardId) {
|
||||||
|
const uniquePeriods = [
|
||||||
|
...new Set(
|
||||||
|
records.map((r) => r.period).filter((p): p is string => Boolean(p)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const paidInvoices = await db.query.invoices.findMany({
|
||||||
|
columns: { period: true },
|
||||||
|
where: and(
|
||||||
|
eq(invoices.userId, user.id),
|
||||||
|
eq(invoices.cardId, data.cardId),
|
||||||
|
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
|
||||||
|
inArray(invoices.period, uniquePeriods),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paidInvoices.length > 0) {
|
||||||
|
const labels = paidInvoices
|
||||||
|
.map((inv) => {
|
||||||
|
const [year, month] = (inv.period ?? "").split("-");
|
||||||
|
const monthName = MONTH_NAMES[Number(month) - 1] ?? month;
|
||||||
|
return `${monthName}/${year}`;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `As faturas dos meses ${labels} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`,
|
||||||
|
} as ActionResult<{ ids: string[] }>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await db
|
||||||
|
.insert(transactions)
|
||||||
|
.values(records)
|
||||||
|
.returning({ id: transactions.id });
|
||||||
|
|
||||||
const notificationEntries = buildEntriesByPayer(
|
const notificationEntries = buildEntriesByPayer(
|
||||||
records.map((record) => ({
|
records.map((record) => ({
|
||||||
@@ -128,9 +172,13 @@ export async function createTransactionAction(
|
|||||||
|
|
||||||
revalidate(user.id);
|
revalidate(user.id);
|
||||||
|
|
||||||
return { success: true, message: "Lançamento criado com sucesso." };
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Lançamento criado com sucesso.",
|
||||||
|
data: { ids: inserted.map((r) => r.id) },
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleActionError(error);
|
return handleActionError(error) as ActionResult<{ ids: string[] }>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,12 +377,23 @@ export async function deleteTransactionAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linkedAttachments = await db
|
||||||
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.innerJoin(
|
||||||
|
attachments,
|
||||||
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
|
)
|
||||||
|
.where(eq(transactionAttachments.transactionId, data.id));
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(transactions)
|
.delete(transactions)
|
||||||
.where(
|
.where(
|
||||||
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await cleanupAttachmentsAfterTransactionDelete(linkedAttachments);
|
||||||
|
|
||||||
if (existing.payerId) {
|
if (existing.payerId) {
|
||||||
const notificationEntries = buildEntriesByPayer([
|
const notificationEntries = buildEntriesByPayer([
|
||||||
{
|
{
|
||||||
|
|||||||
13
src/features/transactions/attachments-config.ts
Normal file
13
src/features/transactions/attachments-config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const ALLOWED_MIME_TYPES = [
|
||||||
|
"application/pdf",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_MAX_FILE_SIZE_MB = 50;
|
||||||
|
|
||||||
|
export const MAX_FILE_SIZE = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; // 50MB (fallback)
|
||||||
|
|
||||||
|
export const ATTACHMENT_SIZE_OPTIONS = [5, 10, 25, 50, 100] as const;
|
||||||
|
export type AttachmentSizeOption = (typeof ATTACHMENT_SIZE_OPTIONS)[number];
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RiAttachment2, RiCloseLine } from "@remixicon/react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
ALLOWED_MIME_TYPES,
|
||||||
|
DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
|
} from "@/features/transactions/attachments-config";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
|
interface AttachmentFilePickerProps {
|
||||||
|
file: File | null;
|
||||||
|
onChange: (file: File | null) => void;
|
||||||
|
maxSizeMb?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentFilePicker({
|
||||||
|
file,
|
||||||
|
onChange,
|
||||||
|
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
|
}: AttachmentFilePickerProps) {
|
||||||
|
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const selected = e.target.files?.[0];
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!ALLOWED_MIME_TYPES.includes(
|
||||||
|
selected.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.size > maxFileSizeBytes) {
|
||||||
|
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs">Anexo</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept={ALLOWED_MIME_TYPES.join(",")}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{file ? (
|
||||||
|
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
|
||||||
|
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={file.name}>
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-6 shrink-0"
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
>
|
||||||
|
<RiCloseLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<RiAttachment2 className="size-4" />
|
||||||
|
Adicionar anexo
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px]">
|
||||||
|
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiDeleteBinLine,
|
||||||
|
RiDownloadLine,
|
||||||
|
RiExternalLinkLine,
|
||||||
|
RiFileImageLine,
|
||||||
|
RiFileLine,
|
||||||
|
RiFilePdfLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { detachTransactionAttachmentAction } from "@/features/transactions/actions/attachments";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentIcon({ mimeType }: { mimeType: string }) {
|
||||||
|
if (mimeType === "application/pdf")
|
||||||
|
return <RiFilePdfLine className="size-4 text-red-500 shrink-0" />;
|
||||||
|
if (mimeType.startsWith("image/"))
|
||||||
|
return <RiFileImageLine className="size-4 text-blue-500 shrink-0" />;
|
||||||
|
return <RiFileLine className="size-4 text-muted-foreground shrink-0" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentPreview({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
fileName,
|
||||||
|
mimeType,
|
||||||
|
url,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
url: string;
|
||||||
|
}) {
|
||||||
|
const isPdf = mimeType === "application/pdf";
|
||||||
|
const isImage = mimeType.startsWith("image/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="flex h-[92vh] w-[min(96vw,1400px)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:p-0"
|
||||||
|
>
|
||||||
|
<DialogHeader className="flex-row items-center justify-between gap-3 border-b px-4 py-3 sm:px-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<DialogTitle
|
||||||
|
className="truncate text-sm font-medium"
|
||||||
|
title={fileName}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button type="button" variant="ghost" size="icon" asChild>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
download={fileName}
|
||||||
|
>
|
||||||
|
<RiDownloadLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" asChild>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
<RiExternalLinkLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="ghost" size="sm">
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 min-w-0 flex-1">
|
||||||
|
{isPdf && (
|
||||||
|
<iframe
|
||||||
|
src={url}
|
||||||
|
className="h-full w-full border-0 bg-background"
|
||||||
|
title={fileName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isImage && (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-black/85 p-4 sm:p-6">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={fileName}
|
||||||
|
className="max-h-full max-w-full rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentItemProps {
|
||||||
|
attachmentId: string;
|
||||||
|
transactionId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
mimeType: string;
|
||||||
|
url: string;
|
||||||
|
onDeleted: () => void;
|
||||||
|
readonly?: boolean;
|
||||||
|
isPendingDelete?: boolean;
|
||||||
|
onPendingDelete?: (attachmentId: string) => void;
|
||||||
|
onUndoPendingDelete?: (attachmentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentItem({
|
||||||
|
attachmentId,
|
||||||
|
transactionId,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
mimeType,
|
||||||
|
url,
|
||||||
|
onDeleted,
|
||||||
|
readonly = false,
|
||||||
|
isPendingDelete = false,
|
||||||
|
onPendingDelete,
|
||||||
|
onUndoPendingDelete,
|
||||||
|
}: AttachmentItemProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
|
||||||
|
const canPreview =
|
||||||
|
mimeType === "application/pdf" || mimeType.startsWith("image/");
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (onPendingDelete) {
|
||||||
|
onPendingDelete(attachmentId);
|
||||||
|
setConfirmOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await detachTransactionAttachmentAction({
|
||||||
|
attachmentId,
|
||||||
|
transactionId,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message);
|
||||||
|
onDeleted();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setConfirmOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm transition-opacity ${isPendingDelete ? "opacity-50 border-dashed" : ""}`}
|
||||||
|
>
|
||||||
|
<AttachmentIcon mimeType={mimeType} />
|
||||||
|
{isPendingDelete ? (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate font-medium line-through">{fileName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Será removido ao salvar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : canPreview ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 text-left"
|
||||||
|
onClick={() => setPreviewOpen(true)}
|
||||||
|
title={fileName}
|
||||||
|
>
|
||||||
|
<p className="truncate font-medium hover:underline">{fileName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(fileSize)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate font-medium">{fileName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(fileSize)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isPendingDelete && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 shrink-0"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a href={url} target="_blank" rel="noreferrer" download={fileName}>
|
||||||
|
<RiDownloadLine className="size-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!readonly &&
|
||||||
|
(isPendingDelete ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 text-xs h-7 px-2"
|
||||||
|
onClick={() => onUndoPendingDelete?.(attachmentId)}
|
||||||
|
>
|
||||||
|
Desfazer
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canPreview && (
|
||||||
|
<AttachmentPreview
|
||||||
|
open={previewOpen}
|
||||||
|
onOpenChange={setPreviewOpen}
|
||||||
|
fileName={fileName}
|
||||||
|
mimeType={mimeType}
|
||||||
|
url={url}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remover anexo</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Tem certeza que deseja remover{" "}
|
||||||
|
<span className="break-all font-medium text-foreground">
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline" disabled={isPending}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? "Removendo..." : "Remover"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RiFileAddLine } from "@remixicon/react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { AttachmentItem } from "./attachment-item";
|
||||||
|
import { AttachmentUpload } from "./attachment-upload";
|
||||||
|
|
||||||
|
type AttachmentRow = {
|
||||||
|
attachmentId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
mimeType: string;
|
||||||
|
createdAt: Date;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AttachmentSectionProps {
|
||||||
|
transactionId: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
onLoaded?: (count: number) => void;
|
||||||
|
pendingDetachIds?: string[];
|
||||||
|
onPendingDetach?: (attachmentId: string) => void;
|
||||||
|
onUndoPendingDetach?: (attachmentId: string) => void;
|
||||||
|
pendingUploadFiles?: File[];
|
||||||
|
onPendingUpload?: (file: File) => void;
|
||||||
|
onCancelPendingUpload?: (file: File) => void;
|
||||||
|
maxSizeMb?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentSection({
|
||||||
|
transactionId,
|
||||||
|
readonly = false,
|
||||||
|
onLoaded,
|
||||||
|
pendingDetachIds,
|
||||||
|
onPendingDetach,
|
||||||
|
onUndoPendingDetach,
|
||||||
|
pendingUploadFiles,
|
||||||
|
onPendingUpload,
|
||||||
|
onCancelPendingUpload,
|
||||||
|
maxSizeMb,
|
||||||
|
}: AttachmentSectionProps) {
|
||||||
|
const [items, setItems] = useState<AttachmentRow[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchTransactionAttachmentsAction(transactionId);
|
||||||
|
setItems(data);
|
||||||
|
onLoaded?.(data.length);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [transactionId, onLoaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="text-xs text-muted-foreground">Carregando...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPendingUploads = (pendingUploadFiles?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-2 overflow-hidden">
|
||||||
|
{items.length === 0 && !hasPendingUploads && readonly && (
|
||||||
|
<p className="text-xs text-muted-foreground">Nenhum anexo.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(items.length > 0 || hasPendingUploads) && (
|
||||||
|
<div className="min-w-0 space-y-1.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<AttachmentItem
|
||||||
|
key={item.attachmentId}
|
||||||
|
attachmentId={item.attachmentId}
|
||||||
|
transactionId={transactionId}
|
||||||
|
fileName={item.fileName}
|
||||||
|
fileSize={item.fileSize}
|
||||||
|
mimeType={item.mimeType}
|
||||||
|
url={item.url}
|
||||||
|
onDeleted={load}
|
||||||
|
readonly={readonly}
|
||||||
|
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
|
||||||
|
onPendingDelete={onPendingDetach}
|
||||||
|
onUndoPendingDelete={onUndoPendingDetach}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pendingUploadFiles?.map((file) => (
|
||||||
|
<div
|
||||||
|
key={`${file.name}-${file.size}`}
|
||||||
|
className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border border-dashed px-3 py-2 text-sm opacity-60"
|
||||||
|
>
|
||||||
|
<RiFileAddLine className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate font-medium">{file.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Será adicionado ao salvar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 text-xs h-7 px-2"
|
||||||
|
onClick={() => onCancelPendingUpload?.(file)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readonly && (
|
||||||
|
<AttachmentUpload
|
||||||
|
transactionId={transactionId}
|
||||||
|
onUploaded={load}
|
||||||
|
onPendingUpload={onPendingUpload}
|
||||||
|
maxSizeMb={maxSizeMb}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RiAttachment2 } from "@remixicon/react";
|
||||||
|
import { useRef, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
confirmAttachmentUploadAction,
|
||||||
|
getPresignedUploadUrlAction,
|
||||||
|
} from "@/features/transactions/actions/attachments";
|
||||||
|
import {
|
||||||
|
ALLOWED_MIME_TYPES,
|
||||||
|
DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
|
} from "@/features/transactions/attachments-config";
|
||||||
|
|
||||||
|
interface AttachmentUploadProps {
|
||||||
|
transactionId: string;
|
||||||
|
onUploaded: () => void;
|
||||||
|
onPendingUpload?: (file: File) => void;
|
||||||
|
maxSizeMb?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentUpload({
|
||||||
|
transactionId,
|
||||||
|
onUploaded,
|
||||||
|
onPendingUpload,
|
||||||
|
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
|
}: AttachmentUploadProps) {
|
||||||
|
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!inputRef.current) return;
|
||||||
|
inputRef.current.value = "";
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!ALLOWED_MIME_TYPES.includes(
|
||||||
|
file.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxFileSizeBytes) {
|
||||||
|
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onPendingUpload) {
|
||||||
|
onPendingUpload(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const presignResult = await getPresignedUploadUrlAction({
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
fileSize: file.size,
|
||||||
|
transactionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!presignResult.success) {
|
||||||
|
toast.error(presignResult.error ?? "Erro ao iniciar upload.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResponse = await fetch(presignResult.presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
toast.error("Erro ao enviar o arquivo. Tente novamente.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmResult = await confirmAttachmentUploadAction({
|
||||||
|
uploadToken: presignResult.uploadToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmResult.success) {
|
||||||
|
toast.success(confirmResult.message);
|
||||||
|
onUploaded();
|
||||||
|
} else {
|
||||||
|
toast.error(confirmResult.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept={ALLOWED_MIME_TYPES.join(",")}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<RiAttachment2 className="size-4" />
|
||||||
|
{isPending ? "Enviando..." : "Adicionar anexo"}
|
||||||
|
</span>
|
||||||
|
{!isPending && (
|
||||||
|
<span className="text-xs">
|
||||||
|
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { RiErrorWarningLine } from "@remixicon/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
|
||||||
|
|
||||||
export type BulkActionScope = "current" | "future" | "all";
|
export type BulkActionScope = "current" | "period" | "future" | "all";
|
||||||
|
|
||||||
type BulkActionDialogProps = {
|
type BulkActionDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -108,6 +109,30 @@ export function BulkActionDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<RadioGroupItem value="period" id="period" className="mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="period"
|
||||||
|
className="text-sm cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
Todos os pagadores deste período
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Aplica a todos os lançamentos deste mesmo mês na série
|
||||||
|
</p>
|
||||||
|
{scope === "period" && actionType === "edit" && (
|
||||||
|
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
|
||||||
|
<RiErrorWarningLine className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<p className="text-xs">
|
||||||
|
Atenção: os valores individuais de cada pagador serão
|
||||||
|
substituídos pelos valores deste lançamento.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<RadioGroupItem value="future" id="future" className="mt-0.5" />
|
<RadioGroupItem value="future" id="future" className="mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
import { Separator } from "@/shared/components/ui/separator";
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import { Spinner } from "@/shared/components/ui/spinner";
|
import { Spinner } from "@/shared/components/ui/spinner";
|
||||||
import { getTodayDateString } from "@/shared/utils/date";
|
import { getTodayDateString } from "@/shared/utils/date";
|
||||||
|
import { createClientSafeId } from "@/shared/utils/id";
|
||||||
import {
|
import {
|
||||||
dateToPeriod,
|
dateToPeriod,
|
||||||
displayPeriod,
|
displayPeriod,
|
||||||
@@ -120,6 +121,19 @@ interface TransactionRow {
|
|||||||
payerId: string | undefined;
|
payerId: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createEmptyTransactionRow(
|
||||||
|
defaultPayerId?: string | null,
|
||||||
|
): TransactionRow {
|
||||||
|
return {
|
||||||
|
id: createClientSafeId(),
|
||||||
|
purchaseDate: getTodayDateString(),
|
||||||
|
name: "",
|
||||||
|
amount: "",
|
||||||
|
categoryId: undefined,
|
||||||
|
payerId: defaultPayerId ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function MassAddDialog({
|
export function MassAddDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -153,15 +167,8 @@ export function MassAddDialog({
|
|||||||
const isCartaoSelected = paymentMethod === "Cartão de crédito";
|
const isCartaoSelected = paymentMethod === "Cartão de crédito";
|
||||||
|
|
||||||
// Transaction rows
|
// Transaction rows
|
||||||
const [transactions, setTransactions] = useState<TransactionRow[]>([
|
const [transactions, setTransactions] = useState<TransactionRow[]>(() => [
|
||||||
{
|
createEmptyTransactionRow(defaultPayerId),
|
||||||
id: crypto.randomUUID(),
|
|
||||||
purchaseDate: getTodayDateString(),
|
|
||||||
name: "",
|
|
||||||
amount: "",
|
|
||||||
categoryId: undefined,
|
|
||||||
payerId: defaultPayerId ?? undefined,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Categorias agrupadas e filtradas por tipo de transação
|
// Categorias agrupadas e filtradas por tipo de transação
|
||||||
@@ -175,14 +182,7 @@ export function MassAddDialog({
|
|||||||
const addTransaction = () => {
|
const addTransaction = () => {
|
||||||
setTransactions([
|
setTransactions([
|
||||||
...transactions,
|
...transactions,
|
||||||
{
|
createEmptyTransactionRow(defaultPayerId),
|
||||||
id: crypto.randomUUID(),
|
|
||||||
purchaseDate: getTodayDateString(),
|
|
||||||
name: "",
|
|
||||||
amount: "",
|
|
||||||
categoryId: undefined,
|
|
||||||
payerId: defaultPayerId ?? undefined,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,16 +256,7 @@ export function MassAddDialog({
|
|||||||
setPeriod(selectedPeriod);
|
setPeriod(selectedPeriod);
|
||||||
setContaId(undefined);
|
setContaId(undefined);
|
||||||
setCartaoId(defaultCardId ?? undefined);
|
setCartaoId(defaultCardId ?? undefined);
|
||||||
setTransactions([
|
setTransactions([createEmptyTransactionRow(defaultPayerId)]);
|
||||||
{
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
purchaseDate: getTodayDateString(),
|
|
||||||
name: "",
|
|
||||||
amount: "",
|
|
||||||
categoryId: undefined,
|
|
||||||
payerId: defaultPayerId ?? undefined,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Error is handled by the onSubmit function
|
// Error is handled by the onSubmit function
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
currencyFormatter,
|
currencyFormatter,
|
||||||
formatCondition,
|
formatCondition,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
import { Separator } from "@/shared/components/ui/separator";
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import { parseLocalDateString } from "@/shared/utils/date";
|
import { parseLocalDateString } from "@/shared/utils/date";
|
||||||
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||||
|
import { AttachmentSection } from "../attachments/attachment-section";
|
||||||
import { InstallmentTimeline } from "../shared/installment-timeline";
|
import { InstallmentTimeline } from "../shared/installment-timeline";
|
||||||
import type { TransactionItem } from "../types";
|
import type { TransactionItem } from "../types";
|
||||||
|
|
||||||
@@ -37,6 +39,12 @@ export function TransactionDetailsDialog({
|
|||||||
transaction,
|
transaction,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: TransactionDetailsDialogProps) {
|
}: TransactionDetailsDialogProps) {
|
||||||
|
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAttachmentCount(null);
|
||||||
|
}, [transaction?.id]);
|
||||||
|
|
||||||
if (!transaction) return null;
|
if (!transaction) return null;
|
||||||
|
|
||||||
const isInstallment =
|
const isInstallment =
|
||||||
@@ -63,7 +71,7 @@ export function TransactionDetailsDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-xl">
|
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{transaction.name}</DialogTitle>
|
<DialogTitle>{transaction.name}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -71,9 +79,46 @@ export function TransactionDetailsDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto text-sm">
|
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
||||||
<div className="grid gap-3">
|
<div className="min-w-0 space-y-4">
|
||||||
<ul className="grid gap-3">
|
<section className="rounded-lg border bg-muted/20 p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Resumo
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold">
|
||||||
|
{currencyFormatter.format(valorTotal)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={
|
||||||
|
transaction.isSettled
|
||||||
|
? "text-success bg-success/10"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{transaction.isSettled ? "Pago" : "Pendente"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<TransactionTypeBadge
|
||||||
|
kind={
|
||||||
|
transaction.categoriaName === "Saldo inicial"
|
||||||
|
? "Saldo inicial"
|
||||||
|
: transaction.transactionType
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{formatCondition(transaction.condition)}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Detalhes
|
||||||
|
</h3>
|
||||||
|
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Período"
|
label="Período"
|
||||||
value={formatPeriod(transaction.period)}
|
value={formatPeriod(transaction.period)}
|
||||||
@@ -99,41 +144,11 @@ export function TransactionDetailsDialog({
|
|||||||
value={transaction.categoriaName ?? "—"}
|
value={transaction.categoriaName ?? "—"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<li className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">Tipo de Transação</span>
|
|
||||||
<TransactionTypeBadge
|
|
||||||
kind={
|
|
||||||
transaction.categoriaName === "Saldo inicial"
|
|
||||||
? "Saldo inicial"
|
|
||||||
: transaction.transactionType
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<DetailRow
|
|
||||||
label="Condição"
|
|
||||||
value={formatCondition(transaction.condition)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<li className="flex items-center justify-between">
|
<li className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground">Responsável</span>
|
<span className="text-muted-foreground">Responsável</span>
|
||||||
<span>{transaction.pagadorName}</span>
|
<span>{transaction.pagadorName}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">Status</span>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={
|
|
||||||
transaction.isSettled
|
|
||||||
? "text-success bg-success/10"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{transaction.isSettled ? "Pago" : "Pendente"}
|
|
||||||
</Badge>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
{isBoleto && transaction.dueDate && (
|
{isBoleto && transaction.dueDate && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Vencimento"
|
label="Vencimento"
|
||||||
@@ -147,18 +162,16 @@ export function TransactionDetailsDialog({
|
|||||||
<Badge variant="outline">Dividido</Badge>
|
<Badge variant="outline">Dividido</Badge>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{transaction.note && (
|
|
||||||
<li className="flex flex-col gap-1">
|
|
||||||
<span className="text-muted-foreground">Notas</span>
|
|
||||||
<span className="text-foreground">{transaction.note}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<ul className="mb-2 grid gap-3">
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Valores
|
||||||
|
</h3>
|
||||||
|
<ul className="min-w-0 grid gap-2 rounded-lg border p-3">
|
||||||
{isInstallment && (
|
{isInstallment && (
|
||||||
<li className="mt-4">
|
<li className="mb-1">
|
||||||
<InstallmentTimeline
|
<InstallmentTimeline
|
||||||
purchaseDate={parseLocalDateString(
|
purchaseDate={parseLocalDateString(
|
||||||
transaction.purchaseDate,
|
transaction.purchaseDate,
|
||||||
@@ -188,19 +201,39 @@ export function TransactionDetailsDialog({
|
|||||||
value={`${transaction.recurrenceCount} meses`}
|
value={`${transaction.recurrenceCount} meses`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isInstallment && <Separator className="my-2" />}
|
|
||||||
|
|
||||||
<li className="flex items-center justify-between font-semibold">
|
|
||||||
<span className="text-muted-foreground">Total da Compra</span>
|
|
||||||
<span className="text-lg">
|
|
||||||
{currencyFormatter.format(valorTotal)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{transaction.note ? (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Notas
|
||||||
|
</h3>
|
||||||
|
<div className="rounded-lg border p-3 text-foreground">
|
||||||
|
{transaction.note}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{attachmentCount !== 0 && (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Anexos
|
||||||
|
</h3>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<AttachmentSection
|
||||||
|
transactionId={transaction.id}
|
||||||
|
readonly
|
||||||
|
onLoaded={setAttachmentCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{onEdit && !transaction.readonly && (
|
{onEdit && !transaction.readonly && (
|
||||||
<Button variant="outline" onClick={handleEdit}>
|
<Button variant="outline" onClick={handleEdit}>
|
||||||
@@ -223,9 +256,9 @@ interface DetailRowProps {
|
|||||||
|
|
||||||
function DetailRow({ label, value }: DetailRowProps) {
|
function DetailRow({ label, value }: DetailRowProps) {
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center justify-between">
|
<li className="min-w-0 flex items-center justify-between gap-3">
|
||||||
<span className="text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground">{label}</span>
|
||||||
<span>{value}</span>
|
<span className="min-w-0 truncate">{value}</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function BoletoFieldsSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showPaymentDate ? (
|
{showPaymentDate ? (
|
||||||
<div className="space-y-2 w-full md:w-1/2">
|
<div className="space-y-1 w-full md:w-1/2">
|
||||||
<Label htmlFor="boletoPaymentDate">Pagamento do boleto</Label>
|
<Label htmlFor="boletoPaymentDate">Pagamento do boleto</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
id="boletoPaymentDate"
|
id="boletoPaymentDate"
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface TransactionDialogProps {
|
|||||||
forceShowTransactionType?: boolean;
|
forceShowTransactionType?: boolean;
|
||||||
/** Called after successful create/update. Receives the action result. */
|
/** Called after successful create/update. Receives the action result. */
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
|
/** Max attachment file size in MB for this user */
|
||||||
|
maxSizeMb?: number;
|
||||||
onBulkEditRequest?: (data: {
|
onBulkEditRequest?: (data: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -41,6 +43,9 @@ export interface TransactionDialogProps {
|
|||||||
amount: number;
|
amount: number;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
boletoPaymentDate: string | null;
|
boletoPaymentDate: string | null;
|
||||||
|
isSettled: boolean | null;
|
||||||
|
pendingDetachIds: string[];
|
||||||
|
pendingUploadFiles: File[];
|
||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
createTransactionAction,
|
createTransactionAction,
|
||||||
updateTransactionAction,
|
updateTransactionAction,
|
||||||
} from "@/features/transactions/actions";
|
} from "@/features/transactions/actions";
|
||||||
|
import {
|
||||||
|
confirmAttachmentUploadAction,
|
||||||
|
detachTransactionAttachmentAction,
|
||||||
|
getPresignedUploadUrlAction,
|
||||||
|
} from "@/features/transactions/actions/attachments";
|
||||||
import {
|
import {
|
||||||
filterSecondaryPayerOptions,
|
filterSecondaryPayerOptions,
|
||||||
groupAndSortCategories,
|
groupAndSortCategories,
|
||||||
@@ -30,7 +35,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/shared/components/ui/dialog";
|
} from "@/shared/components/ui/dialog";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||||
|
import { AttachmentFilePicker } from "../../attachments/attachment-file-picker";
|
||||||
|
import { AttachmentSection } from "../../attachments/attachment-section";
|
||||||
import { BasicFieldsSection } from "./basic-fields-section";
|
import { BasicFieldsSection } from "./basic-fields-section";
|
||||||
import { BoletoFieldsSection } from "./boleto-fields-section";
|
import { BoletoFieldsSection } from "./boleto-fields-section";
|
||||||
import { CategorySection } from "./category-section";
|
import { CategorySection } from "./category-section";
|
||||||
@@ -65,10 +73,11 @@ export function TransactionDialog({
|
|||||||
defaultAmount,
|
defaultAmount,
|
||||||
lockCardSelection,
|
lockCardSelection,
|
||||||
lockPaymentMethod,
|
lockPaymentMethod,
|
||||||
isImporting = false,
|
isImporting,
|
||||||
defaultTransactionType,
|
defaultTransactionType,
|
||||||
forceShowTransactionType = false,
|
forceShowTransactionType,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
maxSizeMb,
|
||||||
onBulkEditRequest,
|
onBulkEditRequest,
|
||||||
}: TransactionDialogProps) {
|
}: TransactionDialogProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||||
@@ -90,6 +99,9 @@ export function TransactionDialog({
|
|||||||
);
|
);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
||||||
|
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
@@ -108,8 +120,9 @@ export function TransactionDialog({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Derive credit card period on open when cardId is pre-filled
|
// Derive credit card period on open when cardId is pre-filled (create only)
|
||||||
if (
|
if (
|
||||||
|
mode !== "update" &&
|
||||||
initial.paymentMethod === "Cartão de crédito" &&
|
initial.paymentMethod === "Cartão de crédito" &&
|
||||||
initial.cardId &&
|
initial.cardId &&
|
||||||
initial.purchaseDate
|
initial.purchaseDate
|
||||||
@@ -126,6 +139,9 @@ export function TransactionDialog({
|
|||||||
|
|
||||||
setFormState(initial);
|
setFormState(initial);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
setPendingFile(null);
|
||||||
|
setPendingDetachIds([]);
|
||||||
|
setPendingUploadFiles([]);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
dialogOpen,
|
dialogOpen,
|
||||||
@@ -313,6 +329,29 @@ export function TransactionDialog({
|
|||||||
const result = await createTransactionAction(payload);
|
const result = await createTransactionAction(payload);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
if (pendingFile && result.data?.ids?.length) {
|
||||||
|
const firstId = result.data.ids[0];
|
||||||
|
const isNewSeries =
|
||||||
|
formState.condition === "Parcelado" ||
|
||||||
|
formState.condition === "Recorrente";
|
||||||
|
const presign = await getPresignedUploadUrlAction({
|
||||||
|
fileName: pendingFile.name,
|
||||||
|
mimeType: pendingFile.type,
|
||||||
|
fileSize: pendingFile.size,
|
||||||
|
transactionId: firstId,
|
||||||
|
});
|
||||||
|
if (presign.success) {
|
||||||
|
await fetch(presign.presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: pendingFile,
|
||||||
|
headers: { "Content-Type": pendingFile.type },
|
||||||
|
});
|
||||||
|
await confirmAttachmentUploadAction({
|
||||||
|
uploadToken: presign.uploadToken,
|
||||||
|
scope: isNewSeries ? "all" : "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
@@ -324,11 +363,11 @@ export function TransactionDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update mode
|
|
||||||
const hasSeriesId = Boolean(transaction?.seriesId);
|
const hasSeriesId = Boolean(transaction?.seriesId);
|
||||||
|
|
||||||
if (hasSeriesId && onBulkEditRequest) {
|
if (hasSeriesId && onBulkEditRequest) {
|
||||||
// Para lançamentos em série, abre o diálogo de bulk action
|
// Para lançamentos em série, passa os arquivos para a página confirmar
|
||||||
|
// o upload após o escopo ser escolhido (sem upload antecipado ao S3)
|
||||||
onBulkEditRequest({
|
onBulkEditRequest({
|
||||||
id: transaction?.id ?? "",
|
id: transaction?.id ?? "",
|
||||||
name: formState.name.trim(),
|
name: formState.name.trim(),
|
||||||
@@ -346,11 +385,17 @@ export function TransactionDialog({
|
|||||||
mode === "update" && formState.paymentMethod === "Boleto"
|
mode === "update" && formState.paymentMethod === "Boleto"
|
||||||
? formState.boletoPaymentDate || null
|
? formState.boletoPaymentDate || null
|
||||||
: null,
|
: null,
|
||||||
|
isSettled:
|
||||||
|
formState.paymentMethod === "Cartão de crédito"
|
||||||
|
? null
|
||||||
|
: Boolean(formState.isSettled),
|
||||||
|
pendingDetachIds,
|
||||||
|
pendingUploadFiles,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atualização normal para lançamentos únicos ou todos os campos
|
// Atualização normal para lançamentos únicos
|
||||||
const updatePayload: UpdateTransactionInput = {
|
const updatePayload: UpdateTransactionInput = {
|
||||||
id: transaction?.id ?? "",
|
id: transaction?.id ?? "",
|
||||||
...payload,
|
...payload,
|
||||||
@@ -359,6 +404,31 @@ export function TransactionDialog({
|
|||||||
const result = await updateTransactionAction(updatePayload);
|
const result = await updateTransactionAction(updatePayload);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
for (const attachmentId of pendingDetachIds) {
|
||||||
|
await detachTransactionAttachmentAction({
|
||||||
|
attachmentId,
|
||||||
|
transactionId: transaction?.id ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const file of pendingUploadFiles) {
|
||||||
|
const presign = await getPresignedUploadUrlAction({
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
fileSize: file.size,
|
||||||
|
transactionId: transaction?.id ?? "",
|
||||||
|
});
|
||||||
|
if (presign.success) {
|
||||||
|
await fetch(presign.presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
});
|
||||||
|
await confirmAttachmentUploadAction({
|
||||||
|
uploadToken: presign.uploadToken,
|
||||||
|
scope: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
@@ -411,18 +481,18 @@ export function TransactionDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||||
<DialogContent>
|
<DialogContent className="min-w-0 overflow-x-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-0"
|
className="flex min-w-0 flex-col gap-0"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
<div className="space-y-3 -mx-6 max-h-[70vh] overflow-y-auto px-6 pb-1">
|
<div className="min-w-0 space-y-3 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
|
||||||
<BasicFieldsSection
|
<BasicFieldsSection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
@@ -473,17 +543,64 @@ export function TransactionDialog({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isUpdateMode ? (
|
{isUpdateMode ? (
|
||||||
|
<>
|
||||||
<NoteSection
|
<NoteSection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
/>
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium leading-none">
|
||||||
|
Anexos
|
||||||
|
</Label>
|
||||||
|
<AttachmentSection
|
||||||
|
transactionId={transaction?.id ?? ""}
|
||||||
|
maxSizeMb={maxSizeMb}
|
||||||
|
pendingDetachIds={
|
||||||
|
transaction?.seriesId ? pendingDetachIds : undefined
|
||||||
|
}
|
||||||
|
onPendingDetach={
|
||||||
|
transaction?.seriesId
|
||||||
|
? (id) => setPendingDetachIds((prev) => [...prev, id])
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onUndoPendingDetach={
|
||||||
|
transaction?.seriesId
|
||||||
|
? (id) =>
|
||||||
|
setPendingDetachIds((prev) =>
|
||||||
|
prev.filter((x) => x !== id),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
pendingUploadFiles={
|
||||||
|
transaction?.seriesId ? pendingUploadFiles : undefined
|
||||||
|
}
|
||||||
|
onPendingUpload={
|
||||||
|
transaction?.seriesId
|
||||||
|
? (file) =>
|
||||||
|
setPendingUploadFiles((prev) => [...prev, file])
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onCancelPendingUpload={
|
||||||
|
transaction?.seriesId
|
||||||
|
? (file) =>
|
||||||
|
setPendingUploadFiles((prev) =>
|
||||||
|
prev.filter((f) => f !== file),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Collapsible defaultOpen={formState.condition !== "À vista"}>
|
<Collapsible
|
||||||
|
defaultOpen={formState.condition !== "À vista"}
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
||||||
<RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" />
|
<RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" />
|
||||||
Condições e anotações
|
Condições, anotações e anexos
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="space-y-3 pt-3">
|
<CollapsibleContent className="min-w-0 overflow-hidden space-y-3 pt-3">
|
||||||
<ConditionSection
|
<ConditionSection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
@@ -494,6 +611,11 @@ export function TransactionDialog({
|
|||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
/>
|
/>
|
||||||
|
<AttachmentFilePicker
|
||||||
|
file={pendingFile}
|
||||||
|
onChange={setPendingFile}
|
||||||
|
maxSizeMb={maxSizeMb}
|
||||||
|
/>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function decodeAccountCard(value: string): {
|
|||||||
id: string;
|
id: string;
|
||||||
} | null {
|
} | null {
|
||||||
if (value.startsWith("card:")) return { type: "card", id: value.slice(5) };
|
if (value.startsWith("card:")) return { type: "card", id: value.slice(5) };
|
||||||
if (value.startsWith("account:")) return { type: "account", id: value.slice(8) };
|
if (value.startsWith("account:"))
|
||||||
|
return { type: "account", id: value.slice(8) };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +66,9 @@ export function GlobalFields({
|
|||||||
onBulkCategoryChange,
|
onBulkCategoryChange,
|
||||||
}: GlobalFieldsProps) {
|
}: GlobalFieldsProps) {
|
||||||
const isCard = accountCardValue?.startsWith("card:") ?? false;
|
const isCard = accountCardValue?.startsWith("card:") ?? false;
|
||||||
const expenseCategories = categoryOptions.filter((o) => o.group === "despesa");
|
const expenseCategories = categoryOptions.filter(
|
||||||
|
(o) => o.group === "despesa",
|
||||||
|
);
|
||||||
const incomeCategories = categoryOptions.filter((o) => o.group === "receita");
|
const incomeCategories = categoryOptions.filter((o) => o.group === "receita");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -131,7 +134,10 @@ export function GlobalFields({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{payerOptions.map((opt) => (
|
{payerOptions.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
<PayerSelectContent label={opt.label} avatarUrl={opt.avatarUrl} />
|
<PayerSelectContent
|
||||||
|
label={opt.label}
|
||||||
|
avatarUrl={opt.avatarUrl}
|
||||||
|
/>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -150,7 +156,10 @@ export function GlobalFields({
|
|||||||
<SelectLabel>Despesa</SelectLabel>
|
<SelectLabel>Despesa</SelectLabel>
|
||||||
{expenseCategories.map((opt) => (
|
{expenseCategories.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
<CategorySelectContent label={opt.label} icon={opt.icon} />
|
<CategorySelectContent
|
||||||
|
label={opt.label}
|
||||||
|
icon={opt.icon}
|
||||||
|
/>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
@@ -163,7 +172,10 @@ export function GlobalFields({
|
|||||||
<SelectLabel>Receita</SelectLabel>
|
<SelectLabel>Receita</SelectLabel>
|
||||||
{incomeCategories.map((opt) => (
|
{incomeCategories.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
<CategorySelectContent label={opt.label} icon={opt.icon} />
|
<CategorySelectContent
|
||||||
|
label={opt.label}
|
||||||
|
icon={opt.icon}
|
||||||
|
/>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useTransition,
|
||||||
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
fetchCategoryMappings,
|
fetchCategoryMappings,
|
||||||
saveCategoryMappings,
|
saveCategoryMappings,
|
||||||
} from "@/features/transactions/actions/category-memory-action";
|
} from "@/features/transactions/actions/category-memory-action";
|
||||||
import { normalizeDescriptionKey } from "@/features/transactions/lib/import-utils";
|
|
||||||
import {
|
import {
|
||||||
checkDuplicateFitIds,
|
checkDuplicateFitIds,
|
||||||
deleteTransactionByFitId,
|
deleteTransactionByFitId,
|
||||||
@@ -27,6 +32,7 @@ import {
|
|||||||
} from "@/features/transactions/components/import/review-table";
|
} from "@/features/transactions/components/import/review-table";
|
||||||
import { UploadZone } from "@/features/transactions/components/import/upload-zone";
|
import { UploadZone } from "@/features/transactions/components/import/upload-zone";
|
||||||
import type { SelectOption } from "@/features/transactions/components/types";
|
import type { SelectOption } from "@/features/transactions/components/types";
|
||||||
|
import { normalizeDescriptionKey } from "@/features/transactions/lib/import-utils";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -82,7 +88,8 @@ export function ImportPage({
|
|||||||
...t,
|
...t,
|
||||||
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
|
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
|
||||||
selected: t.externalId ? !duplicates.has(t.externalId) : true,
|
selected: t.externalId ? !duplicates.has(t.externalId) : true,
|
||||||
categoryId: categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
|
categoryId:
|
||||||
|
categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -167,7 +174,9 @@ export function ImportPage({
|
|||||||
const handleImport = () => {
|
const handleImport = () => {
|
||||||
if (!statement || !canImport) return;
|
if (!statement || !canImport) return;
|
||||||
|
|
||||||
const decoded = decodeAccountCard(accountCardValue!);
|
const decoded = accountCardValue
|
||||||
|
? decodeAccountCard(accountCardValue)
|
||||||
|
: null;
|
||||||
const cardId = decoded?.type === "card" ? decoded.id : null;
|
const cardId = decoded?.type === "card" ? decoded.id : null;
|
||||||
const accountId = decoded?.type === "account" ? decoded.id : null;
|
const accountId = decoded?.type === "account" ? decoded.id : null;
|
||||||
const paymentMethod =
|
const paymentMethod =
|
||||||
@@ -197,7 +206,10 @@ export function ImportPage({
|
|||||||
|
|
||||||
// Salva mapeamentos description → category (fire-and-forget)
|
// Salva mapeamentos description → category (fire-and-forget)
|
||||||
saveCategoryMappings(
|
saveCategoryMappings(
|
||||||
selectedRows.map((r) => ({ description: r.description, categoryId: r.categoryId })),
|
selectedRows.map((r) => ({
|
||||||
|
description: r.description,
|
||||||
|
categoryId: r.categoryId,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { importBatchId } = result;
|
const { importBatchId } = result;
|
||||||
@@ -236,7 +248,8 @@ export function ImportPage({
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle>Importar extrato</CardTitle>
|
<CardTitle>Importar extrato</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Importe transações a partir de um arquivo .ofx ou planilha .xlsx exportado pelo seu banco.
|
Importe transações a partir de um arquivo .ofx ou planilha .xlsx
|
||||||
|
exportado pelo seu banco.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<ImportSteps current={currentStep} />
|
<ImportSteps current={currentStep} />
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export function ImportSteps({ current }: ImportStepsProps) {
|
|||||||
isCompleted &&
|
isCompleted &&
|
||||||
"border-primary bg-primary text-primary-foreground",
|
"border-primary bg-primary text-primary-foreground",
|
||||||
isActive && "border-primary text-primary",
|
isActive && "border-primary text-primary",
|
||||||
!isCompleted && !isActive && "border-muted-foreground/30 text-muted-foreground",
|
!isCompleted &&
|
||||||
|
!isActive &&
|
||||||
|
"border-muted-foreground/30 text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef } from "react";
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { useRef } from "react";
|
||||||
import { CategorySelectContent } from "@/features/transactions/components/select-items";
|
import { CategorySelectContent } from "@/features/transactions/components/select-items";
|
||||||
import type { SelectOption } from "@/features/transactions/components/types";
|
import type { SelectOption } from "@/features/transactions/components/types";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
@@ -91,9 +91,7 @@ export function ReviewTable({
|
|||||||
onCheckedChange={(v) => onToggleAll(!!v)}
|
onCheckedChange={(v) => onToggleAll(!!v)}
|
||||||
aria-label="Selecionar todas"
|
aria-label="Selecionar todas"
|
||||||
data-state={
|
data-state={
|
||||||
!allSelected && someSelected
|
!allSelected && someSelected ? "indeterminate" : undefined
|
||||||
? "indeterminate"
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -114,7 +112,10 @@ export function ReviewTable({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{virtualRows.map((virtualRow) => {
|
{virtualRows.map((virtualRow) => {
|
||||||
const row = rows[virtualRow.index]!;
|
const row = rows[virtualRow.index];
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const index = virtualRow.index;
|
const index = virtualRow.index;
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -199,9 +200,7 @@ export function ReviewTable({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<TransactionTypeBadge
|
<TransactionTypeBadge
|
||||||
kind={
|
kind={
|
||||||
row.transactionType === "income"
|
row.transactionType === "income" ? "Receita" : "Despesa"
|
||||||
? "Receita"
|
|
||||||
: "Despesa"
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
|
|||||||
}
|
}
|
||||||
onParsed(statement);
|
onParsed(statement);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Não foi possível ler o arquivo. Verifique se é um OFX válido.");
|
setError(
|
||||||
|
"Não foi possível ler o arquivo. Verifique se é um OFX válido.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsText(file, "windows-1252");
|
reader.readAsText(file, "windows-1252");
|
||||||
@@ -119,11 +121,7 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{error ? (
|
{error ? <p className="text-destructive text-sm">{error}</p> : <span />}
|
||||||
<p className="text-destructive text-sm">{error}</p>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDownloadTemplate}
|
onClick={handleDownloadTemplate}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
toggleTransactionSettlementAction,
|
toggleTransactionSettlementAction,
|
||||||
updateTransactionBulkAction,
|
updateTransactionBulkAction,
|
||||||
} from "@/features/transactions/actions";
|
} from "@/features/transactions/actions";
|
||||||
|
import {
|
||||||
|
confirmAttachmentUploadAction,
|
||||||
|
detachAttachmentBulkAction,
|
||||||
|
getPresignedUploadUrlAction,
|
||||||
|
} from "@/features/transactions/actions/attachments";
|
||||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
import type {
|
import type {
|
||||||
TransactionsExportContext,
|
TransactionsExportContext,
|
||||||
@@ -59,6 +64,7 @@ interface TransactionsPageProps {
|
|||||||
lockPaymentMethod?: boolean;
|
lockPaymentMethod?: boolean;
|
||||||
pagination?: TransactionsPaginationState;
|
pagination?: TransactionsPaginationState;
|
||||||
exportContext?: TransactionsExportContext;
|
exportContext?: TransactionsExportContext;
|
||||||
|
attachmentMaxSizeMb?: number;
|
||||||
// Opções específicas para o dialog de importação (quando visualizando dados de outro usuário)
|
// Opções específicas para o dialog de importação (quando visualizando dados de outro usuário)
|
||||||
importPayerOptions?: SelectOption[];
|
importPayerOptions?: SelectOption[];
|
||||||
importSplitPayerOptions?: SelectOption[];
|
importSplitPayerOptions?: SelectOption[];
|
||||||
@@ -91,6 +97,7 @@ export function TransactionsPage({
|
|||||||
lockPaymentMethod,
|
lockPaymentMethod,
|
||||||
pagination,
|
pagination,
|
||||||
exportContext,
|
exportContext,
|
||||||
|
attachmentMaxSizeMb,
|
||||||
importPayerOptions,
|
importPayerOptions,
|
||||||
importSplitPayerOptions,
|
importSplitPayerOptions,
|
||||||
importDefaultPayerId,
|
importDefaultPayerId,
|
||||||
@@ -129,6 +136,9 @@ export function TransactionsPage({
|
|||||||
amount: number;
|
amount: number;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
boletoPaymentDate: string | null;
|
boletoPaymentDate: string | null;
|
||||||
|
isSettled: boolean | null;
|
||||||
|
pendingDetachIds: string[];
|
||||||
|
pendingUploadFiles: File[];
|
||||||
transaction: TransactionItem;
|
transaction: TransactionItem;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingDeleteData, setPendingDeleteData] =
|
const [pendingDeleteData, setPendingDeleteData] =
|
||||||
@@ -182,7 +192,7 @@ export function TransactionsPage({
|
|||||||
toast.success(
|
toast.success(
|
||||||
nextValue
|
nextValue
|
||||||
? `"${item.name}" marcado como pago`
|
? `"${item.name}" marcado como pago`
|
||||||
: `"${item.name}" desmarcado`,
|
: `"${item.name}" marcado como não pago`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@@ -244,6 +254,9 @@ export function TransactionsPage({
|
|||||||
amount: number;
|
amount: number;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
boletoPaymentDate: string | null;
|
boletoPaymentDate: string | null;
|
||||||
|
isSettled: boolean | null;
|
||||||
|
pendingDetachIds: string[];
|
||||||
|
pendingUploadFiles: File[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!selectedTransaction) {
|
if (!selectedTransaction) {
|
||||||
return;
|
return;
|
||||||
@@ -274,6 +287,7 @@ export function TransactionsPage({
|
|||||||
amount: pendingEditData.amount,
|
amount: pendingEditData.amount,
|
||||||
dueDate: pendingEditData.dueDate,
|
dueDate: pendingEditData.dueDate,
|
||||||
boletoPaymentDate: pendingEditData.boletoPaymentDate,
|
boletoPaymentDate: pendingEditData.boletoPaymentDate,
|
||||||
|
isSettled: pendingEditData.isSettled ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -281,6 +295,36 @@ export function TransactionsPage({
|
|||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propaga remoções de anexo pendentes com o mesmo escopo
|
||||||
|
for (const attachmentId of pendingEditData.pendingDetachIds) {
|
||||||
|
await detachAttachmentBulkAction({
|
||||||
|
attachmentId,
|
||||||
|
transactionId: pendingEditData.id,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faz upload dos arquivos pendentes e confirma com o escopo escolhido
|
||||||
|
for (const file of pendingEditData.pendingUploadFiles) {
|
||||||
|
const presign = await getPresignedUploadUrlAction({
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
fileSize: file.size,
|
||||||
|
transactionId: pendingEditData.id,
|
||||||
|
});
|
||||||
|
if (presign.success) {
|
||||||
|
await fetch(presign.presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
});
|
||||||
|
await confirmAttachmentUploadAction({
|
||||||
|
uploadToken: presign.uploadToken,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setBulkEditOpen(false);
|
setBulkEditOpen(false);
|
||||||
setPendingEditData(null);
|
setPendingEditData(null);
|
||||||
@@ -435,6 +479,7 @@ export function TransactionsPage({
|
|||||||
lockCardSelection={lockCardSelection}
|
lockCardSelection={lockCardSelection}
|
||||||
lockPaymentMethod={lockPaymentMethod}
|
lockPaymentMethod={lockPaymentMethod}
|
||||||
defaultTransactionType={transactionTypeForCreate ?? undefined}
|
defaultTransactionType={transactionTypeForCreate ?? undefined}
|
||||||
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -456,6 +501,7 @@ export function TransactionsPage({
|
|||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
transaction={transactionToCopy ?? undefined}
|
transaction={transactionToCopy ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionDialog
|
<TransactionDialog
|
||||||
@@ -477,6 +523,7 @@ export function TransactionsPage({
|
|||||||
transaction={transactionToImport ?? undefined}
|
transaction={transactionToImport ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
isImporting={true}
|
isImporting={true}
|
||||||
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BulkImportDialog
|
<BulkImportDialog
|
||||||
@@ -504,6 +551,7 @@ export function TransactionsPage({
|
|||||||
transaction={selectedTransaction ?? undefined}
|
transaction={selectedTransaction ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
onBulkEditRequest={handleBulkEditRequest}
|
onBulkEditRequest={handleBulkEditRequest}
|
||||||
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionDetailsDialog
|
<TransactionDetailsDialog
|
||||||
@@ -572,7 +620,7 @@ export function TransactionsPage({
|
|||||||
onConfirm={handleBulkEdit}
|
onConfirm={handleBulkEdit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{allowCreate ? (
|
{allowCreate && massAddOpen ? (
|
||||||
<MassAddDialog
|
<MassAddDialog
|
||||||
open={massAddOpen}
|
open={massAddOpen}
|
||||||
onOpenChange={setMassAddOpen}
|
onOpenChange={setMassAddOpen}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
RiArrowLeftSLine,
|
RiArrowLeftSLine,
|
||||||
RiArrowRightDoubleLine,
|
RiArrowRightDoubleLine,
|
||||||
RiArrowRightSLine,
|
RiArrowRightSLine,
|
||||||
|
RiAttachment2,
|
||||||
RiBankCard2Line,
|
RiBankCard2Line,
|
||||||
RiChat1Line,
|
RiChat1Line,
|
||||||
RiCheckboxBlankCircleLine,
|
RiCheckboxBlankCircleLine,
|
||||||
@@ -13,9 +14,9 @@ import {
|
|||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiDeleteBin5Line,
|
RiDeleteBin5Line,
|
||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
|
RiFileExcel2Line,
|
||||||
RiFileList2Line,
|
RiFileList2Line,
|
||||||
RiFlashlightFill,
|
RiFlashlightFill,
|
||||||
RiFileExcel2Line,
|
|
||||||
RiGroupLine,
|
RiGroupLine,
|
||||||
RiHistoryLine,
|
RiHistoryLine,
|
||||||
RiMoreFill,
|
RiMoreFill,
|
||||||
@@ -115,6 +116,14 @@ type BuildColumnsArgs = {
|
|||||||
showActions: boolean;
|
showActions: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getPaymentMethodTableLabel(method: string) {
|
||||||
|
if (method === "Transferência bancária") {
|
||||||
|
return "Transf. bancária";
|
||||||
|
}
|
||||||
|
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
const buildColumns = ({
|
const buildColumns = ({
|
||||||
currentUserId,
|
currentUserId,
|
||||||
noteAsColumn,
|
noteAsColumn,
|
||||||
@@ -182,6 +191,7 @@ const buildColumns = ({
|
|||||||
note,
|
note,
|
||||||
isDivided,
|
isDivided,
|
||||||
isAnticipated,
|
isAnticipated,
|
||||||
|
hasAttachments,
|
||||||
} = row.original;
|
} = row.original;
|
||||||
|
|
||||||
const installmentBadge =
|
const installmentBadge =
|
||||||
@@ -191,7 +201,7 @@ const buildColumns = ({
|
|||||||
|
|
||||||
const isBoleto = paymentMethod === "Boleto" && dueDate;
|
const isBoleto = paymentMethod === "Boleto" && dueDate;
|
||||||
const dueDateLabel =
|
const dueDateLabel =
|
||||||
isBoleto && dueDate ? `Venc. ${formatDate(dueDate)}` : null;
|
isBoleto && dueDate ? `venc. ${formatDate(dueDate)}` : null;
|
||||||
const hasNote = Boolean(note?.trim().length);
|
const hasNote = Boolean(note?.trim().length);
|
||||||
const isLastInstallment =
|
const isLastInstallment =
|
||||||
currentInstallment === installmentCount &&
|
currentInstallment === installmentCount &&
|
||||||
@@ -201,13 +211,19 @@ const buildColumns = ({
|
|||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<EstablishmentLogo name={name} size={28} />
|
<EstablishmentLogo name={name} size={28} />
|
||||||
<span className="flex flex-col">
|
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="flex flex-col py-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
{formatDate(purchaseDate)}
|
{formatDate(purchaseDate)}
|
||||||
|
|
||||||
|
{dueDateLabel ? (
|
||||||
|
<span className="text-primary">{dueDateLabel}</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="line-clamp-2 max-w-[160px] font-semibold truncate">
|
<span className="line-clamp-2 max-w-[180px] font-bold truncate">
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -215,7 +231,6 @@ const buildColumns = ({
|
|||||||
{name}
|
{name}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
|
||||||
|
|
||||||
{isDivided && (
|
{isDivided && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -226,7 +241,9 @@ const buildColumns = ({
|
|||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<span className="sr-only">Dividido entre pagadores</span>
|
<span className="sr-only">
|
||||||
|
Dividido entre pagadores
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
@@ -259,12 +276,6 @@ const buildColumns = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{dueDateLabel ? (
|
|
||||||
<Badge variant="outline" className="px-2 text-xs">
|
|
||||||
{dueDateLabel}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isAnticipated && (
|
{isAnticipated && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -277,7 +288,9 @@ const buildColumns = ({
|
|||||||
<span className="sr-only">Parcela antecipada</span>
|
<span className="sr-only">Parcela antecipada</span>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">Parcela antecipada</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
Parcela antecipada
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -301,6 +314,23 @@ const buildColumns = ({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{hasAttachments ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex rounded-full p-1">
|
||||||
|
<RiAttachment2
|
||||||
|
className="h-4 w-4 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Possui anexos</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Possui anexos</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -366,7 +396,7 @@ const buildColumns = ({
|
|||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{icon}
|
{icon}
|
||||||
<span>{method}</span>
|
<span>{getPaymentMethodTableLabel(method)}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type TransactionItem = {
|
|||||||
isAnticipated: boolean;
|
isAnticipated: boolean;
|
||||||
anticipationId: string | null;
|
anticipationId: string | null;
|
||||||
seriesId: string | null;
|
seriesId: string | null;
|
||||||
|
hasAttachments: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ type TransactionRowWithRelations = Partial<typeof transactions.$inferSelect> & {
|
|||||||
financialAccount?: AccountRow | null;
|
financialAccount?: AccountRow | null;
|
||||||
card?: CardRow | null;
|
card?: CardRow | null;
|
||||||
category?: CategoryRow | null;
|
category?: CategoryRow | null;
|
||||||
|
hasAttachments?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
|
export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
|
||||||
@@ -442,6 +443,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,
|
||||||
|
hasAttachments: item.hasAttachments ?? false,
|
||||||
readonly:
|
readonly:
|
||||||
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
||||||
item.category?.name === "Saldo inicial" ||
|
item.category?.name === "Saldo inicial" ||
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
categories,
|
categories,
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
payers,
|
payers,
|
||||||
|
transactionAttachments,
|
||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
||||||
@@ -43,6 +44,7 @@ const DEFAULT_EXCLUDE_INITIAL_BALANCE = true;
|
|||||||
|
|
||||||
const buildInitialBalanceVisibilityFilter = () =>
|
const buildInitialBalanceVisibilityFilter = () =>
|
||||||
or(
|
or(
|
||||||
|
isNull(transactions.note),
|
||||||
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
||||||
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
||||||
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
||||||
@@ -73,6 +75,7 @@ const mapTransactionRows = (
|
|||||||
financialAccount: typeof financialAccounts.$inferSelect | null;
|
financialAccount: typeof financialAccounts.$inferSelect | null;
|
||||||
card: typeof cards.$inferSelect | null;
|
card: typeof cards.$inferSelect | null;
|
||||||
category: typeof categories.$inferSelect | null;
|
category: typeof categories.$inferSelect | null;
|
||||||
|
hasAttachments: boolean;
|
||||||
}[],
|
}[],
|
||||||
) =>
|
) =>
|
||||||
transactionRows.map((row) => ({
|
transactionRows.map((row) => ({
|
||||||
@@ -81,6 +84,7 @@ const mapTransactionRows = (
|
|||||||
financialAccount: row.financialAccount,
|
financialAccount: row.financialAccount,
|
||||||
card: row.card,
|
card: row.card,
|
||||||
category: row.category,
|
category: row.category,
|
||||||
|
hasAttachments: row.hasAttachments,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
async function selectTransactionsWithRelations({
|
async function selectTransactionsWithRelations({
|
||||||
@@ -97,6 +101,10 @@ async function selectTransactionsWithRelations({
|
|||||||
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))
|
||||||
|
|||||||
1
src/global.d.ts
vendored
Normal file
1
src/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
|
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
|
||||||
|
import type { VariantProps } from "class-variance-authority";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { buttonVariants } from "@/shared/components/ui/button";
|
import { buttonVariants } from "@/shared/components/ui/button";
|
||||||
@@ -13,11 +14,13 @@ import { cn } from "@/shared/utils/ui";
|
|||||||
interface AnimatedThemeTogglerProps
|
interface AnimatedThemeTogglerProps
|
||||||
extends React.ComponentPropsWithoutRef<"button"> {
|
extends React.ComponentPropsWithoutRef<"button"> {
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
variant?: VariantProps<typeof buttonVariants>["variant"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnimatedThemeToggler = ({
|
export const AnimatedThemeToggler = ({
|
||||||
className,
|
className,
|
||||||
duration = 400,
|
duration = 400,
|
||||||
|
variant = "ghost",
|
||||||
...props
|
...props
|
||||||
}: AnimatedThemeTogglerProps) => {
|
}: AnimatedThemeTogglerProps) => {
|
||||||
const [isDark, setIsDark] = useState(false);
|
const [isDark, setIsDark] = useState(false);
|
||||||
@@ -84,10 +87,10 @@ export const AnimatedThemeToggler = ({
|
|||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
data-state={isDark ? "dark" : "light"}
|
data-state={isDark ? "dark" : "light"}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
buttonVariants({ variant, size: "icon-sm" }),
|
||||||
"group relative text-muted-foreground transition-all duration-200",
|
"group relative transition-all duration-200",
|
||||||
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
|
variant === "ghost" &&
|
||||||
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground",
|
"text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40 data-[state=open]:bg-accent/60 data-[state=open]:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { version } from "@/package.json";
|
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
variant?: "full" | "small" | "compact";
|
variant?: "full" | "small" | "compact";
|
||||||
className?: string;
|
className?: string;
|
||||||
showVersion?: boolean;
|
/** Apenas nos variants "full" e "compact" */
|
||||||
invertTextOnDark?: boolean;
|
invertTextOnDark?: boolean;
|
||||||
/** Exibe o ícone na cor original, sem filtro preto */
|
/** Exibe o ícone na cor original, sem filtro preto. Apenas nos variants "full" e "compact" */
|
||||||
colorIcon?: boolean;
|
colorIcon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconFilterClass = "brightness-0 saturate-0";
|
||||||
|
|
||||||
export function Logo({
|
export function Logo({
|
||||||
variant = "full",
|
variant = "full",
|
||||||
className,
|
className,
|
||||||
showVersion = false,
|
|
||||||
invertTextOnDark = true,
|
invertTextOnDark = true,
|
||||||
colorIcon = false,
|
colorIcon = false,
|
||||||
}: LogoProps) {
|
}: LogoProps) {
|
||||||
@@ -26,10 +26,7 @@ export function Logo({
|
|||||||
alt="OpenMonetis"
|
alt="OpenMonetis"
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
className={cn(
|
className={cn("object-contain", !colorIcon && iconFilterClass)}
|
||||||
"object-contain",
|
|
||||||
!colorIcon && "brightness-0 saturate-0",
|
|
||||||
)}
|
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
@@ -67,7 +64,7 @@ export function Logo({
|
|||||||
alt="OpenMonetis"
|
alt="OpenMonetis"
|
||||||
width={28}
|
width={28}
|
||||||
height={28}
|
height={28}
|
||||||
className="object-contain"
|
className={cn("object-contain", !colorIcon && iconFilterClass)}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
@@ -78,11 +75,6 @@ export function Logo({
|
|||||||
className={cn("object-contain", invertTextOnDark && "dark:invert")}
|
className={cn("object-contain", invertTextOnDark && "dark:invert")}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
{showVersion && (
|
|
||||||
<span className="text-[9px] font-medium text-muted-foreground">
|
|
||||||
{version}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function MonthNavigation() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="sticky top-16 z-10 flex w-full flex-row p-4">
|
<Card className="sticky top-16 z-10 flex w-full flex-row p-4 backdrop-blur-xs supports-backdrop-filter:bg-card/80">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<NavigationButton
|
<NavigationButton
|
||||||
direction="left"
|
direction="left"
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import type { DashboardNotificationsSnapshot } from "@/features/dashboard/notifications-queries";
|
|
||||||
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
|
||||||
import { Logo } from "@/shared/components/logo";
|
|
||||||
import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell";
|
import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell";
|
||||||
import { RefreshPageButton } from "@/shared/components/refresh-page-button";
|
import { RefreshPageButton } from "@/shared/components/refresh-page-button";
|
||||||
|
import type { DashboardNotificationsSnapshot } from "@/shared/lib/types/notifications";
|
||||||
import { NavMenu } from "./nav-menu";
|
import { NavMenu } from "./nav-menu";
|
||||||
|
import { NavbarShell } from "./navbar-shell";
|
||||||
import { NavbarUser } from "./navbar-user";
|
import { NavbarUser } from "./navbar-user";
|
||||||
|
|
||||||
type AppNavbarProps = {
|
type AppNavbarProps = {
|
||||||
@@ -19,9 +18,6 @@ type AppNavbarProps = {
|
|||||||
notificationsSnapshot: DashboardNotificationsSnapshot;
|
notificationsSnapshot: DashboardNotificationsSnapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navbarActionClassName =
|
|
||||||
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
|
|
||||||
|
|
||||||
export function AppNavbar({
|
export function AppNavbar({
|
||||||
user,
|
user,
|
||||||
pagadorAvatarUrl,
|
pagadorAvatarUrl,
|
||||||
@@ -29,27 +25,20 @@ export function AppNavbar({
|
|||||||
notificationsSnapshot,
|
notificationsSnapshot,
|
||||||
}: AppNavbarProps) {
|
}: AppNavbarProps) {
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 left-0 right-0 z-50 flex h-16 shrink-0 items-center bg-primary">
|
<NavbarShell logoHref="/dashboard" fixed>
|
||||||
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4">
|
|
||||||
<Link href="/dashboard" className="shrink-0 mr-1">
|
|
||||||
<Logo variant="compact" invertTextOnDark={false} />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<NavMenu />
|
<NavMenu />
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<NotificationBell
|
<NotificationBell
|
||||||
notifications={notificationsSnapshot.notifications}
|
notifications={notificationsSnapshot.notifications}
|
||||||
totalCount={notificationsSnapshot.totalCount}
|
unreadCount={notificationsSnapshot.unreadCount}
|
||||||
|
visibleCount={notificationsSnapshot.visibleCount}
|
||||||
budgetNotifications={notificationsSnapshot.budgetNotifications}
|
budgetNotifications={notificationsSnapshot.budgetNotifications}
|
||||||
preLancamentosCount={preLancamentosCount}
|
preLancamentosCount={preLancamentosCount}
|
||||||
/>
|
/>
|
||||||
<RefreshPageButton className={navbarActionClassName} />
|
<RefreshPageButton variant="navbar" />
|
||||||
<AnimatedThemeToggler className={navbarActionClassName} />
|
<AnimatedThemeToggler variant="navbar" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavbarUser user={user} pagadorAvatarUrl={pagadorAvatarUrl} />
|
<NavbarUser user={user} pagadorAvatarUrl={pagadorAvatarUrl} />
|
||||||
</div>
|
</NavbarShell>
|
||||||
</header>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,18 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/shared/components/ui/sheet";
|
} from "@/shared/components/ui/sheet";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
import { MobileLink, MobileSectionLabel } from "./mobile-link";
|
import { MobileLink, MobileSectionLabel } from "./mobile-link";
|
||||||
import { NavDropdown } from "./nav-dropdown";
|
import { NavDropdown } from "./nav-dropdown";
|
||||||
import { NAV_SECTIONS } from "./nav-items";
|
import { NAV_SECTIONS } from "./nav-items";
|
||||||
import { NavPill } from "./nav-pill";
|
import { NavPill } from "./nav-pill";
|
||||||
import { triggerActiveClass, triggerClass } from "./nav-styles";
|
|
||||||
import { MobileTools, NavToolsDropdown } from "./nav-tools";
|
import { MobileTools, NavToolsDropdown } from "./nav-tools";
|
||||||
|
|
||||||
|
const triggerClass =
|
||||||
|
"h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! lowercase! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10!";
|
||||||
|
|
||||||
|
const triggerActiveClass = "bg-black/15! text-black!";
|
||||||
|
|
||||||
export function NavMenu() {
|
export function NavMenu() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
@@ -55,7 +60,10 @@ export function NavMenu() {
|
|||||||
return (
|
return (
|
||||||
<NavigationMenuItem key={section.label}>
|
<NavigationMenuItem key={section.label}>
|
||||||
<NavigationMenuTrigger
|
<NavigationMenuTrigger
|
||||||
className={`${triggerClass} ${isSectionActive ? triggerActiveClass : ""}`}
|
className={cn(
|
||||||
|
triggerClass,
|
||||||
|
isSectionActive && triggerActiveClass,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{section.label}
|
{section.label}
|
||||||
</NavigationMenuTrigger>
|
</NavigationMenuTrigger>
|
||||||
@@ -82,9 +90,9 @@ export function NavMenu() {
|
|||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="navbar"
|
||||||
size="icon"
|
size="icon-sm"
|
||||||
className="-order-1 border border-black/10 text-black/75 shadow-none md:hidden hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20"
|
className="-order-1 md:hidden"
|
||||||
>
|
>
|
||||||
<RiMenuLine className="size-5" />
|
<RiMenuLine className="size-5" />
|
||||||
<span className="sr-only">Abrir menu</span>
|
<span className="sr-only">Abrir menu</span>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { buttonVariants } from "@/shared/components/ui/button";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import { NavLink } from "./nav-link";
|
import { NavLink } from "./nav-link";
|
||||||
import { linkActive, linkBase, linkIdle } from "./nav-styles";
|
|
||||||
|
|
||||||
type NavPillProps = {
|
type NavPillProps = {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -23,7 +23,11 @@ export function NavPill({ href, preservePeriod, children }: NavPillProps) {
|
|||||||
<NavLink
|
<NavLink
|
||||||
href={href}
|
href={href}
|
||||||
preservePeriod={preservePeriod}
|
preservePeriod={preservePeriod}
|
||||||
className={cn(linkBase, isActive ? linkActive : linkIdle)}
|
className={cn(
|
||||||
|
buttonVariants({ variant: "navbar", size: "sm" }),
|
||||||
|
"lowercase",
|
||||||
|
isActive && "bg-black/15 text-black",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
export const linkBase =
|
|
||||||
"inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all lowercase";
|
|
||||||
|
|
||||||
export const linkIdle = "text-black/75 hover:bg-black/10 hover:text-black";
|
|
||||||
|
|
||||||
export const linkActive = "bg-black/15 text-black";
|
|
||||||
|
|
||||||
export const triggerActiveClass = ["bg-black/15!", "text-black!"].join(" ");
|
|
||||||
|
|
||||||
export const triggerClass = [
|
|
||||||
"h-8!",
|
|
||||||
"rounded-md!",
|
|
||||||
"px-2!",
|
|
||||||
"py-0!",
|
|
||||||
"text-sm!",
|
|
||||||
"font-medium!",
|
|
||||||
"bg-transparent!",
|
|
||||||
"text-black/75!",
|
|
||||||
"hover:text-black!",
|
|
||||||
"hover:bg-black/10!",
|
|
||||||
"focus:text-black!",
|
|
||||||
"focus:bg-black/10!",
|
|
||||||
"focus-visible:ring-black/20!",
|
|
||||||
"data-[state=open]:text-black!",
|
|
||||||
"data-[state=open]:bg-black/10!",
|
|
||||||
"shadow-none!",
|
|
||||||
"[&_svg]:text-current!",
|
|
||||||
"lowercase!",
|
|
||||||
].join(" ");
|
|
||||||
33
src/shared/components/navigation/navbar/navbar-shell.tsx
Normal file
33
src/shared/components/navigation/navbar/navbar-shell.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Logo } from "@/shared/components/logo";
|
||||||
|
|
||||||
|
type NavbarShellProps = {
|
||||||
|
logoHref?: string;
|
||||||
|
fixed?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NavbarShell({
|
||||||
|
logoHref,
|
||||||
|
fixed = false,
|
||||||
|
children,
|
||||||
|
}: NavbarShellProps) {
|
||||||
|
const positionClass = fixed ? "fixed top-0 left-0 right-0" : "sticky top-0";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={`${positionClass} z-50 flex h-16 shrink-0 items-center bg-primary`}
|
||||||
|
>
|
||||||
|
<div className="relative z-10 mx-auto flex h-full w-full max-w-8xl items-center gap-4 px-4">
|
||||||
|
{logoHref ? (
|
||||||
|
<Link href={logoHref} className="shrink-0">
|
||||||
|
<Logo variant="compact" invertTextOnDark={false} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Logo variant="compact" invertTextOnDark={false} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user