28 Commits

Author SHA1 Message Date
Felipe Coutinho
20d0c3e0a7 chore(docs): atualizar regra de versionamento no CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:56 +00:00
Felipe Coutinho
71b5a004e3 chore: ajustes de formatação e configuração
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:27 +00:00
Felipe Coutinho
65b1506d75 chore(release): publicar versão 2.1.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:23 +00:00
Felipe Coutinho
2a458d5a3c chore(configurações): redesign visual da página de configurações
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:47:19 +00:00
Felipe Coutinho
f418987f47 feat(lançamentos): escopo "period" na ação em lote e correção do fluxo de anexos em séries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:46:33 +00:00
Felipe Coutinho
59b4dea071 feat(preferências): configuração de tamanho máximo de anexo por arquivo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:46:28 +00:00
Felipe Coutinho
6ce132fe0c feat(db): adicionar coluna attachmentMaxSizeMb em userPreferences
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:45:41 +00:00
Felipe Coutinho
49731238e4 Update version badge from 2.1.0 to 2.1.1 2026-03-29 11:14:23 -03:00
Felipe Coutinho
c5df97f7aa chore(setup): reduzir logo e colocar nome ASCII lado a lado
Logo reduzido de 19 para 10 linhas (seleção dos frames-chave).
Nome do projeto em ASCII art posicionado ao lado direito do logo,
centralizado verticalmente. Tagline abaixo do bloco.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:05:53 +00:00
Felipe Coutinho
3476fda4db chore(setup): adicionar banner ASCII do logo e corrigir script db:extensions
Substitui o header simples pelo logo em ASCII art na cor primária
(laranja) com nome e tagline centralizados. Corrige chamada
db:enableExtensions → db:extensions após renomeio do script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:55:26 +00:00
Felipe Coutinho
519b673ae5 chore(release): publicar versão 2.1.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:21 +00:00
Felipe Coutinho
303b8bedd4 chore(config): limpeza de tsconfig.json e .vscode/settings.json
Reformata arrays no tsconfig para multi-line. Remove configurações
obsoletas do .vscode (explorerExclude.backup, eslint.enable,
typescript.preferences.organizeImportsCollation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:17 +00:00
Felipe Coutinho
f2b9b16896 chore(package): renomear scripts e remover dependências Vercel
Renomeia mockup→db:seed, db:enableExtensions→db:extensions e remove
o script dev-env. Remove @vercel/analytics e @vercel/speed-insights.
Atualiza README com o novo nome do script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:13 +00:00
Felipe Coutinho
6eba35542b chore(logo): remover prop showVersion e atualizar logo_small.png
Remove a prop showVersion do componente Logo e seu uso na sidebar.
Aplica iconFilterClass também no variant compact. Atualiza a imagem
logo_small.png.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:10 +00:00
Felipe Coutinho
f5e95ffba6 chore(analytics): substituir Vercel Analytics por Umami self-hosted
Remove @vercel/analytics e @vercel/speed-insights e adiciona o script
do Umami self-hosted no layout raiz, restrito ao domínio openmonetis.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:44:03 +00:00
Felipe Coutinho
a75bb86eec refactor(navbar): extrair NavbarShell e adicionar variante navbar no Button
Unifica a estrutura da navbar entre o app e a landing page via novo
componente NavbarShell. Centraliza estilos de botões da navbar na
variante `navbar` do Button, eliminando nav-styles.ts e as classes
inline duplicadas. AnimatedThemeToggler, RefreshPageButton e MobileNav
passam a aceitar prop `variant` para adaptar ao contexto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:43:59 +00:00
Felipe Coutinho
a3b858621f fix(transactions): preservar período salvo ao editar lançamento de cartão
No modal de edição, o período não era recalculado com base no fechamento
do cartão, garantindo que o valor salvo no banco seja sempre exibido.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 17:51:43 +00:00
Felipe Coutinho
fee2a2c9f5 fix(build): corrigir erros de tipo introduzidos pelo TypeScript 6.0
- Adiciona src/global.d.ts com declare module '*.css' para suportar
  side-effect imports de CSS com moduleResolution bundler
- Adiciona ignoreDeprecations "6.0" no tsconfig para silenciar aviso
  de depreciação do baseUrl (será removido no TS 7)
- Corrige cast de .message em better-auth 1.5.6, cujo tipo passou a
  ser string | RawError em chamadas de passkey

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:21:56 +00:00
Felipe Coutinho
839d7d0866 chore(release): publicar versão 2.1.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:47 +00:00
Felipe Coutinho
7cd7d95245 docs: atualizar README, .env.example e CLAUDE.md para a versão 2.1.0
Documenta variáveis S3 opcionais, instruções de self-hosting com anexos
e padrão de commit messages no guia do projeto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:44 +00:00
Felipe Coutinho
9bd762f7a3 chore(db): reorganizar migrations e adicionar tabelas de anexos
Consolida migrations anteriores e adiciona tabelas `anexos` e
`lancamento_anexos` com constraints de integridade referencial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:39 +00:00
Felipe Coutinho
9b76db4ce9 chore(deps): adicionar AWS SDK S3 e atualizar dependências
Adiciona @aws-sdk/client-s3 e @aws-sdk/s3-request-presigner para
suporte a anexos; atualiza ai-sdk, better-auth, drizzle-orm, recharts,
biome e typescript para versões mais recentes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:30 +00:00
Felipe Coutinho
91457b6490 chore(ci): adicionar workflow de release automático
Cria tag e GitHub Release a partir da versão do package.json e da
entrada correspondente no CHANGELOG.md ao fazer push na branch main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:26 +00:00
Felipe Coutinho
a0a71623d7 fix(ui): corrigir overflow do dialog e ícone de anexo nas categorias
Adiciona min-w-0 e overflow-x-hidden no DialogContent para evitar
expansão indevida; corrige referência do ícone RiAttachment2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:13 +00:00
Felipe Coutinho
00e624b8bc fix(lancamentos): bloquear criação em fatura já paga no cartão de crédito
Evita divergência no relatório de análise de parcelas ao impedir o
cadastro de lançamentos em períodos cujas faturas já foram quitadas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:10 +00:00
Felipe Coutinho
f82043127a feat(lancamentos): adicionar suporte a anexos com upload para storage S3
Permite vincular arquivos (PDF, imagens) a lançamentos via upload direto
para storage compatível com S3, usando token assinado por arquivo e
validação de propriedade na leitura e remoção.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:05 +00:00
Felipe Coutinho
32da4f906e fix(transactions): avoid crypto.randomUUID on initial load 2026-03-26 14:18:47 +00:00
Felipe Coutinho
0bd9d0ac47 chore(docker): simplify compose file for public self-hosting
Remove build step (use published image), strip verbose comments,
and inline pgcrypto init instead of external script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 19:16:56 +00:00
75 changed files with 9773 additions and 996 deletions

View File

@@ -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
View 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

View File

@@ -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"

View File

@@ -7,6 +7,64 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [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 ## [2.0.2] - 2026-03-25
### Adicionado ### Adicionado

View File

@@ -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.
--- ---

View File

@@ -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.
[![Version](https://img.shields.io/badge/version-2.0.0-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.1.1-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](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

View File

@@ -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

View 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";

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN "attachment_max_size_mb" integer DEFAULT 50 NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,167 +1,181 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1762993507299, "when": 1762993507299,
"tag": "0000_flashy_manta", "tag": "0000_flashy_manta",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "7",
"when": 1765199006435, "when": 1765199006435,
"tag": "0001_young_mister_fear", "tag": "0001_young_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "7", "version": "7",
"when": 1765200545692, "when": 1765200545692,
"tag": "0002_slimy_flatman", "tag": "0002_slimy_flatman",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "7", "version": "7",
"when": 1767102605526, "when": 1767102605526,
"tag": "0003_green_korg", "tag": "0003_green_korg",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 4, "idx": 4,
"version": "7", "version": "7",
"when": 1767104066872, "when": 1767104066872,
"tag": "0004_acoustic_mach_iv", "tag": "0004_acoustic_mach_iv",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 5, "idx": 5,
"version": "7", "version": "7",
"when": 1767106121811, "when": 1767106121811,
"tag": "0005_adorable_bruce_banner", "tag": "0005_adorable_bruce_banner",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 6, "idx": 6,
"version": "7", "version": "7",
"when": 1767107487318, "when": 1767107487318,
"tag": "0006_youthful_mister_fear", "tag": "0006_youthful_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 7, "idx": 7,
"version": "7", "version": "7",
"when": 1767118780033, "when": 1767118780033,
"tag": "0007_sturdy_kate_bishop", "tag": "0007_sturdy_kate_bishop",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "7", "version": "7",
"when": 1767125796314, "when": 1767125796314,
"tag": "0008_fat_stick", "tag": "0008_fat_stick",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 9, "idx": 9,
"version": "7", "version": "7",
"when": 1768925100873, "when": 1768925100873,
"tag": "0009_add_dashboard_widgets", "tag": "0009_add_dashboard_widgets",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 10, "idx": 10,
"version": "7", "version": "7",
"when": 1769369834242, "when": 1769369834242,
"tag": "0010_lame_psynapse", "tag": "0010_lame_psynapse",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 11, "idx": 11,
"version": "7", "version": "7",
"when": 1769447087678, "when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns", "tag": "0011_remove_unused_inbox_columns",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 12, "idx": 12,
"version": "7", "version": "7",
"when": 1769533200000, "when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese", "tag": "0012_rename_tables_to_portuguese",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 13, "idx": 13,
"version": "7", "version": "7",
"when": 1769523352777, "when": 1769523352777,
"tag": "0013_fancy_rick_jones", "tag": "0013_fancy_rick_jones",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 14, "idx": 14,
"version": "7", "version": "7",
"when": 1769619226903, "when": 1769619226903,
"tag": "0014_yielding_jack_flag", "tag": "0014_yielding_jack_flag",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 15, "idx": 15,
"version": "7", "version": "7",
"when": 1770332054481, "when": 1770332054481,
"tag": "0015_concerned_kat_farrell", "tag": "0015_concerned_kat_farrell",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 16, "idx": 16,
"version": "7", "version": "7",
"when": 1771166328908, "when": 1771166328908,
"tag": "0016_complete_randall", "tag": "0016_complete_randall",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 17, "idx": 17,
"version": "7", "version": "7",
"when": 1772400510326, "when": 1772400510326,
"tag": "0017_previous_warstar", "tag": "0017_previous_warstar",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 18, "idx": 18,
"version": "7", "version": "7",
"when": 1773020417482, "when": 1773020417482,
"tag": "0018_rainy_epoch", "tag": "0018_rainy_epoch",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 19, "idx": 19,
"version": "7", "version": "7",
"when": 1773699152928, "when": 1773699152928,
"tag": "0019_ordinary_wild_pack", "tag": "0019_ordinary_wild_pack",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 20, "idx": 20,
"version": "7", "version": "7",
"when": 1773841892114, "when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints", "tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 21, "idx": 21,
"version": "7", "version": "7",
"when": 1774033320053, "when": 1774033320053,
"tag": "0021_careful_malcolm_colcord", "tag": "0021_careful_malcolm_colcord",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 22, "idx": 22,
"version": "7", "version": "7",
"when": 1748000000000, "when": 1748000000000,
"tag": "0022_import-category-mappings", "tag": "0022_import-category-mappings",
"breakpoints": true "breakpoints": true
} },
] {
} "idx": 23,
"version": "7",
"when": 1774529878374,
"tag": "0023_sturdy_wolfpack",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
}
]
}

View File

@@ -1,11 +1,10 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.0.2", "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",
"mockup": "tsx scripts/mock-data.ts",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "biome check .", "lint": "biome check .",
@@ -14,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",
@@ -29,10 +28,12 @@
"backup": "bash scripts/backup.sh" "backup": "bash scripts/backup.sh"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.63", "@ai-sdk/anthropic": "^3.0.64",
"@ai-sdk/google": "^3.0.52", "@ai-sdk/google": "^3.0.53",
"@ai-sdk/openai": "^3.0.47", "@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",
@@ -59,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.134",
"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",
@@ -78,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",
@@ -87,7 +86,7 @@
"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",
@@ -98,6 +97,6 @@
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"tailwindcss": "4.2.2", "tailwindcss": "4.2.2",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "5.9.3" "typescript": "6.0.2"
} }
} }

1650
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -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");

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
); );

View File

@@ -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}

View File

@@ -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">
<CompanionTab tokens={userApiTokens} /> <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} />
</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>

View File

@@ -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>
); );

View File

@@ -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,65 +50,60 @@ 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"> {/* Center Navigation Links */}
<Logo variant="compact" invertTextOnDark={false} /> <nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
{navLinks.map(({ href, label }) => (
<a
key={href}
href={href}
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors"
>
{label}
</a>
))}
</nav>
{/* Center Navigation Links */} <nav className="ml-auto flex items-center gap-2 md:gap-3">
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2"> <AnimatedThemeToggler variant="navbar" />
{navLinks.map(({ href, label }) => ( {!isPublicDomain &&
<a (session?.user ? (
key={href} <Link prefetch href="/dashboard" className="hidden md:block">
href={href} <Button
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors" variant="outline"
> size="sm"
{label} className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none"
</a> >
))} Dashboard
</nav> </Button>
</Link>
<nav className="flex items-center gap-2 md:gap-3"> ) : (
<AnimatedThemeToggler className={navbarActionClassName} /> <div className="hidden md:flex items-center gap-2">
{!isPublicDomain && <Link href="/login">
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none" className="text-black/75 hover:bg-black/10 hover:text-black shadow-none"
> >
Dashboard Entrar
</Button> </Button>
</Link> </Link>
) : ( <Link href="/signup">
<div className="hidden md:flex items-center gap-2"> <Button
<Link href="/login"> size="sm"
<Button className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2"
variant="ghost" >
size="sm" Começar
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none" </Button>
> </Link>
Entrar </div>
</Button> ))}
</Link> <MobileNav
<Link href="/signup"> isPublicDomain={isPublicDomain}
<Button isLoggedIn={!!session?.user}
size="sm" />
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2" </nav>
> </NavbarShell>
Começar
</Button>
</Link>
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
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>
</div>
</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">

View File

@@ -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";
@@ -23,19 +21,23 @@ export default function RootLayout({
return ( return (
<html <html
lang="pt-BR" lang="pt-BR"
className={`${america.variable} ${america.className}`} className={`${america.variable} ${america.className} `}
suppressHydrationWarning suppressHydrationWarning
> >
<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>
); );

View File

@@ -135,6 +135,7 @@ export const userPreferences = pgTable("preferencias_usuario", {
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[];
@@ -847,32 +848,36 @@ export const inboxItemsRelations = relations(inboxItems, ({ one }) => ({
}), }),
})); }));
export const transactionsRelations = relations(transactions, ({ one }) => ({ export const transactionsRelations = relations(
user: one(user, { transactions,
fields: [transactions.userId], ({ one, many }) => ({
references: [user.id], user: one(user, {
fields: [transactions.userId],
references: [user.id],
}),
card: one(cards, {
fields: [transactions.cardId],
references: [cards.id],
}),
financialAccount: one(financialAccounts, {
fields: [transactions.accountId],
references: [financialAccounts.id],
}),
category: one(categories, {
fields: [transactions.categoryId],
references: [categories.id],
}),
payer: one(payers, {
fields: [transactions.payerId],
references: [payers.id],
}),
anticipation: one(installmentAnticipations, {
fields: [transactions.anticipationId],
references: [installmentAnticipations.id],
}),
transactionAttachments: many(transactionAttachments),
}), }),
card: one(cards, { );
fields: [transactions.cardId],
references: [cards.id],
}),
financialAccount: one(financialAccounts, {
fields: [transactions.accountId],
references: [financialAccounts.id],
}),
category: one(categories, {
fields: [transactions.categoryId],
references: [categories.id],
}),
payer: one(payers, {
fields: [transactions.payerId],
references: [payers.id],
}),
anticipation: one(installmentAnticipations, {
fields: [transactions.anticipationId],
references: [installmentAnticipations.id],
}),
}));
export const installmentAnticipationsRelations = relations( export const installmentAnticipationsRelations = relations(
installmentAnticipations, installmentAnticipations,
@@ -896,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",
{ {
@@ -939,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;

View File

@@ -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);
} }
} }

View File

@@ -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>

View File

@@ -35,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" },

View File

@@ -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,

View File

@@ -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,
}); });
} }

View File

@@ -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,49 +67,28 @@ 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"> {/* Steps */}
{/* Header */} <div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
<div> {steps.map((step, index) => (
<div className="flex items-center gap-2 mb-1"> <div key={step.title} className="flex items-start gap-2">
<h2 className="text-lg font-bold">OpenMonetis Companion</h2> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<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"> <step.icon className="h-4 w-4" />
<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 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
{steps.map((step, index) => (
<div key={step.title} className="flex items-start gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<step.icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">
{index + 1}. {step.title}
</p>
<p className="text-xs text-muted-foreground">
{step.description}
</p>
</div>
</div> </div>
))} <div className="min-w-0">
</div> <p className="text-sm font-medium leading-tight">
{index + 1}. {step.title}
{/* Divider */} </p>
<div className="border-t" /> <p className="text-xs text-muted-foreground">
{step.description}
{/* Devices */} </p>
<ApiTokensForm tokens={tokens} /> </div>
</div>
))}
</div> </div>
</Card>
{/* Devices */}
<ApiTokensForm tokens={tokens} />
</div>
); );
} }

View File

@@ -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);

View File

@@ -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,7 +194,43 @@ export function PreferencesForm({
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</div> </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>
</section>
</section> </section>
<div className="flex justify-end"> <div className="flex justify-end">

View File

@@ -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))

View 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));
}
}
}

View File

@@ -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,7 +188,8 @@ 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 }), ...(data.isSettled !== undefined && { isSettled: data.isSettled }),
@@ -309,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: {
@@ -319,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),
}); });
@@ -346,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),
}); });

View File

@@ -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

View File

@@ -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([
{ {

View 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];

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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,57 +79,18 @@ 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">
<DetailRow <div className="flex items-start justify-between gap-3">
label="Período" <div className="min-w-0">
value={formatPeriod(transaction.period)} <p className="text-xs uppercase tracking-wide text-muted-foreground">
/> Resumo
</p>
<li className="flex items-center justify-between"> <p className="mt-1 text-2xl font-semibold">
<span className="text-muted-foreground"> {currencyFormatter.format(valorTotal)}
Forma de Pagamento </p>
</span> </div>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(transaction.paymentMethod)}
<span>{transaction.paymentMethod}</span>
</span>
</li>
<DetailRow
label={transaction.cartaoName ? "Cartão" : "Conta"}
value={transaction.cartaoName ?? transaction.contaName ?? "—"}
/>
<DetailRow
label="Categoria"
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">
<span className="text-muted-foreground">Responsável</span>
<span>{transaction.pagadorName}</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Status</span>
<Badge <Badge
variant="secondary" variant="secondary"
className={ className={
@@ -132,75 +101,139 @@ export function TransactionDetailsDialog({
> >
{transaction.isSettled ? "Pago" : "Pendente"} {transaction.isSettled ? "Pago" : "Pendente"}
</Badge> </Badge>
</li> </div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{isBoleto && transaction.dueDate && ( <TransactionTypeBadge
<DetailRow kind={
label="Vencimento" transaction.categoriaName === "Saldo inicial"
value={formatDate(transaction.dueDate)} ? "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
label="Período"
value={formatPeriod(transaction.period)}
/> />
)}
{transaction.isDivided && (
<li className="flex items-center justify-between"> <li className="flex items-center justify-between">
<span className="text-muted-foreground">Divisão</span> <span className="text-muted-foreground">
<Badge variant="outline">Dividido</Badge> Forma de Pagamento
</span>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(transaction.paymentMethod)}
<span>{transaction.paymentMethod}</span>
</span>
</li> </li>
)}
{transaction.note && ( <DetailRow
<li className="flex flex-col gap-1"> label={transaction.cartaoName ? "Cartão" : "Conta"}
<span className="text-muted-foreground">Notas</span> value={transaction.cartaoName ?? transaction.contaName ?? "—"}
<span className="text-foreground">{transaction.note}</span> />
<DetailRow
label="Categoria"
value={transaction.categoriaName ?? "—"}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span>
<span>{transaction.pagadorName}</span>
</li> </li>
)}
</ul>
<ul className="mb-2 grid gap-3"> {isBoleto && transaction.dueDate && (
{isInstallment && ( <DetailRow
<li className="mt-4"> label="Vencimento"
<InstallmentTimeline value={formatDate(transaction.dueDate)}
purchaseDate={parseLocalDateString(
transaction.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={transaction.period}
/> />
</li> )}
)}
<DetailRow {transaction.isDivided && (
label={isInstallment ? "Valor da Parcela" : "Valor"} <li className="flex items-center justify-between">
value={currencyFormatter.format(valorParcela)} <span className="text-muted-foreground">Divisão</span>
/> <Badge variant="outline">Dividido</Badge>
</li>
)}
</ul>
</section>
<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 && (
<li className="mb-1">
<InstallmentTimeline
purchaseDate={parseLocalDateString(
transaction.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={transaction.period}
/>
</li>
)}
{isInstallment && (
<DetailRow <DetailRow
label="Valor Restante" label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorRestante)} value={currencyFormatter.format(valorParcela)}
/> />
)}
{transaction.recurrenceCount && ( {isInstallment && (
<DetailRow <DetailRow
label="Quantidade de Recorrências" label="Valor Restante"
value={`${transaction.recurrenceCount} meses`} value={currencyFormatter.format(valorRestante)}
/> />
)} )}
{!isInstallment && <Separator className="my-2" />} {transaction.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${transaction.recurrenceCount} meses`}
/>
)}
</ul>
</section>
<li className="flex items-center justify-between font-semibold"> {transaction.note ? (
<span className="text-muted-foreground">Total da Compra</span> <section className="space-y-2">
<span className="text-lg"> <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{currencyFormatter.format(valorTotal)} Notas
</span> </h3>
</li> <div className="rounded-lg border p-3 text-foreground">
</ul> {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>
); );
} }

View File

@@ -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"

View File

@@ -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;
@@ -42,6 +44,8 @@ export interface TransactionDialogProps {
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null; isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
}) => void; }) => void;
} }

View File

@@ -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(),
@@ -350,11 +389,13 @@ export function TransactionDialog({
formState.paymentMethod === "Cartão de crédito" formState.paymentMethod === "Cartão de crédito"
? null ? null
: Boolean(formState.isSettled), : 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,
@@ -363,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);
@@ -415,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}
@@ -477,17 +543,64 @@ export function TransactionDialog({
) : null} ) : null}
{isUpdateMode ? ( {isUpdateMode ? (
<NoteSection <>
formState={formState} <NoteSection
onFieldChange={handleFieldChange} formState={formState}
/> 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}
@@ -498,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>
)} )}

View File

@@ -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,
@@ -130,6 +137,8 @@ export function TransactionsPage({
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null; isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
transaction: TransactionItem; transaction: TransactionItem;
} | null>(null); } | null>(null);
const [pendingDeleteData, setPendingDeleteData] = const [pendingDeleteData, setPendingDeleteData] =
@@ -246,6 +255,8 @@ export function TransactionsPage({
dueDate: string | null; dueDate: string | null;
boletoPaymentDate: string | null; boletoPaymentDate: string | null;
isSettled: boolean | null; isSettled: boolean | null;
pendingDetachIds: string[];
pendingUploadFiles: File[];
}) => { }) => {
if (!selectedTransaction) { if (!selectedTransaction) {
return; return;
@@ -284,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);
@@ -438,6 +479,7 @@ export function TransactionsPage({
lockCardSelection={lockCardSelection} lockCardSelection={lockCardSelection}
lockPaymentMethod={lockPaymentMethod} lockPaymentMethod={lockPaymentMethod}
defaultTransactionType={transactionTypeForCreate ?? undefined} defaultTransactionType={transactionTypeForCreate ?? undefined}
maxSizeMb={attachmentMaxSizeMb}
/> />
) : null} ) : null}
@@ -459,6 +501,7 @@ export function TransactionsPage({
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
transaction={transactionToCopy ?? undefined} transaction={transactionToCopy ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
maxSizeMb={attachmentMaxSizeMb}
/> />
<TransactionDialog <TransactionDialog
@@ -480,6 +523,7 @@ export function TransactionsPage({
transaction={transactionToImport ?? undefined} transaction={transactionToImport ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
isImporting={true} isImporting={true}
maxSizeMb={attachmentMaxSizeMb}
/> />
<BulkImportDialog <BulkImportDialog
@@ -507,6 +551,7 @@ export function TransactionsPage({
transaction={selectedTransaction ?? undefined} transaction={selectedTransaction ?? undefined}
defaultPeriod={selectedPeriod} defaultPeriod={selectedPeriod}
onBulkEditRequest={handleBulkEditRequest} onBulkEditRequest={handleBulkEditRequest}
maxSizeMb={attachmentMaxSizeMb}
/> />
<TransactionDetailsDialog <TransactionDetailsDialog
@@ -575,7 +620,7 @@ export function TransactionsPage({
onConfirm={handleBulkEdit} onConfirm={handleBulkEdit}
/> />
{allowCreate ? ( {allowCreate && massAddOpen ? (
<MassAddDialog <MassAddDialog
open={massAddOpen} open={massAddOpen}
onOpenChange={setMassAddOpen} onOpenChange={setMassAddOpen}

View File

@@ -6,6 +6,7 @@ import {
RiArrowLeftSLine, RiArrowLeftSLine,
RiArrowRightDoubleLine, RiArrowRightDoubleLine,
RiArrowRightSLine, RiArrowRightSLine,
RiAttachment2,
RiBankCard2Line, RiBankCard2Line,
RiChat1Line, RiChat1Line,
RiCheckboxBlankCircleLine, RiCheckboxBlankCircleLine,
@@ -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,106 +211,126 @@ 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>
<Tooltip> <span className="flex items-center gap-1">
<TooltipTrigger asChild> <Tooltip>
<span className="line-clamp-2 max-w-[160px] font-semibold truncate"> <TooltipTrigger asChild>
<span className="line-clamp-2 max-w-[180px] font-bold truncate">
{name}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{name} {name}
</span> </TooltipContent>
</TooltipTrigger> </Tooltip>
<TooltipContent side="top" className="max-w-xs">
{name} {isDivided && (
</TooltipContent> <Tooltip>
</Tooltip> <TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiGroupLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">
Dividido entre pagadores
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Dividido entre pagadores
</TooltipContent>
</Tooltip>
)}
{isLastInstallment ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icons/party.svg"
alt="Última parcela"
width={16}
height={16}
className="h-4 w-4"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
{installmentBadge ? (
<Badge variant="outline" className="px-2 text-xs">
{installmentBadge}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiTimeLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Parcela antecipada</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Parcela antecipada
</TooltipContent>
</Tooltip>
)}
{!noteAsColumn && hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-accent transition-colors duration-300">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{note}
</TooltipContent>
</Tooltip>
) : 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>
{isDivided && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiGroupLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Dividido entre pagadores</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Dividido entre pagadores
</TooltipContent>
</Tooltip>
)}
{isLastInstallment ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icons/party.svg"
alt="Última parcela"
width={16}
height={16}
className="h-4 w-4"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
{installmentBadge ? (
<Badge variant="outline" className="px-2 text-xs">
{installmentBadge}
</Badge>
) : null}
{dueDateLabel ? (
<Badge variant="outline" className="px-2 text-xs">
{dueDateLabel}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiTimeLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Parcela antecipada</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Parcela antecipada</TooltipContent>
</Tooltip>
)}
{!noteAsColumn && hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-accent transition-colors duration-300">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{note}
</TooltipContent>
</Tooltip>
) : null}
</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>
); );
}, },

View File

@@ -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;
}; };

View File

@@ -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" ||

View File

@@ -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";
@@ -74,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) => ({
@@ -82,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({
@@ -98,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
View File

@@ -0,0 +1 @@
declare module "*.css";

View File

@@ -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}

View File

@@ -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>
); );
} }

View File

@@ -1,10 +1,9 @@
import Link from "next/link";
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 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,28 +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"> <NavMenu />
<Link href="/dashboard" className="shrink-0 mr-1"> <div className="ml-auto flex items-center gap-2">
<Logo variant="compact" invertTextOnDark={false} /> <NotificationBell
</Link> notifications={notificationsSnapshot.notifications}
unreadCount={notificationsSnapshot.unreadCount}
<NavMenu /> visibleCount={notificationsSnapshot.visibleCount}
budgetNotifications={notificationsSnapshot.budgetNotifications}
<div className="ml-auto flex items-center gap-2"> preLancamentosCount={preLancamentosCount}
<NotificationBell />
notifications={notificationsSnapshot.notifications} <RefreshPageButton variant="navbar" />
unreadCount={notificationsSnapshot.unreadCount} <AnimatedThemeToggler variant="navbar" />
visibleCount={notificationsSnapshot.visibleCount}
budgetNotifications={notificationsSnapshot.budgetNotifications}
preLancamentosCount={preLancamentosCount}
/>
<RefreshPageButton className={navbarActionClassName} />
<AnimatedThemeToggler className={navbarActionClassName} />
</div>
<NavbarUser user={user} pagadorAvatarUrl={pagadorAvatarUrl} />
</div> </div>
</header> <NavbarUser user={user} pagadorAvatarUrl={pagadorAvatarUrl} />
</NavbarShell>
); );
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(" ");

View 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>
);
}

View File

@@ -77,7 +77,5 @@ function LogoContent() {
const { state } = useSidebar(); const { state } = useSidebar();
const isCollapsed = state === "collapsed"; const isCollapsed = state === "collapsed";
return ( return <Logo variant={isCollapsed ? "small" : "full"} />;
<Logo variant={isCollapsed ? "small" : "full"} showVersion={!isCollapsed} />
);
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { RiRefreshLine } from "@remixicon/react"; import { RiRefreshLine } from "@remixicon/react";
import type { VariantProps } from "class-variance-authority";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTransition } from "react"; import { useTransition } from "react";
import { buttonVariants } from "@/shared/components/ui/button"; import { buttonVariants } from "@/shared/components/ui/button";
@@ -11,10 +12,12 @@ import {
} from "@/shared/components/ui/tooltip"; } from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
type RefreshPageButtonProps = React.ComponentPropsWithoutRef<"button">; type RefreshPageButtonProps = React.ComponentPropsWithoutRef<"button"> &
Pick<VariantProps<typeof buttonVariants>, "variant">;
export function RefreshPageButton({ export function RefreshPageButton({
className, className,
variant = "ghost",
...props ...props
}: RefreshPageButtonProps) { }: RefreshPageButtonProps) {
const router = useRouter(); const router = useRouter();
@@ -36,10 +39,10 @@ export function RefreshPageButton({
aria-label="Atualizar página" aria-label="Atualizar página"
title="Atualizar página" title="Atualizar página"
className={cn( className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }), buttonVariants({ variant, size: "icon-sm" }),
"size-8 text-muted-foreground transition-all duration-200", "transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40", variant === "ghost" &&
"disabled:pointer-events-none disabled:opacity-50", "text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
className, className,
)} )}
{...props} {...props}

View File

@@ -19,6 +19,8 @@ const buttonVariants = cva(
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
navbar:
"bg-transparent text-black/75 shadow-none hover:bg-black/10 hover:text-black focus-visible:ring-black/20",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",

View File

@@ -56,7 +56,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg sm:p-10 sm:max-w-xl", "bg-background fixed top-[50%] left-[50%] z-50 grid w-full min-w-0 max-w-[calc(100%-2rem)] max-h-[90vh] overflow-x-hidden overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg sm:p-10 sm:max-w-xl",
className, className,
)} )}
{...props} {...props}

View File

@@ -149,7 +149,7 @@ export const CATEGORY_ICON_OPTIONS: CategoryIconOption[] = [
{ label: "Chave", value: "RiKeyLine" }, { label: "Chave", value: "RiKeyLine" },
{ label: "Configurações", value: "RiSettings3Line" }, { label: "Configurações", value: "RiSettings3Line" },
{ label: "Link", value: "RiLinkLine" }, { label: "Link", value: "RiLinkLine" },
{ label: "Anexo", value: "RiAttachmentLine" }, { label: "Anexo", value: "RiAttachment2" },
{ label: "Download", value: "RiDownloadLine" }, { label: "Download", value: "RiDownloadLine" },
{ label: "Upload", value: "RiUploadLine" }, { label: "Upload", value: "RiUploadLine" },
{ label: "Nuvem Download", value: "RiCloudDownloadLine" }, { label: "Nuvem Download", value: "RiCloudDownloadLine" },
@@ -327,7 +327,7 @@ export const CATEGORY_ICON_GROUPS: CategoryIconGroup[] = [
{ label: "Chave", value: "RiKeyLine" }, { label: "Chave", value: "RiKeyLine" },
{ label: "Configurações", value: "RiSettings3Line" }, { label: "Configurações", value: "RiSettings3Line" },
{ label: "Link", value: "RiLinkLine" }, { label: "Link", value: "RiLinkLine" },
{ label: "Anexo", value: "RiAttachmentLine" }, { label: "Anexo", value: "RiAttachment2" },
{ label: "Download", value: "RiDownloadLine" }, { label: "Download", value: "RiDownloadLine" },
{ label: "Upload", value: "RiUploadLine" }, { label: "Upload", value: "RiUploadLine" },
{ label: "Nuvem Download", value: "RiCloudDownloadLine" }, { label: "Nuvem Download", value: "RiCloudDownloadLine" },

View File

@@ -0,0 +1,52 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { S3_BUCKET, s3 } from "./s3-client";
export async function createPresignedPutUrl(
fileKey: string,
mimeType: string,
): Promise<string> {
const command = new PutObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
ContentType: mimeType,
});
return getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutos
}
export async function createPresignedGetUrl(fileKey: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
});
return getSignedUrl(s3, command, { expiresIn: 3600 }); // 1 hora
}
export async function headS3Object(fileKey: string): Promise<{
contentLength: number | null;
contentType: string | null;
}> {
const command = new HeadObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
});
const result = await s3.send(command);
return {
contentLength: result.ContentLength ?? null,
contentType: result.ContentType ?? null,
};
}
export async function deleteS3Object(fileKey: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: S3_BUCKET,
Key: fileKey,
});
await s3.send(command);
}

View File

@@ -0,0 +1,13 @@
import { S3Client } from "@aws-sdk/client-s3";
export const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT ?? "",
region: process.env.S3_REGION ?? "auto",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
},
forcePathStyle: true,
});
export const S3_BUCKET = process.env.S3_BUCKET ?? "attachments";

37
src/shared/utils/id.ts Normal file
View File

@@ -0,0 +1,37 @@
const FALLBACK_HEX_RADIX = 16;
function randomHex(byteCount: number) {
const cryptoApi = globalThis.crypto;
if (cryptoApi?.getRandomValues) {
return Array.from(cryptoApi.getRandomValues(new Uint8Array(byteCount)))
.map((byte) => byte.toString(FALLBACK_HEX_RADIX).padStart(2, "0"))
.join("");
}
let hex = "";
for (let index = 0; index < byteCount; index += 1) {
hex += Math.floor(Math.random() * 256)
.toString(FALLBACK_HEX_RADIX)
.padStart(2, "0");
}
return hex;
}
export function createClientSafeId() {
const cryptoApi = globalThis.crypto;
if (cryptoApi?.randomUUID) {
return cryptoApi.randomUUID();
}
return [
randomHex(4),
randomHex(2),
randomHex(2),
randomHex(2),
randomHex(6),
].join("-");
}

View File

@@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": ".", "baseUrl": ".",
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],