52 Commits

Author SHA1 Message Date
Felipe Coutinho
a09942e3d8 chore(release): preparar changelog da versão 2.3.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:46:47 +00:00
Felipe Coutinho
96febd5904 fix(docker): separar deps drizzle do node_modules standalone
O pnpm install no Stage 3 sobrescrevia o node_modules copiado do
.next/standalone, removendo o modulo next e quebrando o startup.

Agora as deps do drizzle-kit sao instaladas em /app/migrate/ antes
de copiar o standalone, mantendo os dois node_modules isolados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:45:48 +00:00
Felipe Coutinho
c3cfbc878c fix(tipografia): ajustar display da fonte america 2026-04-03 18:11:56 +00:00
Felipe Coutinho
55bbfabe9f chore(release): preparar changelog da versão 2.3.0 2026-04-03 18:11:34 +00:00
Felipe Coutinho
f5cdae4853 fix(ui): remover avisos visuais e destacar atualizações 2026-04-03 18:11:30 +00:00
Felipe Coutinho
5c4995961c refactor(lista): componentizar inbox e tabela de lançamentos 2026-04-03 18:10:58 +00:00
Felipe Coutinho
1b4dfaaba7 fix(lançamentos): reforçar validações e revisar formulário 2026-04-03 18:10:50 +00:00
Felipe Coutinho
549a5bdba1 fix(financeiro): alinhar saldo, métricas e relatórios 2026-04-03 18:10:43 +00:00
Felipe Coutinho
acaf9d5c27 feat(dados-client): adotar react query em leituras do app 2026-04-03 18:10:34 +00:00
Felipe Coutinho
e4c6a91350 fix(segurança): endurecer autenticação e rotas privadas 2026-04-03 18:10:23 +00:00
Felipe Coutinho
ba369e8a83 chore(infra): atualizar build, docker e tooling 2026-04-03 18:10:16 +00:00
Felipe Coutinho
d01bc8a669 fix(docker): remove chown recursivo da imagem final 2026-04-01 17:15:06 +00:00
Felipe Coutinho
e024e0d54e fix(docker): cria pasta public antes do pnpm install
O postinstall do pdfjs-dist tenta copiar pdf.worker.min.mjs para
public/, mas no stage deps do Dockerfile a pasta não existia.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:49:00 +00:00
Felipe Coutinho
c44089169f style: troca subpixel-antialiased por antialiased no body
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:35:16 +00:00
Felipe Coutinho
d04e30e3c9 fix(robots): remove estático duplicado, corrige robots.ts e llms.txt
Remove `public/robots.txt` que era ignorado pelo Next.js em favor do
`src/app/robots.ts` (Metadata API). Adiciona `/signup` à lista de
rotas bloqueadas e remove referência ao `sitemap.xml` inexistente.

Restaura o link `/robots.txt` no `llms.txt`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:31:37 +00:00
Felipe Coutinho
229b6c5bc0 chore: adiciona robots.txt bloqueando rotas privadas do app
Permite indexação apenas da landing page e do llms.txt. Bloqueia todas
as rotas autenticadas (dashboard, transações, contas, cartões, etc.)
e a API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:29:39 +00:00
Felipe Coutinho
c3b133d8d9 docs(llms.txt): adiciona stack técnico e remove links inexistentes
Inclui stack no cabeçalho do arquivo e remove referências a AGENTS.md,
robots.txt e sitemap.xml que não existem no repositório.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:24:04 +00:00
Felipe Coutinho
e9a2ab1782 chore(release): publicar versão 2.2.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:18:55 +00:00
Felipe Coutinho
c7d6e23398 chore: atualiza biome, CLAUDE.md, llms.txt e corrige optional chaining
- biome.json: schema atualizado para 2.4.9
- public/llms.txt: novo arquivo de documentação pública do projeto
- CLAUDE.md: ajustes menores de documentação interna
- invoices-queries.ts: usa optional chaining `?.startsWith` no lugar de
  verificação dupla de nullish
- CHANGELOG.md: documentadas as mudanças do ciclo atual em [Unreleased]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:15:03 +00:00
Felipe Coutinho
0514efb1c4 style(tipografia): adiciona fonte America Medium e padroniza pesos de texto
Adiciona os arquivos `america-medium.woff2` e `america-bold.woff2` e
registra o weight 500 no `font_index.ts`.

Padroniza o uso de `font-medium` em substituição a `font-semibold` e
`font-bold` em títulos, valores monetários e rótulos de destaque em
todos os componentes do app, landing page e componentes de UI base.

`Card` ganha `hover:border-primary/40` e `CardTitle` recebe `text-base`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:14:55 +00:00
Felipe Coutinho
e32fb85006 perf(cache): migração para diretiva use cache do Next.js
Todas as queries cacheadas do dashboard migram de `unstable_cache` para
a diretiva `use cache` com `cacheTag` e `cacheLife({ revalidate: 3 })`.

Todas as páginas e o layout do dashboard passam a chamar `connection()`
para garantir renderização dinâmica. O root layout envolve os filhos em
`<Suspense>`. `next.config.ts` remove `turbopackFileSystemCacheForDev`
e adota `cacheComponents: true`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:14:23 +00:00
Felipe Coutinho
96df6a1798 feat(notificações): alertas de vencimento para o período seguinte
Boletos e faturas do próximo período com vencimento dentro de 5 dias
agora geram notificações do tipo `due_soon`, evitando duplicatas com
notificações já existentes do período corrente.

A query de boletos passa a filtrar pela data de vencimento não nula e
limita a janela de busca a 12 meses anteriores ao período atual.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:14:07 +00:00
Felipe Coutinho
1f8a97bd16 feat(auth): redesign visual das páginas de autenticação
O sidebar de autenticação ganha mockup animado de faturas e três itens
de funcionalidade no rodapé, substituindo o texto descritivo anterior.

As páginas de login e cadastro recebem gradiente decorativo de fundo e
exibem o logo no topo em viewports mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:14:01 +00:00
Felipe Coutinho
0ab3298cef feat(anexos): página de galeria de comprovantes e documentos
Adiciona rota `/attachments` com visualização de todos os anexos do
usuário em grade, visualização inline de imagem e PDF, navegação entre
arquivos do mesmo lançamento e download direto.

Inclui também:
- API REST em `/api/attachments` para servir os arquivos
- Actions `fetch-by-id` e `fetch-dialog-options` em transactions
- Item "Anexos" adicionado à navbar
- `formatBytes` extraído para `src/shared/utils/number.ts`
- Migrations de banco atualizadas
- Fix: uploads e remoções de anexo agora funcionam para todos os
  lançamentos, não apenas os pertencentes a séries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:13:54 +00:00
Felipe Coutinho
cad41680eb feat(pdf): adiciona suporte a visualização de PDF nos anexos
Inclui `pdfjs-dist` como dependência e configura o script `postinstall`
para copiar o web worker necessário para `public/pdf.worker.min.mjs`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:13:39 +00:00
Felipe Coutinho
3b00f328c5 Update version badge to 2.1.2 2026-03-30 15:49:54 -03:00
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
263 changed files with 16039 additions and 3348 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=

View File

@@ -1,58 +0,0 @@
# AI Coding Assistant Instructions for OpenMonetis
## Project Overview
OpenMonetis is a self-hosted personal finance management application built with Next.js 16, TypeScript, PostgreSQL, and Drizzle ORM. It provides manual transaction tracking, account management, budgeting, and financial insights with a Portuguese interface.
## Architecture
- **Frontend**: Next.js App Router with React 19, shadcn/ui components, Tailwind CSS
- **Backend**: Server actions in Next.js, API routes for auth/health
- **Database**: PostgreSQL with Drizzle ORM, schema in `db/schema.ts`
- **Auth**: Better Auth (OAuth + email magic links)
- **Deployment**: Docker multi-stage build, health checks
## Key Patterns
- **Server Actions**: Use `"use server"` for mutations, validate with Zod schemas, handle errors with `handleActionError`
- **Database Queries**: Use Drizzle's query API with relations, e.g., `db.query.lancamentos.findMany({ with: { categoria: true } })`
- **Authentication**: Import from `lib/auth/server`, redirect on failure
- **Revalidation**: Call `revalidateForEntity("lancamentos")` after mutations
- **Portuguese Naming**: DB fields like `nome`, `tipo_conta`, `pagador` (payer), `lancamento` (transaction)
- **Component Structure**: Feature-based folders in `components/`, shared UI in `components/ui/`
## Development Workflow
- **Start Dev**: `pnpm dev` (Turbopack), `docker compose up db -d` for DB
- **Database**: `pnpm db:push` to sync schema, `pnpm db:studio` for visual editor
- **Build**: `pnpm build`, `pnpm start` for production
- **Docker**: `pnpm docker:up` for full stack, `pnpm docker:logs` for monitoring
## Common Tasks
- **Add Transaction**: Create server action in `app/(dashboard)/lancamentos/actions.ts`, validate with Zod, insert via Drizzle
- **New Entity**: Add to `db/schema.ts`, define relations, create CRUD actions in `lib/[entity]/actions.ts`
- **UI Component**: Use shadcn/ui, place in `components/[feature]/`, export from `components/ui/`
- **API Route**: Add to `app/api/`, use `getUserSession()` for auth
## Conventions
- **Imports**: Absolute paths with `@/`, group by external/internal
- **Error Handling**: Return `{ success: false, error: string }` from actions
- **Currency**: Store as decimal strings (e.g., "123.45"), convert to cents for calculations
- **Periods**: Format as "YYYY-MM", use `parsePeriodParam()` for URL params
- **Notifications**: Send emails via `sendPagadorAutoEmails()` for payer updates
## External Integrations
- **Better Auth**: Config in `lib/auth/config.ts`, session handling
- **Drizzle**: Migrations in `drizzle/`, studio at `pnpm db:studio`
- **AI Features**: Use `@ai-sdk/*` for insights, configured in environment
- **Email**: Resend for notifications, configured via `RESEND_API_KEY`
## File Examples
- Schema: `db/schema.ts` (relations, indexes)
- Actions: `app/(dashboard)/lancamentos/actions.ts` (CRUD with validation)
- Components: `components/lancamentos/page/lancamentos-page.tsx` (client component)
- Utils: `lib/lancamentos/page-helpers.ts` (data transformation)

View File

@@ -15,8 +15,32 @@ env:
DOCKER_IMAGE_NAME: openmonetis DOCKER_IMAGE_NAME: openmonetis
jobs: jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: quality
permissions: permissions:
contents: read contents: read
packages: write packages: write

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,125 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [Unreleased]
## [2.3.1] - 2026-04-03
### Corrigido
- Infraestrutura: deps do drizzle-kit agora são instaladas em `/app/migrate/` separado do `node_modules` do standalone, corrigindo erro `Cannot find module 'next'` no startup do container
## [2.3.0] - 2026-04-03
### Adicionado
- Dependências: adiciona `@tanstack/react-query` e um provider global para padronizar cache, deduplicação e invalidação de leituras client-side
- Dashboard: widget "Minhas Contas" ganha preferência persistida para mostrar ou ocultar contas marcadas como não consideradas no saldo total
- Dashboard: cards de métricas ganham botão de ajuda com explicação do cálculo exibido no app
- Versionamento: menu do usuário na navbar passa a avisar quando existe release mais recente publicada no GitHub
- Qualidade: adiciona `knip` ao projeto com o script `pnpm run lint:deadcode` para auditar arquivos, exports e tipos sem uso
- Infraestrutura: imagem Docker passa a rodar migrations automaticamente via `docker-entrypoint.sh` antes de iniciar a aplicação
### Alterado
- Anexos: listagem no modal de edição/detalhes, URLs temporárias da galeria e preview deixam de depender de `useEffect` para data fetching direto no componente e passam a usar React Query sobre rotas GET dedicadas
- Insights: carregamento de análises salvas passa a usar React Query com cache por período, mantendo estado draft local apenas para análises recém-geradas ou removidas
- Parcelamentos: histórico de antecipações no diálogo passa a usar React Query com invalidação automática após cancelamento
- Dashboard, insights e relatórios passam a excluir movimentações de contas marcadas como não consideradas no saldo total; balanço e previsto também passam a considerar ajustes de transferências entre contas consideradas e não consideradas
- UX: boletos e faturas passam a exibir labels relativas como "vence hoje", "vence amanhã" e "pago ontem", com tooltip para a data completa
- Lançamentos: diálogo foi reorganizado em blocos mais claros; a criação passa a aceitar múltiplos anexos e a edição em lote preserva `purchaseDate` e `period` ao propagar alterações por série
- Inbox e tabela de lançamentos foram componentizados em partes menores, mantendo paginação e ações em lote mais simples de evoluir
- Infraestrutura: workflow de publish ganha etapa obrigatória de qualidade; `docker-compose` passa a suportar perfil local ou banco remoto; build fixa `pnpm@10.33.0`; projeto atualizado para `Next.js 16.2.2`, `Biome 2.4.10` e dependências correlatas
- Qualidade: `knip` ganha configuração inicial para reduzir falsos positivos, ignorando `src/shared/components/ui/**`, o worker público de PDF, `setup.mjs` e o falso positivo de `postcss`
### Corrigido
- Segurança: criação de antecipações agora valida se `payerId` e `categoryId` informados pertencem ao usuário autenticado antes de persistir referências cruzadas
- Segurança: histórico de antecipações endurece os joins de `transactions`, `payers` e `categories` com filtro por `userId`, evitando exposição de nomes relacionados caso exista referência inconsistente no banco
- Segurança: domínio público deixa de responder rotas `/api/*`, e o Better Auth passa a aplicar rate limits explícitos para login e cadastro por e-mail
- APIs privadas: rotas de anexos, insights salvos, histórico de antecipações e presign de download passam a responder com `Cache-Control: private, no-store`; a rota de antecipações também deixa de devolver mensagens internas de erro ao cliente
- Build: rotas web de tokens do Companion passam a ser explicitamente dinâmicas, removendo o warning de prerender no `next build`
- Lançamentos: edição em série de compras parceladas volta a persistir `purchaseDate` e `period`, permitindo mover parcelas para a fatura ou competência correta conforme o escopo escolhido
- Lançamentos: edições que tentam mover compras de cartão para faturas já pagas agora são bloqueadas com mensagem clara também no fluxo de atualização e propagação em lote
- Imagens: logos institucionais, avatares padrão e componentes com `next/image` em modo `fill` passam a usar containers fixos com `sizes`, removendo avisos de proporção e performance
- Gráficos: `ChartContainer` passa a definir `initialDimension` no `ResponsiveContainer` do Recharts, evitando avisos `width(-1)` e `height(-1)` durante a medição inicial em widgets e relatórios
## [2.2.1] - 2026-04-01
### Corrigido
- Docker: imagem de produção deixa de executar `chown -R /app` no stage final; as permissões passam a ser definidas nos `COPY --chown`, reduzindo o risco de travamento e lentidão excessiva no build/push da GitHub Action
## [2.2.0] - 2026-04-01
### Adicionado
- Anexos: nova página de galeria em `/attachments` com miniaturas, visualização inline de imagem e PDF, download direto e acesso a partir do lançamento
- Anexos: suporte a visualização de PDF diretamente no app via `pdfjs-dist`
- Autenticação: sidebar redesenhado com mockup de faturas e três itens de funcionalidade; páginas de login e cadastro ganham gradiente decorativo e logo visível no mobile
- Notificações: alertas de vencimento para boletos e faturas do período seguinte exibidos quando o vencimento está dentro de 5 dias
- Documentação: novo arquivo público `public/llms.txt` com resumo do projeto e links curados para documentação, setup e arquitetura
### Alterado
- Performance: queries de cache do dashboard migradas de `unstable_cache` para a diretiva `use cache` com `cacheTag` e `cacheLife`; todas as páginas do dashboard passam a chamar `connection()` para renderização dinâmica; `next.config.ts` adota `cacheComponents: true`
- Tipografia: adicionada fonte America Medium (weight 500); pesos tipográficos padronizados para `font-medium` em títulos, valores e rótulos em todos os componentes
- Anexos: `AttachmentPreview` foi simplificado para exibir apenas nome da transação, nome do arquivo, navegação entre anexos e ações de download, abrir em nova aba e fechar com ícone `X`
### Corrigido
- Lançamentos: uploads e remoções de anexo agora funcionam para todos os lançamentos, não apenas os pertencentes a séries
## [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 ## [2.0.3] - 2026-03-26
### Corrigido ### Corrigido

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.
--- ---
@@ -43,6 +44,10 @@ Use esta pergunta:
Se um contrato cruza dominios, ele deve morar em `src/shared/`. Se um contrato cruza dominios, ele deve morar em `src/shared/`.
**Excecao intencional: `attachments` depende de `transactions`**
`src/features/attachments` importa `TransactionDialog`, `TransactionDetailsDialog` e `TransactionItem` diretamente de `src/features/transactions`. Isso e uma dependencia explicita e aceita: anexos sao semanticamente uma extensao de lancamentos — existem por causa deles e nao fazem sentido sem esse contexto. Mover esses componentes para `shared/` seria errado (eles pertencem a transactions). Nao tratar isso como bug a corrigir.
Exemplos comuns: Exemplos comuns:
- auth: `src/shared/lib/auth/*` - auth: `src/shared/lib/auth/*`

View File

@@ -5,14 +5,16 @@
# ============================================ # ============================================
FROM node:22-alpine AS deps FROM node:22-alpine AS deps
# Instalar pnpm globalmente RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app WORKDIR /app
# Copiar apenas arquivos de dependências para aproveitar cache # Copiar apenas arquivos de dependências para aproveitar cache
COPY package.json pnpm-lock.yaml* ./ COPY package.json pnpm-lock.yaml* ./
# Criar pasta public para o postinstall do pdfjs-dist
RUN mkdir -p public
# Instalar dependências (production + dev para o build) # Instalar dependências (production + dev para o build)
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
@@ -21,8 +23,7 @@ RUN pnpm install --frozen-lockfile
# ============================================ # ============================================
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
# Instalar pnpm globalmente RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app WORKDIR /app
@@ -32,13 +33,14 @@ COPY --from=deps /app/node_modules ./node_modules
# Copiar todo o código fonte # Copiar todo o código fonte
COPY . . COPY . .
# Garantir que o pdf.worker vem da versão instalada no stage 1, não do host
COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
# Variáveis de ambiente necessárias para o build # Variáveis de ambiente necessárias para o build
# DATABASE_URL será fornecida em runtime, mas precisa estar definida para validação
ENV NEXT_TELEMETRY_DISABLED=1 \ ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production NODE_ENV=production
# Build da aplicação Next.js # Build da aplicação Next.js
# Nota: Se houver erros de tipo, ajuste typescript.ignoreBuildErrors no next.config.ts
RUN pnpm build RUN pnpm build
# ============================================ # ============================================
@@ -46,8 +48,7 @@ RUN pnpm build
# ============================================ # ============================================
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
# Instalar pnpm globalmente RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app WORKDIR /app
@@ -55,12 +56,27 @@ WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
# Copiar apenas arquivos necessários para produção # Instalar deps do drizzle-kit em diretório separado ANTES de copiar o standalone
COPY --from=builder /app/public ./public # Isso evita que o pnpm install sobrescreva o node_modules do Next.js standalone
COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package.json /tmp/pkg.json
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml RUN mkdir -p /app/migrate && \
node -e "\
const p=JSON.parse(require('fs').readFileSync('/tmp/pkg.json','utf8'));\
require('fs').writeFileSync('/app/migrate/package.json',JSON.stringify({\
name:'openmonetis-migrate',version:p.version,\
dependencies:{\
'drizzle-orm':p.dependencies['drizzle-orm'],\
'pg':p.dependencies['pg']\
},\
devDependencies:{'drizzle-kit':p.devDependencies['drizzle-kit']}\
}));" && \
cd /app/migrate && pnpm install --no-frozen-lockfile --ignore-scripts && \
chown -R nextjs:nodejs /app/migrate
# Copiar arquivos de build do Next.js # Copiar apenas arquivos necessários para produção
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Copiar arquivos de build do Next.js (inclui node_modules standalone com next)
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
@@ -69,8 +85,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
COPY --from=builder --chown=nextjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts COPY --from=builder --chown=nextjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
# Copiar node_modules para ter drizzle-kit disponível para migrations # Copiar entrypoint de migrations
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules COPY docker-entrypoint.sh ./
RUN chmod +x /app/docker-entrypoint.sh && chown nextjs:nodejs /app/docker-entrypoint.sh
# Definir variáveis de ambiente de produção # Definir variáveis de ambiente de produção
ENV NODE_ENV=production \ ENV NODE_ENV=production \
@@ -81,16 +98,13 @@ ENV NODE_ENV=production \
# Expor porta # Expor porta
EXPOSE 3000 EXPOSE 3000
# Ajustar permissões para o usuário nextjs
RUN chown -R nextjs:nodejs /app
# Mudar para usuário não-root # Mudar para usuário não-root
USER nextjs USER nextjs
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1 CMD wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1
# Comando de inicialização # Entrypoint: roda migrations e depois executa o CMD
# Nota: Em produção com standalone build, o servidor é iniciado pelo arquivo server.js ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "server.js"] CMD ["node", "server.js"]

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

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -4,23 +4,28 @@ 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 # - Execute: docker compose --profile local up
# #
# 2. Banco REMOTO (ex: Supabase, Neon, etc): # 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 (apenas o serviço app) # - Execute: docker compose up
# #
# 3. Para parar todos os serviços: # 3. Build local (desenvolvimento):
# - Execute: docker compose --profile local up --build
#
# 4. Para parar todos os serviços:
# - Execute: docker compose down # - Execute: docker compose down
# #
# 4. Para remover volumes (CUIDADO: apaga dados do banco local): # 5. Para remover volumes (CUIDADO: apaga dados do banco local):
# - Execute: docker compose down -v # - Execute: docker compose down -v
services: services:
# ============================================ # ============================================
# Serviço: PostgreSQL (Banco de dados local) # Serviço: PostgreSQL (Banco de dados local)
# Ativado apenas com: --profile local
# ============================================ # ============================================
db: db:
profiles: ["local"]
image: postgres:18-alpine image: postgres:18-alpine
container_name: openmonetis_postgres container_name: openmonetis_postgres
restart: unless-stopped restart: unless-stopped
@@ -63,6 +68,7 @@ services:
# Serviço: Aplicação Next.js # Serviço: Aplicação Next.js
# ============================================ # ============================================
app: app:
build: .
image: felipegcoutinho/openmonetis:latest image: felipegcoutinho/openmonetis:latest
container_name: openmonetis_app container_name: openmonetis_app
@@ -80,6 +86,13 @@ services:
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}
# S3 (opcional)
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_BUCKET: ${S3_BUCKET:-}
# Email (opcional) # 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:-}
@@ -96,24 +109,11 @@ services:
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 # required: false permite subir sem banco local (banco remoto via DATABASE_URL)
# Para banco remoto, comente as linhas abaixo
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
required: false
# Script de inicialização: roda migrations antes de iniciar o app
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo "Aguardando banco de dados..."
sleep 5
echo "Rodando migrations..."
pnpm db:push || echo "Migrations falharam ou já estão atualizadas"
echo "Iniciando aplicação Next.js..."
node server.js
healthcheck: healthcheck:
test: test:

15
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
echo "Rodando migrations..."
RETRIES=5
until /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
RETRIES=$((RETRIES - 1))
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
sleep 5
done
if [ "$RETRIES" -eq 0 ]; then
echo "Aviso: migrations nao foram aplicadas"
fi
exec "$@"

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

@@ -162,6 +162,20 @@
"when": 1748000000000, "when": 1748000000000,
"tag": "0022_import-category-mappings", "tag": "0022_import-category-mappings",
"breakpoints": true "breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1774529878374,
"tag": "0023_sturdy_wolfpack",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
} }
] ]
} }

22
knip.jsonc Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://unpkg.com/knip@6/schema-jsonc.json",
// Exclude shared UI primitives from dead code reporting while we focus the
// cleanup on feature and domain code first.
"ignore": [
"src/shared/components/ui/**"
],
// Runtime asset referenced by string in the PDF viewer.
"ignoreFiles": [
"public/pdf.worker.min.mjs",
"setup.mjs"
],
// PostCSS is inferred from the config file, but the project only depends on
// the Tailwind PostCSS plugin directly.
"ignoreDependencies": [
"postcss"
],
"next": true,
"postcss": true,
"biome": true,
"drizzle": true
}

View File

@@ -6,16 +6,20 @@ dotenv.config();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
experimental: { cacheComponents: true,
turbopackFileSystemCacheForDev: true,
},
reactCompiler: true, reactCompiler: true,
images: { images: {
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")], remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
}, },
devIndicators: { devIndicators: {
position: "bottom-right", position: "bottom-right",
}, },
experimental: {
prefetchInlining: true,
turbopackFileSystemCacheForDev: true,
},
// Headers for Safari compatibility // Headers for Safari compatibility
async headers() { async headers() {
return [ return [

View File

@@ -1,103 +1,108 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.0.3", "version": "2.3.1",
"private": true, "private": true,
"scripts": { "packageManager": "pnpm@10.33.0",
"dev": "next dev --turbopack", "scripts": {
"dev-env": "tsx scripts/dev.ts", "dev": "next dev --turbopack",
"mockup": "tsx scripts/mock-data.ts", "db:seed": "tsx scripts/mock-data.ts",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "biome check .", "lint": "biome check .",
"lint:fix": "biome check --write .", "lint:deadcode": "knip --reporter compact",
"env:setup": "bash scripts/setup-env.sh", "lint:fix": "biome check --write .",
"db:generate": "drizzle-kit generate", "env:setup": "bash scripts/setup-env.sh",
"db:migrate": "drizzle-kit migrate", "db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push", "db:migrate": "drizzle-kit migrate",
"db:enableExtensions": "tsx scripts/postgres/enable-extensions.ts", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"docker:up": "docker compose up --build", "db:studio": "drizzle-kit studio",
"docker:up:db": "docker compose up -d db", "docker:up": "docker compose up --build",
"docker:up:d": "docker compose up --build -d", "postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"docker:down": "docker compose down", "docker:up:db": "docker compose up -d db",
"docker:down:volumes": "docker compose down -v", "docker:up:d": "docker compose up --build -d",
"docker:logs": "docker compose logs -f", "docker:down": "docker compose down",
"docker:logs:app": "docker compose logs -f app", "docker:down:volumes": "docker compose down -v",
"docker:logs:db": "docker compose logs -f db", "docker:logs": "docker compose logs -f",
"docker:restart": "docker compose restart", "docker:logs:app": "docker compose logs -f app",
"docker:rebuild": "docker compose up --build --force-recreate", "docker:logs:db": "docker compose logs -f db",
"backup": "bash scripts/backup.sh" "docker:restart": "docker compose restart",
}, "docker:rebuild": "docker compose up --build --force-recreate",
"dependencies": { "backup": "bash scripts/backup.sh"
"@ai-sdk/anthropic": "^3.0.63", },
"@ai-sdk/google": "^3.0.52", "dependencies": {
"@ai-sdk/openai": "^3.0.47", "@ai-sdk/anthropic": "^3.0.65",
"@better-auth/passkey": "^1.5.5", "@ai-sdk/google": "^3.0.55",
"@dnd-kit/core": "^6.3.1", "@ai-sdk/openai": "^3.0.49",
"@dnd-kit/sortable": "^10.0.0", "@aws-sdk/client-s3": "^3.1022.0",
"@dnd-kit/utilities": "^3.2.2", "@aws-sdk/s3-request-presigner": "^3.1022.0",
"@openrouter/ai-sdk-provider": "^2.3.3", "@better-auth/passkey": "^1.5.6",
"@radix-ui/react-alert-dialog": "1.1.15", "@dnd-kit/core": "^6.3.1",
"@radix-ui/react-avatar": "1.1.11", "@dnd-kit/sortable": "^10.0.0",
"@radix-ui/react-checkbox": "1.3.3", "@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-collapsible": "1.1.12", "@openrouter/ai-sdk-provider": "^2.3.3",
"@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-label": "2.1.8", "@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-progress": "1.1.8", "@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-select": "2.2.6", "@radix-ui/react-label": "2.1.8",
"@radix-ui/react-separator": "1.1.8", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "1.2.4", "@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-switch": "1.2.6", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-select": "2.2.6",
"@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-switch": "1.2.6",
"@remixicon/react": "4.9.0", "@radix-ui/react-tabs": "1.1.13",
"@tanstack/react-table": "8.21.3", "@radix-ui/react-toggle": "1.1.10",
"@tanstack/react-virtual": "^3.13.23", "@radix-ui/react-toggle-group": "1.1.11",
"@vercel/analytics": "^2.0.1", "@radix-ui/react-tooltip": "1.2.8",
"@vercel/speed-insights": "^2.0.0", "@remixicon/react": "4.9.0",
"ai": "^6.0.134", "@tanstack/react-query": "^5.96.2",
"better-auth": "1.5.5", "@tanstack/react-table": "8.21.3",
"canvas-confetti": "^1.9.4", "@tanstack/react-virtual": "^3.13.23",
"class-variance-authority": "0.7.1", "ai": "^6.0.143",
"clsx": "2.1.1", "better-auth": "1.5.6",
"cmdk": "^1.1.1", "canvas-confetti": "^1.9.4",
"date-fns": "^4.1.0", "class-variance-authority": "0.7.1",
"drizzle-orm": "0.45.1", "clsx": "2.1.1",
"jspdf": "^4.2.1", "cmdk": "^1.1.1",
"jspdf-autotable": "^5.0.7", "date-fns": "^4.1.0",
"next": "16.1.7", "drizzle-orm": "0.45.2",
"next-themes": "0.4.6", "jspdf": "^4.2.1",
"pg": "8.20.0", "jspdf-autotable": "^5.0.7",
"radix-ui": "^1.4.3", "next": "16.2.2",
"react": "19.2.4", "next-themes": "0.4.6",
"react-day-picker": "^9.14.0", "pdfjs-dist": "^5.6.205",
"react-dom": "19.2.4", "pg": "8.20.0",
"recharts": "3.8.0", "radix-ui": "^1.4.3",
"resend": "^6.9.4", "react": "19.2.4",
"sonner": "2.0.7", "react-day-picker": "^9.14.0",
"tailwind-merge": "3.5.0", "react-dom": "19.2.4",
"vaul": "1.1.2", "recharts": "3.8.1",
"xlsx": "^0.18.5", "resend": "^6.10.0",
"zod": "4.3.6" "sonner": "2.0.7",
}, "tailwind-merge": "3.5.0",
"devDependencies": { "vaul": "1.1.2",
"@biomejs/biome": "2.4.8", "xlsx": "^0.18.5",
"@tailwindcss/postcss": "4.2.2", "zod": "4.3.6"
"@types/canvas-confetti": "^1.9.0", },
"@types/node": "25.5.0", "devDependencies": {
"@types/pg": "^8.20.0", "@biomejs/biome": "2.4.10",
"@types/react": "19.2.14", "@tailwindcss/postcss": "4.2.2",
"@types/react-dom": "19.2.3", "@types/canvas-confetti": "^1.9.0",
"dotenv": "^17.3.1", "@types/node": "25.5.0",
"drizzle-kit": "0.31.10", "@types/pg": "^8.20.0",
"tailwindcss": "4.2.2", "@types/react": "19.2.14",
"tsx": "4.21.0", "@types/react-dom": "19.2.3",
"typescript": "5.9.3" "dotenv": "^17.4.0",
} "drizzle-kit": "0.31.10",
"knip": "^6.3.0",
"tailwindcss": "4.2.2",
"tsx": "4.21.0",
"typescript": "6.0.2"
}
} }

2617
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -7,9 +7,12 @@ export const america = localFont({
weight: "400", weight: "400",
style: "normal", style: "normal",
}, },
{
path: "./america-medium.woff2",
weight: "500",
style: "normal",
},
], ],
display: "swap", display: "fallback",
variable: "--font-america", variable: "--font-america",
}); });
export const americaFontVariable = america.variable;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

37
public/llms.txt Normal file
View File

@@ -0,0 +1,37 @@
# OpenMonetis
> OpenMonetis is a self-hosted personal finance web app for manual financial control. It helps users manage accounts, cards, invoices, budgets, notes, reports, attachments, and AI-generated insights. The product UI is in Brazilian Portuguese, the codebase uses English folder and import names, and there is no hosted SaaS version.
>
> **Stack:** Next.js 16, React 19, PostgreSQL, Drizzle ORM, Better Auth, Tailwind CSS 4, shadcn/ui. Package manager: pnpm. Linter: Biome.
OpenMonetis is meant to be deployed by the user on their own machine or server.
There is no Open Finance or automatic bank synchronization.
Transactions can be entered manually or imported from OFX and XLS/XLSX files.
Attachments are optional and require S3-compatible storage.
The public website is mainly a landing page; the main technical documentation lives in the GitHub repository.
## Docs
- [Landing page](/): Public homepage and high-level product overview
- [README](https://github.com/felipegcoutinho/openmonetis/blob/main/README.md): Main project documentation covering features, installation, Docker, environment variables, architecture, contributing, and license
- [CHANGELOG](https://github.com/felipegcoutinho/openmonetis/blob/main/CHANGELOG.md): Release history and notable changes
- [LICENSE](https://github.com/felipegcoutinho/openmonetis/blob/main/LICENSE): CC BY-NC-SA 4.0 license terms
## Setup
- [Setup script](https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs): Interactive installer for local or self-hosted setup
- [Environment example](https://github.com/felipegcoutinho/openmonetis/blob/main/.env.example): Required and optional environment variables
- [Docker Compose](https://github.com/felipegcoutinho/openmonetis/blob/main/docker-compose.yml): Local app and PostgreSQL stack definition
## Architecture
- [CLAUDE.md](https://github.com/felipegcoutinho/openmonetis/blob/main/CLAUDE.md): Project architecture, naming rules, query rules, and feature checklist
## Optional
- [robots.txt](/robots.txt): Crawl policy for the public site
## Related Projects
- [OpenMonetis Companion](https://github.com/felipegcoutinho/openmonetis-companion): Android app that captures bank notifications and sends them to the OpenMonetis inbox for review

28
public/pdf.worker.min.mjs Normal file

File diff suppressed because one or more lines are too long

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

@@ -1,9 +1,19 @@
import { LoginForm } from "@/features/auth/components/login-form"; import { LoginForm } from "@/features/auth/components/login-form";
import { Logo } from "@/shared/components/logo";
export default function LoginPage() { export default function LoginPage() {
return ( return (
<div className="flex min-h-svh flex-col items-center justify-center bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10"> <div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
<div className="w-full max-w-sm md:max-w-5xl"> <div className="pointer-events-none absolute inset-0">
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
</div>
<div className="relative mb-6 flex md:hidden">
<Logo variant="compact" colorIcon />
</div>
<div className="relative w-full max-w-sm md:max-w-5xl">
<LoginForm /> <LoginForm />
</div> </div>
</div> </div>

View File

@@ -1,9 +1,19 @@
import { SignupForm } from "@/features/auth/components/signup-form"; import { SignupForm } from "@/features/auth/components/signup-form";
import { Logo } from "@/shared/components/logo";
export default function Page() { export default function SignupPage() {
return ( return (
<div className="flex min-h-svh flex-col items-center justify-center bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10"> <div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
<div className="w-full max-w-sm md:max-w-5xl"> <div className="pointer-events-none absolute inset-0">
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
</div>
<div className="relative mb-6 flex md:hidden">
<Logo variant="compact" colorIcon />
</div>
<div className="relative w-full max-w-sm md:max-w-5xl">
<SignupForm /> <SignupForm />
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { RiPencilLine } from "@remixicon/react"; import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { connection } from "next/server";
import { AccountDialog } from "@/features/accounts/components/account-dialog"; import { AccountDialog } from "@/features/accounts/components/account-dialog";
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card"; import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
import type { Account } from "@/features/accounts/components/types"; import type { Account } from "@/features/accounts/components/types";
@@ -42,6 +43,7 @@ const capitalize = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value; value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
export default async function Page({ params, searchParams }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { accountId } = await params; const { accountId } = await params;
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -190,6 +192,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

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { AccountsPage } from "@/features/accounts/components/accounts-page"; import { AccountsPage } from "@/features/accounts/components/accounts-page";
import { fetchAllAccountsForUser } from "@/features/accounts/queries"; import { fetchAllAccountsForUser } from "@/features/accounts/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const { activeAccounts, archivedAccounts, logoOptions } = const { activeAccounts, archivedAccounts, logoOptions } =
await fetchAllAccountsForUser(userId); await fetchAllAccountsForUser(userId);

View File

@@ -0,0 +1,38 @@
import { Skeleton } from "@/shared/components/ui/skeleton";
export default function AnexosLoading() {
return (
<main className="flex flex-col gap-6">
<div className="w-full space-y-6">
{/* Header */}
<Skeleton className="h-10 w-40 rounded-md bg-foreground/10" />
{/* Month navigation */}
<Skeleton className="h-10 w-64 rounded-md bg-foreground/10" />
{/* Count */}
<Skeleton className="h-4 w-20 rounded-md bg-foreground/10" />
{/* Grid */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => (
<div
key={i}
className="flex flex-col overflow-hidden rounded-lg border"
>
<Skeleton className="aspect-square w-full bg-foreground/10" />
<div className="space-y-1.5 p-2.5">
<Skeleton className="h-3 w-3/4 rounded bg-foreground/10" />
<Skeleton className="h-3 w-full rounded bg-foreground/10" />
<div className="flex justify-between">
<Skeleton className="h-3 w-16 rounded bg-foreground/10" />
<Skeleton className="h-3 w-12 rounded bg-foreground/10" />
</div>
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,36 @@
import { connection } from "next/server";
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
import { getUserId } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period } = parsePeriodParam(periodoParam);
const attachments = await fetchAttachmentsForPeriod(userId, period);
return (
<main className="flex flex-col gap-6">
<AttachmentsPage attachments={attachments} />
</main>
);
}

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { BudgetsPage } from "@/features/budgets/components/budgets-page"; import { BudgetsPage } from "@/features/budgets/components/budgets-page";
import { fetchBudgetsForUser } from "@/features/budgets/queries"; import { fetchBudgetsForUser } from "@/features/budgets/queries";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
@@ -23,6 +24,7 @@ const capitalize = (value: string) =>
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1); value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { MonthlyCalendar } from "@/features/calendar/components/monthly-calendar"; import { MonthlyCalendar } from "@/features/calendar/components/monthly-calendar";
import { fetchCalendarData } from "@/features/calendar/queries"; import { fetchCalendarData } from "@/features/calendar/queries";
import { import {
@@ -16,6 +17,7 @@ type PageProps = {
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const resolvedParams = searchParams ? await searchParams : undefined; const resolvedParams = searchParams ? await searchParams : undefined;

View File

@@ -1,5 +1,6 @@
import { RiPencilLine } from "@remixicon/react"; import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { connection } from "next/server";
import type { FinancialAccount } from "@/db/schema"; import type { FinancialAccount } from "@/db/schema";
import { CardDialog } from "@/features/cards/components/card-dialog"; import { CardDialog } from "@/features/cards/components/card-dialog";
import type { Card } from "@/features/cards/components/types"; import type { Card } from "@/features/cards/components/types";
@@ -39,6 +40,7 @@ type PageProps = {
}; };
export default async function Page({ params, searchParams }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { cardId } = await params; const { cardId } = await params;
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -202,6 +204,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

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { CardsPage } from "@/features/cards/components/cards-page"; import { CardsPage } from "@/features/cards/components/cards-page";
import { fetchAllCardsForUser } from "@/features/cards/queries"; import { fetchAllCardsForUser } from "@/features/cards/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const { activeCards, archivedCards, accounts, logoOptions } = const { activeCards, archivedCards, accounts, logoOptions } =
await fetchAllCardsForUser(userId); await fetchAllCardsForUser(userId);

View File

@@ -1,4 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { connection } from "next/server";
import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header"; import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header";
import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries"; import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries";
import { fetchUserPreferences } from "@/features/settings/queries"; import { fetchUserPreferences } from "@/features/settings/queries";
@@ -32,6 +33,7 @@ const getSingleParam = (
}; };
export default async function Page({ params, searchParams }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { categoryId } = await params; const { categoryId } = await params;
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -99,6 +101,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

@@ -1,9 +1,11 @@
import { connection } from "next/server";
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries"; import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget"; import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { getCurrentPeriod } from "@/shared/utils/period"; import { getCurrentPeriod } from "@/shared/utils/period";
export default async function HistoricoCategoriasPage() { export default async function HistoricoCategoriasPage() {
await connection();
const user = await getUser(); const user = await getUser();
const currentPeriod = getCurrentPeriod(); const currentPeriod = getCurrentPeriod();

View File

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { CategoriesPage } from "@/features/categories/components/categories-page"; import { CategoriesPage } from "@/features/categories/components/categories-page";
import { fetchCategoriesForUser } from "@/features/categories/queries"; import { fetchCategoriesForUser } from "@/features/categories/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const categories = await fetchCategoriesForUser(userId); const categories = await fetchCategoriesForUser(userId);

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable"; import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards"; import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome"; import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
@@ -14,6 +15,7 @@ type PageProps = {
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const user = await getUser(); const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { InboxPage } from "@/features/inbox/components/inbox-page"; import { InboxPage } from "@/features/inbox/components/inbox-page";
import { import {
type ResolvedInboxSearchParams, type ResolvedInboxSearchParams,
@@ -31,6 +32,7 @@ const EMPTY_DIALOG_DATA = {
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const activeStatus = resolveInboxStatus(resolvedSearchParams); const activeStatus = resolveInboxStatus(resolvedSearchParams);

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { InsightsPage } from "@/features/insights/components/insights-page"; import { InsightsPage } from "@/features/insights/components/insights-page";
import MonthNavigation from "@/shared/components/month-picker/month-navigation"; import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { parsePeriodParam } from "@/shared/utils/period"; import { parsePeriodParam } from "@/shared/utils/period";
@@ -18,6 +19,7 @@ const getSingleParam = (
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam); const { period: selectedPeriod } = parsePeriodParam(periodoParam);

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries"; import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar"; import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider"; import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
@@ -9,6 +10,7 @@ export default async function DashboardLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
await connection();
const session = await getUserSession(); const session = await getUserSession();
const navbarData = await fetchDashboardNavbarData(session.user.id); const navbarData = await fetchDashboardNavbarData(session.user.id);

View File

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { NotesPage } from "@/features/notes/components/notes-page"; import { NotesPage } from "@/features/notes/components/notes-page";
import { fetchAllNotesForUser } from "@/features/notes/queries"; import { fetchAllNotesForUser } from "@/features/notes/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId); const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);

View File

@@ -4,6 +4,7 @@ import {
RiWallet3Line, RiWallet3Line,
} from "@remixicon/react"; } from "@remixicon/react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { connection } from "next/server";
import { PayerCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card"; import { PayerCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card";
import { PayerHeaderCard } from "@/features/payers/components/details/payer-header-card"; import { PayerHeaderCard } from "@/features/payers/components/details/payer-header-card";
import { PayerHistoryCard } from "@/features/payers/components/details/payer-history-card"; import { PayerHistoryCard } from "@/features/payers/components/details/payer-history-card";
@@ -91,6 +92,7 @@ const createEmptySlugMaps = (): SlugMaps => ({
type OptionSet = ReturnType<typeof buildOptionSets>; type OptionSet = ReturnType<typeof buildOptionSets>;
export default async function Page({ params, searchParams }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { payerId } = await params; const { payerId } = await params;
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -390,6 +392,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,8 +1,10 @@
import { connection } from "next/server";
import { PayersPage } from "@/features/payers/components/payers-page"; import { PayersPage } from "@/features/payers/components/payers-page";
import { fetchPayersForUser } from "@/features/payers/queries"; import { fetchPayersForUser } from "@/features/payers/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const { payers, avatarOptions } = await fetchPayersForUser(userId); const { payers, avatarOptions } = await fetchPayersForUser(userId);

View File

@@ -1,4 +1,5 @@
import { RiBankCard2Line } from "@remixicon/react"; import { RiBankCard2Line } from "@remixicon/react";
import { connection } from "next/server";
import { fetchCartoesReportData } from "@/features/reports/cards-report-queries"; import { fetchCartoesReportData } from "@/features/reports/cards-report-queries";
import { CardCategoryBreakdown } from "@/features/reports/components/cards/card-category-breakdown"; import { CardCategoryBreakdown } from "@/features/reports/components/cards/card-category-breakdown";
import { CardInvoiceStatus } from "@/features/reports/components/cards/card-invoice-status"; import { CardInvoiceStatus } from "@/features/reports/components/cards/card-invoice-status";
@@ -28,6 +29,7 @@ const getSingleParam = (
export default async function RelatorioCartoesPage({ export default async function RelatorioCartoesPage({
searchParams, searchParams,
}: PageProps) { }: PageProps) {
await connection();
const user = await getUser(); const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -1,4 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { connection } from "next/server";
import type { Category } from "@/db/schema"; import type { Category } from "@/db/schema";
import { fetchCategoryChartData } from "@/features/reports/category-chart-queries"; import { fetchCategoryChartData } from "@/features/reports/category-chart-queries";
import { fetchCategoryReport } from "@/features/reports/category-report-queries"; import { fetchCategoryReport } from "@/features/reports/category-report-queries";
@@ -29,6 +30,7 @@ const getSingleParam = (
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
// Get authenticated user // Get authenticated user
const userId = await getUserId(); const userId = await getUserId();

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { EstablishmentsList } from "@/features/reports/components/establishments/establishments-list"; import { EstablishmentsList } from "@/features/reports/components/establishments/establishments-list";
import { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards"; import { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards";
import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter"; import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter";
@@ -36,6 +37,7 @@ const validatePeriodFilter = (value: string | null): PeriodFilter => {
export default async function TopEstabelecimentosPage({ export default async function TopEstabelecimentosPage({
searchParams, searchParams,
}: PageProps) { }: PageProps) {
await connection();
const user = await getUser(); const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -1,8 +1,10 @@
import { connection } from "next/server";
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page"; import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries"; import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const user = await getUser(); const user = await getUser();
const data = await fetchInstallmentAnalysis(user.id); const data = await fetchInstallmentAnalysis(user.id);

View File

@@ -1,6 +1,7 @@
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";
import { connection } from "next/server";
import { CompanionTab } from "@/features/settings/components/companion-tab"; import { CompanionTab } from "@/features/settings/components/companion-tab";
import { DeleteAccountForm } from "@/features/settings/components/delete-account-form"; import { DeleteAccountForm } from "@/features/settings/components/delete-account-form";
@@ -11,6 +12,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,
@@ -20,6 +22,7 @@ import {
import { auth } from "@/shared/lib/auth/config"; import { auth } from "@/shared/lib/auth/config";
export default async function Page() { export default async function Page() {
await connection();
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: await headers(),
}); });
@@ -64,12 +67,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-medium 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 +81,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-medium">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-medium 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 +130,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-medium 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 +146,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-medium 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 +162,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-medium 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 +182,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-medium 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

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { ImportPage } from "@/features/transactions/components/import/import-page"; import { ImportPage } from "@/features/transactions/components/import/import-page";
import { import {
buildOptionSets, buildOptionSets,
@@ -7,6 +8,7 @@ import { fetchTransactionFilterSources } from "@/features/transactions/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const filterSources = await fetchTransactionFilterSources(userId); const filterSources = await fetchTransactionFilterSources(userId);
const sluggedFilters = buildSluggedFilters(filterSources); const sluggedFilters = buildSluggedFilters(filterSources);

View File

@@ -1,3 +1,4 @@
import { connection } from "next/server";
import { fetchUserPreferences } from "@/features/settings/queries"; import { fetchUserPreferences } from "@/features/settings/queries";
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page"; import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
import { import {
@@ -27,6 +28,7 @@ type PageProps = {
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection();
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -102,6 +104,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">
@@ -131,7 +126,7 @@ export default async function Page() {
Projeto Open Source Projeto Open Source
</Badge> </Badge>
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight"> <h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-medium tracking-tight">
Suas finanças, Suas finanças,
<span className="text-primary"> do seu jeito</span> <span className="text-primary"> do seu jeito</span>
</h1> </h1>
@@ -212,7 +207,7 @@ export default async function Page() {
className="flex flex-col items-center text-center gap-1.5" className="flex flex-col items-center text-center gap-1.5"
> >
<Icon className="size-5" style={{ color: colorVar }} /> <Icon className="size-5" style={{ color: colorVar }} />
<span className="text-2xl md:text-3xl font-bold"> <span className="text-2xl md:text-3xl font-medium">
{value} {value}
</span> </span>
<span className="text-xs md:text-sm text-muted-foreground"> <span className="text-xs md:text-sm text-muted-foreground">
@@ -234,7 +229,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4"> <Badge variant="outline" className="mb-4">
Conheça as telas Conheça as telas
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Veja o que você pode fazer Veja o que você pode fazer
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -259,7 +254,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4"> <Badge variant="outline" className="mb-4">
O que tem aqui O que tem aqui
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Funcionalidades que importam Funcionalidades que importam
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -287,7 +282,7 @@ export default async function Page() {
/> />
</div> </div>
<div> <div>
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2"> <h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
{feature.title} {feature.title}
</h3> </h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
@@ -351,7 +346,7 @@ export default async function Page() {
<RiSmartphoneLine className="size-3.5 mr-1" /> <RiSmartphoneLine className="size-3.5 mr-1" />
Mobile Mobile
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Use o OpenMonetis no celular sem perder o fluxo Use o OpenMonetis no celular sem perder o fluxo
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -389,7 +384,7 @@ export default async function Page() {
<RiSmartphoneLine className="size-3.5 mr-1" /> <RiSmartphoneLine className="size-3.5 mr-1" />
PWA instalável PWA instalável
</Badge> </Badge>
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3"> <h3 className="text-2xl md:text-3xl font-medium tracking-tight mb-3">
Leve o OpenMonetis para a tela inicial Leve o OpenMonetis para a tela inicial
</h3> </h3>
<p className="text-muted-foreground mb-6 leading-relaxed"> <p className="text-muted-foreground mb-6 leading-relaxed">
@@ -435,7 +430,7 @@ export default async function Page() {
Companion Android Companion Android
</Badge> </Badge>
</div> </div>
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3"> <h3 className="text-2xl md:text-3xl font-medium tracking-tight mb-3">
Capture, envie e revise no mesmo fluxo Capture, envie e revise no mesmo fluxo
</h3> </h3>
<p className="text-muted-foreground mb-6 leading-relaxed"> <p className="text-muted-foreground mb-6 leading-relaxed">
@@ -446,7 +441,7 @@ export default async function Page() {
{companionSteps.map((step, index) => ( {companionSteps.map((step, index) => (
<li key={step.title} className="flex items-start gap-3"> <li key={step.title} className="flex items-start gap-3">
<span <span
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold" className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
color: step.colorVar, color: step.colorVar,
@@ -534,7 +529,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4"> <Badge variant="outline" className="mb-4">
Stack técnica Stack técnica
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
O que roda por baixo O que roda por baixo
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -561,7 +556,7 @@ export default async function Page() {
/> />
</div> </div>
<div> <div>
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2"> <h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
{item.title} {item.title}
</h3> </h3>
<p className="text-sm text-muted-foreground mb-2 md:mb-3"> <p className="text-sm text-muted-foreground mb-2 md:mb-3">
@@ -587,7 +582,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4"> <Badge variant="outline" className="mb-4">
Como usar Como usar
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Rode no seu computador Rode no seu computador
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
@@ -622,7 +617,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4"> <Badge variant="outline" className="mb-4">
Para quem é? Para quem é?
</Badge> </Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Feito para quem gosta de controle Feito para quem gosta de controle
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0"> <p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
@@ -649,7 +644,7 @@ export default async function Page() {
/> />
</div> </div>
<div> <div>
<h3 className="font-semibold mb-1">{item.title}</h3> <h3 className="font-medium mb-1">{item.title}</h3>
<p className="text-xs sm:text-sm text-muted-foreground"> <p className="text-xs sm:text-sm text-muted-foreground">
{item.description} {item.description}
</p> </p>
@@ -669,7 +664,7 @@ export default async function Page() {
<div className="max-w-8xl mx-auto px-4"> <div className="max-w-8xl mx-auto px-4">
<AnimateOnScroll> <AnimateOnScroll>
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center"> <div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
Pronto para testar? Pronto para testar?
</h2> </h2>
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8"> <p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
@@ -720,7 +715,7 @@ export default async function Page() {
</div> </div>
<div> <div>
<h3 className="font-semibold mb-3 md:mb-4">Projeto</h3> <h3 className="font-medium mb-3 md:mb-4">Projeto</h3>
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground"> <ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
<li> <li>
<Link <Link
@@ -754,7 +749,7 @@ export default async function Page() {
</div> </div>
<div> <div>
<h3 className="font-semibold mb-3 md:mb-4">Companion</h3> <h3 className="font-medium mb-3 md:mb-4">Companion</h3>
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground"> <ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
<li> <li>
<Link <Link

View File

@@ -0,0 +1,42 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { attachments } from "@/db/schema";
import { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ attachmentId: string }> },
) {
const [userId, { attachmentId }] = await Promise.all([getUserId(), params]);
const [row] = await db
.select({ fileKey: attachments.fileKey })
.from(attachments)
.where(
and(eq(attachments.id, attachmentId), eq(attachments.userId, userId)),
);
if (!row) {
return NextResponse.json(
{ error: "Not found" },
{
status: 404,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
const url = await createPresignedGetUrl(row.fileKey);
return NextResponse.json(
{ url },
{
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}

View File

@@ -1,5 +1,5 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { NextResponse } from "next/server"; import { connection, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { apiTokens } from "@/db/schema"; import { apiTokens } from "@/db/schema";
import { import {
@@ -16,14 +16,17 @@ const createTokenSchema = z.object({
}); });
export async function POST(request: Request) { export async function POST(request: Request) {
await connection();
// Verificar autenticação via sessão web
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try { try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Validar body // Validar body
const body = await request.json(); const body = await request.json();
const { name, deviceId } = createTokenSchema.parse(body); const { name, deviceId } = createTokenSchema.parse(body);

View File

@@ -1,6 +1,6 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { NextResponse } from "next/server"; import { connection, NextResponse } from "next/server";
import { apiTokens } from "@/db/schema"; import { apiTokens } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config"; import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
@@ -10,16 +10,19 @@ interface RouteParams {
} }
export async function DELETE(_request: Request, { params }: RouteParams) { export async function DELETE(_request: Request, { params }: RouteParams) {
await connection();
const { tokenId } = await params;
// Verificar autenticação via sessão web
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try { try {
const { tokenId } = await params;
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Verificar se token pertence ao usuário // Verificar se token pertence ao usuário
const token = await db.query.apiTokens.findFirst({ const token = await db.query.apiTokens.findFirst({
where: and( where: and(

View File

@@ -1,19 +1,22 @@
import { and, desc, eq, isNull } from "drizzle-orm"; import { and, desc, eq, isNull } from "drizzle-orm";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { NextResponse } from "next/server"; import { connection, NextResponse } from "next/server";
import { apiTokens } from "@/db/schema"; import { apiTokens } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config"; import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
export async function GET() { export async function GET() {
await connection();
// Verificar autenticação via sessão web
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try { try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Buscar tokens ativos do usuário // Buscar tokens ativos do usuário
const activeTokens = await db const activeTokens = await db
.select({ .select({

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import {
fetchSavedInsights,
savedInsightsPeriodSchema,
} from "@/features/insights/queries";
import { getUserId } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(request: Request) {
const period = new URL(request.url).searchParams.get("period") ?? "";
const validatedPeriod = savedInsightsPeriodSchema.safeParse(period);
if (!validatedPeriod.success) {
return NextResponse.json(
{
error: validatedPeriod.error.issues[0]?.message ?? "Período inválido.",
},
{
status: 400,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
const userId = await getUserId();
const insights = await fetchSavedInsights(userId, validatedPeriod.data);
return NextResponse.json(insights, {
headers: PRIVATE_RESPONSE_HEADERS,
});
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries";
import { getUserId } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ transactionId: string }> },
) {
const [userId, { transactionId }] = await Promise.all([getUserId(), params]);
const attachments = await fetchTransactionAttachments(userId, transactionId);
return NextResponse.json(attachments, {
headers: PRIVATE_RESPONSE_HEADERS,
});
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
import { getUserId } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ seriesId: string }> },
) {
try {
const [userId, { seriesId }] = await Promise.all([getUserId(), params]);
const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
return NextResponse.json(anticipations, {
headers: PRIVATE_RESPONSE_HEADERS,
});
} catch (error) {
console.error("Erro ao carregar histórico de antecipações:", error);
return NextResponse.json(
{
error: "Erro ao carregar histórico de antecipações.",
},
{
status: 400,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
}

View File

@@ -1,6 +1,6 @@
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Suspense } from "react";
import { QueryProvider } from "@/shared/components/providers/query-provider";
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";
import "./globals.css"; import "./globals.css";
@@ -22,20 +22,27 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html <html
data-scroll-behavior="smooth"
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="antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light"> <ThemeProvider attribute="class" defaultTheme="light">
{children} <QueryProvider>
<Toaster position="top-right" /> <Suspense>{children}</Suspense>
<Toaster position="top-right" />
</QueryProvider>
</ThemeProvider> </ThemeProvider>
<Analytics />
<SpeedInsights />
</body> </body>
</html> </html>
); );

View File

@@ -1,9 +1,5 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
: "https://openmonetis.com";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
rules: [ rules: [
@@ -21,15 +17,15 @@ export default function robots(): MetadataRoute.Robots {
"/notes", "/notes",
"/insights", "/insights",
"/calendar", "/calendar",
"/consultor", "/attachments",
"/settings", "/settings",
"/reports", "/reports",
"/inbox", "/inbox",
"/login", "/login",
"/signup",
"/api/", "/api/",
], ],
}, },
], ],
sitemap: `${BASE_URL}/sitemap.xml`,
}; };
} }

View File

@@ -135,9 +135,11 @@ 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[];
myAccountsShowExcluded?: boolean;
}>(), }>(),
createdAt: timestamp("created_at", { createdAt: timestamp("created_at", {
mode: "date", mode: "date",
@@ -847,32 +849,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 +902,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 +979,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

@@ -88,9 +88,7 @@ export function AccountCard({
{icon} {icon}
</div> </div>
) : null} ) : null}
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-lg font-medium text-foreground">{accountName}</h2>
{accountName}
</h2>
{(excludeFromBalance || excludeInitialBalanceFromIncome) && ( {(excludeFromBalance || excludeInitialBalanceFromIncome) && (
<Tooltip> <Tooltip>

View File

@@ -68,7 +68,7 @@ export function AccountStatementCard({
</div> </div>
) : null} ) : null}
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-foreground"> <h2 className="truncate text-sm font-medium text-foreground">
{accountName} {accountName}
</h2> </h2>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -86,12 +86,12 @@ export function AccountStatementCard({
</p> </p>
<MoneyValues <MoneyValues
amount={currentBalance} amount={currentBalance}
className="text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]" className="text-3xl leading-none font-medium tracking-tight sm:text-[2rem]"
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge
variant={getAccountStatusBadgeVariant(status)} variant={getAccountStatusBadgeVariant(status)}
className="text-[11px]" className="text-xs"
> >
{status} {status}
</Badge> </Badge>
@@ -107,7 +107,7 @@ export function AccountStatementCard({
label="Saldo inicial" label="Saldo inicial"
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês." tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
> >
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-medium text-foreground">
{formatCurrency(openingBalance)} {formatCurrency(openingBalance)}
</span> </span>
</MetaItem> </MetaItem>
@@ -116,7 +116,7 @@ export function AccountStatementCard({
label="Entradas" label="Entradas"
tooltip="Total de receitas deste mês classificadas como pagas para esta conta." tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
> >
<span className="text-sm font-semibold text-success"> <span className="text-sm font-medium text-success">
{formatCurrency(totalIncomes)} {formatCurrency(totalIncomes)}
</span> </span>
</MetaItem> </MetaItem>
@@ -125,7 +125,7 @@ export function AccountStatementCard({
label="Saídas" label="Saídas"
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)." tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
> >
<span className="text-sm font-semibold text-destructive"> <span className="text-sm font-medium text-destructive">
{formatCurrency(totalExpenses)} {formatCurrency(totalExpenses)}
</span> </span>
</MetaItem> </MetaItem>
@@ -136,7 +136,7 @@ export function AccountStatementCard({
> >
<span <span
className={cn( className={cn(
"text-sm font-semibold", "text-sm font-medium",
resultado >= 0 ? "text-success" : "text-destructive", resultado >= 0 ? "text-success" : "text-destructive",
)} )}
> >

View File

@@ -0,0 +1,209 @@
"use client";
import { RiFileLine, RiFilePdf2Line, RiImageLine } from "@remixicon/react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils";
import { formatCurrency } from "@/shared/utils/currency";
import { formatDate } from "@/shared/utils/date";
import { formatBytes } from "@/shared/utils/number";
interface PdfCanvasProps {
url: string;
}
function PdfCanvas({ url }: PdfCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [locked, setLocked] = useState(false);
useEffect(() => {
let cancelled = false;
setLocked(false);
async function render() {
const pdfjsLib = await import("pdfjs-dist");
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;
try {
pdf = await pdfjsLib.getDocument(url).promise;
} catch (err) {
if ((err as { name?: string }).name === "PasswordException") {
if (!cancelled) setLocked(true);
}
return;
}
const page = await pdf.getPage(1);
const canvas = canvasRef.current;
if (!canvas || cancelled) return;
const containerWidth = canvas.parentElement?.offsetWidth ?? 200;
const viewport = page.getViewport({ scale: 1 });
const scale = containerWidth / viewport.width;
const scaled = page.getViewport({ scale });
canvas.width = scaled.width;
canvas.height = scaled.height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
await page.render({ canvasContext: ctx, canvas, viewport: scaled })
.promise;
}
render().catch(() => {});
return () => {
cancelled = true;
};
}, [url]);
if (locked) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
<span className="text-xs font-medium text-muted-foreground/60">
PDF Protegido
</span>
</div>
);
}
return (
<canvas
ref={canvasRef}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
);
}
interface AttachmentGridItemProps {
attachment: AttachmentForPeriod;
url?: string;
onClick: () => void;
onDetails: () => void;
isLoadingDetails?: boolean;
}
export function AttachmentGridItem({
attachment,
url,
onClick,
onDetails,
isLoadingDetails = false,
}: AttachmentGridItemProps) {
const isPdf = attachment.mimeType === "application/pdf";
const isImage = attachment.mimeType.startsWith("image/");
const amount = Number.parseFloat(attachment.transactionAmount);
return (
<div className="group flex flex-col overflow-hidden rounded-lg border bg-card transition-all duration-200 hover:border-primary">
{/* Thumbnail */}
<button
type="button"
onClick={onClick}
className="relative aspect-4/3 w-full border-b overflow-hidden bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset cursor-pointer"
>
{/* Conteúdo do thumbnail */}
{isImage && url && (
<Image
src={url}
alt={attachment.fileName}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw"
unoptimized
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
)}
{isImage && !url && (
<div className="h-full w-full animate-pulse bg-muted-foreground/10" />
)}
{isPdf && url && <PdfCanvas url={url} />}
{isPdf && !url && (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-red-50 dark:bg-red-950/20">
<RiFilePdf2Line className="size-14 text-red-400/60" />
</div>
)}
{!isImage && !isPdf && (
<div className="flex h-full w-full items-center justify-center bg-muted">
<RiFileLine className="size-14 text-muted-foreground/40" />
</div>
)}
{/* Overlay no hover */}
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors duration-200 group-hover:bg-black/10" />
</button>
{/* Informações */}
<div className="flex flex-1 flex-col gap-3 px-4 py-3">
{/* Nome do arquivo + tipo */}
<div className="flex items-center gap-1 min-w-0">
<div className="shrink-0 gap-0.5 text-xs opacity-60">
{isPdf && <RiFilePdf2Line className="size-4 text-red-500" />}
{isImage && <RiImageLine className="size-4 text-blue-500" />}
{!isPdf && !isImage && <RiFileLine className="size-4" />}
</div>
<Tooltip>
<TooltipTrigger asChild>
<p className="truncate text-sm font-medium leading-tight text-foreground">
{attachment.fileName}
</p>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{attachment.fileName}
</TooltipContent>
</Tooltip>
</div>
{/* Data */}
<span className="text-xs text-muted-foreground">
{formatDate(attachment.purchaseDate)}
</span>
{/* Transação e Valor */}
<div className="flex items-start justify-between gap-2">
<Tooltip>
<TooltipTrigger asChild>
<p className="truncate text-sm text-muted-foreground">
{attachment.transactionName}
</p>
</TooltipTrigger>
<TooltipContent side="top">
{attachment.transactionName}
</TooltipContent>
</Tooltip>
<span
className={cn(
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
)}
>
{formatCurrency(amount)}
</span>
</div>
{/* Footer: Tamanho + Botão Detalhes */}
<div className="mt-auto flex items-center justify-between border-t pt-3">
<span className="text-xs font-medium text-muted-foreground/70">
{formatBytes(attachment.fileSize)}
</span>
<button
type="button"
onClick={onDetails}
disabled={isLoadingDetails}
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
>
{isLoadingDetails ? "Carregando..." : "Detalhes"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,200 @@
"use client";
import {
RiArrowLeftSLine,
RiArrowRightSLine,
RiCloseLine,
RiDownloadLine,
RiExternalLinkLine,
} from "@remixicon/react";
import { useEffect, useState } from "react";
import { useAttachmentUrlQuery } from "@/features/attachments/hooks/use-attachment-url";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
interface AttachmentPreviewProps {
attachments: AttachmentForPeriod[];
selectedIndex: number;
onClose: () => void;
}
export function AttachmentPreview({
attachments,
selectedIndex,
onClose,
}: AttachmentPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
const open = selectedIndex >= 0;
useEffect(() => {
if (selectedIndex >= 0) setCurrentIndex(selectedIndex);
}, [selectedIndex]);
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "ArrowLeft") setCurrentIndex((i) => Math.max(0, i - 1));
if (e.key === "ArrowRight")
setCurrentIndex((i) => Math.min(attachments.length - 1, i + 1));
}
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [open, attachments.length]);
const attachment = attachments[currentIndex];
const attachmentId = attachment?.attachmentId;
const {
data: previewUrl,
isLoading: isPreviewLoading,
isError: isPreviewError,
} = useAttachmentUrlQuery(attachmentId ?? "", open && Boolean(attachmentId));
if (!attachment) return null;
const isPdf = attachment.mimeType === "application/pdf";
const isImage = attachment.mimeType.startsWith("image/");
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < attachments.length - 1;
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<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-start justify-between gap-3 border-b px-4 py-3 sm:px-5">
<div className="min-w-0 space-y-0.5">
<DialogTitle
className="truncate text-sm font-medium"
title={attachment.transactionName}
>
{attachment.transactionName}
</DialogTitle>
<p
className="truncate text-xs text-muted-foreground"
title={attachment.fileName}
>
{attachment.fileName}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{attachments.length > 1 && (
<>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!hasPrev}
onClick={() => setCurrentIndex((i) => i - 1)}
title="Anterior (←)"
>
<RiArrowLeftSLine className="size-4" />
</Button>
<span className="select-none text-xs text-muted-foreground tabular-nums">
{currentIndex + 1} / {attachments.length}
</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!hasNext}
onClick={() => setCurrentIndex((i) => i + 1)}
title="Próximo (→)"
>
<RiArrowRightSLine className="size-4" />
</Button>
</>
)}
<Button
type="button"
variant="ghost"
size="icon"
disabled={!previewUrl}
asChild={!!previewUrl}
>
{previewUrl ? (
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
download={attachment.fileName}
>
<RiDownloadLine className="size-4" />
</a>
) : (
<RiDownloadLine className="size-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!previewUrl}
asChild={!!previewUrl}
>
{previewUrl ? (
<a href={previewUrl} target="_blank" rel="noreferrer">
<RiExternalLinkLine className="size-4" />
</a>
) : (
<RiExternalLinkLine className="size-4" />
)}
</Button>
<DialogClose asChild>
<Button type="button" variant="ghost" size="icon">
<RiCloseLine className="size-4" />
</Button>
</DialogClose>
</div>
</DialogHeader>
<div className="min-h-0 min-w-0 flex-1">
{isPreviewLoading && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
</div>
)}
{isPreviewError && (
<div className="flex h-full w-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
Não foi possível carregar a visualização deste anexo.
</div>
)}
{isPdf && previewUrl && (
<iframe
key={attachment.attachmentId}
src={previewUrl}
className="h-full w-full border-0 bg-background"
title={attachment.fileName}
/>
)}
{isImage && previewUrl && (
<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
key={attachment.attachmentId}
src={previewUrl}
alt={attachment.fileName}
className="max-h-full max-w-full rounded-md object-contain"
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,275 @@
"use client";
import {
RiAttachmentLine,
RiFilePdf2Line,
RiImageLine,
} from "@remixicon/react";
import { useRouter } from "next/navigation";
import type React from "react";
import { useState, useTransition } from "react";
import { AttachmentGridItem } from "@/features/attachments/components/attachment-grid-item";
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
import { useAttachmentUrl } from "@/features/attachments/hooks/use-attachment-url";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import { fetchTransactionByIdAction } from "@/features/transactions/actions/fetch-by-id";
import type { TransactionDialogOptions } from "@/features/transactions/actions/fetch-dialog-options";
import { fetchTransactionDialogOptionsAction } from "@/features/transactions/actions/fetch-dialog-options";
import { TransactionDetailsDialog } from "@/features/transactions/components/dialogs/transaction-details-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import type { TransactionItem } from "@/features/transactions/components/types";
import { EmptyState } from "@/shared/components/empty-state";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import PageDescription from "@/shared/components/page-description";
import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/utils/ui";
type FilterType = "all" | "images" | "pdfs";
function AttachmentGridItemWithUrl({
attachment,
onClick,
onDetails,
isLoadingDetails,
}: {
attachment: AttachmentForPeriod;
onClick: () => void;
onDetails: () => void;
isLoadingDetails: boolean;
}) {
const { url, containerRef } = useAttachmentUrl(attachment.attachmentId);
return (
<div ref={containerRef}>
<AttachmentGridItem
attachment={attachment}
url={url ?? undefined}
onClick={onClick}
onDetails={onDetails}
isLoadingDetails={isLoadingDetails}
/>
</div>
);
}
const FILTERS: {
value: FilterType;
label: string;
icon: React.ReactNode;
}[] = [
{
value: "all",
label: "Todos",
icon: <RiAttachmentLine className="size-3.5" />,
},
{
value: "images",
label: "Imagens",
icon: <RiImageLine className="size-3.5 text-blue-500" />,
},
{
value: "pdfs",
label: "PDFs",
icon: <RiFilePdf2Line className="size-3.5 text-red-500" />,
},
];
interface AttachmentsPageProps {
attachments: AttachmentForPeriod[];
}
export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
const router = useRouter();
const [filter, setFilter] = useState<FilterType>("all");
const [selectedIndex, setSelectedIndex] = useState(-1);
const [transactionDetails, setTransactionDetails] =
useState<TransactionItem | null>(null);
const [loadingTransactionId, setLoadingTransactionId] = useState<
string | null
>(null);
const [isPending, startTransition] = useTransition();
// Edit dialog state
const [editOpen, setEditOpen] = useState(false);
const [transactionToEdit, setTransactionToEdit] =
useState<TransactionItem | null>(null);
const [dialogOptions, setDialogOptions] =
useState<TransactionDialogOptions | null>(null);
const filteredAttachments = attachments.filter((a) => {
if (filter === "images") return a.mimeType.startsWith("image/");
if (filter === "pdfs") return a.mimeType === "application/pdf";
return true;
});
const imageCount = attachments.filter((a) =>
a.mimeType.startsWith("image/"),
).length;
const pdfCount = attachments.filter(
(a) => a.mimeType === "application/pdf",
).length;
const counts: Record<FilterType, number> = {
all: attachments.length,
images: imageCount,
pdfs: pdfCount,
};
function handleSelect(attachment: AttachmentForPeriod) {
const idx = filteredAttachments.findIndex(
(a) =>
a.attachmentId === attachment.attachmentId &&
a.transactionId === attachment.transactionId,
);
setSelectedIndex(idx);
}
function handleDetails(transactionId: string) {
setLoadingTransactionId(transactionId);
startTransition(async () => {
const transaction = await fetchTransactionByIdAction(transactionId);
setLoadingTransactionId(null);
if (transaction) setTransactionDetails(transaction);
});
}
function handleEdit(transaction: TransactionItem) {
setTransactionToEdit(transaction);
startTransition(async () => {
const options = await fetchTransactionDialogOptionsAction();
setDialogOptions(options);
setEditOpen(true);
});
}
return (
<div className="w-full space-y-6">
<PageDescription
icon={<RiAttachmentLine className="size-5" />}
title="Anexos"
subtitle="Comprovantes e documentos dos seus lançamentos no mês."
/>
<MonthNavigation />
<Card>
<CardContent>
{attachments.length === 0 ? (
<div className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiAttachmentLine className="size-6 text-primary" />}
title="Nenhum anexo neste mês"
description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
/>
</div>
) : (
<div className="space-y-4">
{/* Header: filtros + contagem */}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{filteredAttachments.length}{" "}
{filteredAttachments.length === 1 ? "anexo" : "anexos"}
{filter !== "all" &&
` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`}
</p>
<div className="flex items-center gap-1 rounded-lg border p-1">
{FILTERS.map(({ value, label, icon }) => (
<button
key={value}
type="button"
onClick={() => {
setFilter(value);
setSelectedIndex(-1);
}}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
filter === value
? "bg-primary text-primary-foreground [&_svg]:opacity-100"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className={cn(filter !== value && "opacity-60")}>
{icon}
</span>
{label}{" "}
<span
className={cn(
"tabular-nums",
filter === value ? "opacity-80" : "opacity-60",
)}
>
({counts[value]})
</span>
</button>
))}
</div>
</div>
{filteredAttachments.length === 0 ? (
<div className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiAttachmentLine className="size-6 text-primary" />}
title="Nenhum anexo encontrado"
description="Não há anexos do tipo selecionado neste mês."
/>
</div>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{filteredAttachments.map((attachment) => (
<AttachmentGridItemWithUrl
key={`${attachment.attachmentId}-${attachment.transactionId}`}
attachment={attachment}
onClick={() => handleSelect(attachment)}
onDetails={() => handleDetails(attachment.transactionId)}
isLoadingDetails={
isPending &&
loadingTransactionId === attachment.transactionId
}
/>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
<AttachmentPreview
attachments={filteredAttachments}
selectedIndex={selectedIndex}
onClose={() => setSelectedIndex(-1)}
/>
<TransactionDetailsDialog
open={!!transactionDetails}
onOpenChange={(open) => {
if (!open) setTransactionDetails(null);
}}
transaction={transactionDetails}
onEdit={handleEdit}
/>
{dialogOptions && transactionToEdit && (
<TransactionDialog
mode="update"
open={editOpen}
onOpenChange={(open) => {
setEditOpen(open);
if (!open) {
setTransactionToEdit(null);
setDialogOptions(null);
router.refresh();
}
}}
transaction={transactionToEdit}
payerOptions={dialogOptions.payerOptions}
splitPayerOptions={dialogOptions.splitPayerOptions}
defaultPayerId={dialogOptions.defaultPayerId}
accountOptions={dialogOptions.accountOptions}
cardOptions={dialogOptions.cardOptions}
categoryOptions={dialogOptions.categoryOptions}
estabelecimentos={dialogOptions.estabelecimentos}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { fetchJson } from "@/shared/lib/fetch-json";
const ATTACHMENT_URL_STALE_TIME = 4 * 60 * 1000;
export const attachmentUrlQueryKey = (attachmentId: string) =>
["attachments", "url", attachmentId] as const;
export function useAttachmentUrlQuery(attachmentId: string, enabled: boolean) {
return useQuery({
queryKey: attachmentUrlQueryKey(attachmentId),
queryFn: async () => {
const payload = await fetchJson<{ url: string }>(
`/api/attachments/${attachmentId}/presign`,
);
return payload.url;
},
enabled: enabled && Boolean(attachmentId),
staleTime: ATTACHMENT_URL_STALE_TIME,
gcTime: ATTACHMENT_URL_STALE_TIME * 2,
});
}
export function useAttachmentUrl(attachmentId: string) {
const [isVisible, setIsVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
void attachmentId;
setIsVisible(false);
const el = containerRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0].isIntersecting) return;
observer.disconnect();
setIsVisible(true);
},
{ rootMargin: "150px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [attachmentId]);
const { data: url } = useAttachmentUrlQuery(attachmentId, isVisible);
return { url: url ?? null, containerRef };
}

View File

@@ -0,0 +1,70 @@
import { and, desc, eq } from "drizzle-orm";
import { cacheLife, cacheTag } from "next/cache";
import {
attachments,
categories,
transactionAttachments,
transactions,
} from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
export type AttachmentForPeriod = {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
transactionId: string;
transactionName: string;
transactionAmount: string;
transactionPeriod: string;
purchaseDate: Date;
categoryName: string | null;
categoryIcon: string | null;
};
export async function fetchAttachmentsForPeriod(
userId: string,
period: string,
): Promise<AttachmentForPeriod[]> {
"use cache";
cacheTag(`dashboard-${userId}`);
cacheLife({ revalidate: 3 });
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) return [];
return db
.select({
attachmentId: attachments.id,
fileName: attachments.fileName,
fileSize: attachments.fileSize,
mimeType: attachments.mimeType,
transactionId: transactions.id,
transactionName: transactions.name,
transactionAmount: transactions.amount,
transactionPeriod: transactions.period,
purchaseDate: transactions.purchaseDate,
categoryName: categories.name,
categoryIcon: categories.icon,
})
.from(transactionAttachments)
.innerJoin(
attachments,
and(
eq(transactionAttachments.attachmentId, attachments.id),
eq(attachments.userId, userId),
),
)
.innerJoin(
transactions,
and(
eq(transactionAttachments.transactionId, transactions.id),
eq(transactions.userId, userId),
eq(transactions.payerId, adminPayerId),
eq(transactions.period, period),
),
)
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.orderBy(desc(transactions.purchaseDate), desc(attachments.id));
}

View File

@@ -7,8 +7,8 @@ interface AuthHeaderProps {
export function AuthHeader({ title, description }: AuthHeaderProps) { export function AuthHeader({ title, description }: AuthHeaderProps) {
return ( return (
<div className={cn("flex flex-col gap-2")}> <div className={cn("flex flex-col gap-2.5")}>
<h1 className="text-2xl font-semibold tracking-tight text-card-foreground"> <h1 className="text-2xl font-medium tracking-tight text-card-foreground">
{title} {title}
</h1> </h1>
{description ? ( {description ? (

View File

@@ -0,0 +1,89 @@
import Image from "next/image";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatCurrency } from "@/shared/utils/currency";
type MockInvoice = {
cardName: string;
logo: string;
amount: number;
dueLabel: string;
};
const MOCK_INVOICES: MockInvoice[] = [
{
cardName: "Nubank",
logo: "nubank.png",
amount: 1898,
dueLabel: "Vence hoje",
},
{
cardName: "Itaú",
logo: "itau.png",
amount: 1923,
dueLabel: "Vence amanhã",
},
];
function MockInvoiceItem({
invoice,
divider,
}: {
invoice: MockInvoice;
divider: boolean;
}) {
const logoSrc = resolveLogoSrc(invoice.logo);
return (
<div className={divider ? "border-b border-border/60" : undefined}>
<div className="flex items-center justify-between py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-0.5">
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full">
{logoSrc && (
<Image
src={logoSrc}
alt={`Logo ${invoice.cardName}`}
width={36}
height={36}
className="h-full w-full object-contain"
/>
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{invoice.cardName}
</p>
<p className="text-xs text-muted-foreground">{invoice.dueLabel}</p>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<span className="text-sm font-medium tracking-tighter text-foreground">
{formatCurrency(invoice.amount)}
</span>
<span className="text-xs font-medium text-primary">Pagar</span>
</div>
</div>
</div>
);
}
export function AuthSidebarInvoicesMock() {
return (
<div className="rounded-xl border bg-card shadow-sm">
<div className="border-b px-4 py-3">
<span className="text-sm font-medium text-foreground">Faturas</span>
<p className="mt-0.5 text-xs text-muted-foreground">
Resumo das faturas do período
</p>
</div>
<div className="px-4">
{MOCK_INVOICES.map((invoice, index) => (
<MockInvoiceItem
key={invoice.cardName}
invoice={invoice}
divider={index < MOCK_INVOICES.length - 1}
/>
))}
</div>
</div>
);
}

View File

@@ -1,5 +1,28 @@
import {
RiBankCardLine,
RiBarChart2Line,
RiShieldCheckLine,
} from "@remixicon/react";
import { Logo } from "@/shared/components/logo"; import { Logo } from "@/shared/components/logo";
import { DotPattern } from "@/shared/components/ui/dot-pattern"; import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { AuthSidebarInvoicesMock } from "./auth-sidebar-invoices-mock";
function FeatureItem({
icon: Icon,
text,
}: {
icon: React.ComponentType<{ className?: string }>;
text: string;
}) {
return (
<div className="flex items-center gap-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-black/12">
<Icon className="h-3.5 w-3.5 text-black/55" />
</div>
<span className="text-sm font-medium text-black/68">{text}</span>
</div>
);
}
function AuthSidebar() { function AuthSidebar() {
return ( return (
@@ -15,6 +38,7 @@ function AuthSidebar() {
/> />
<div className="absolute inset-0 bg-linear-to-br from-white/9 via-transparent to-black/7" /> <div className="absolute inset-0 bg-linear-to-br from-white/9 via-transparent to-black/7" />
</div> </div>
<div className="relative flex flex-1 flex-col justify-between p-10 lg:p-12"> <div className="relative flex flex-1 flex-col justify-between p-10 lg:p-12">
<Logo <Logo
variant="compact" variant="compact"
@@ -22,14 +46,25 @@ function AuthSidebar() {
className="opacity-92 [&_img]:brightness-0 [&_img]:saturate-0" className="opacity-92 [&_img]:brightness-0 [&_img]:saturate-0"
/> />
<div className="max-w-sm space-y-4.5"> <div className="flex flex-1 items-center justify-center py-10">
<h2 className="text-[2rem] font-semibold leading-[1.04] tracking-[-0.03em] text-black/84 lg:text-[2.35rem]"> <div className="w-full rotate-[1.5deg]">
Controle suas finanças com clareza e foco diário. <AuthSidebarInvoicesMock />
</h2> </div>
<p className="max-w-2xs text-sm leading-6 text-black/68"> </div>
Centralize despesas, organize cartões e acompanhe metas mensais em
um painel inteligente feito para o seu dia a dia. <div className="space-y-3">
</p> <FeatureItem
icon={RiBarChart2Line}
text="Controle de gastos por categoria"
/>
<FeatureItem
icon={RiBankCardLine}
text="Faturas e cartões centralizados"
/>
<FeatureItem
icon={RiShieldCheckLine}
text="Seus dados, sem rastreamento"
/>
</div> </div>
</div> </div>
</div> </div>

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);
} }
} }
@@ -238,7 +240,7 @@ export function LoginForm({ className, ...props }: DivProps) {
</a> </a>
</FieldDescription> </FieldDescription>
<FieldDescription className="text-center text-[13px] text-muted-foreground"> <FieldDescription className="text-center text-sm text-muted-foreground">
<a href="/" className={authLinkClassName}> <a href="/" className={authLinkClassName}>
Voltar para a página inicial Voltar para a página inicial
</a> </a>

View File

@@ -277,7 +277,7 @@ export function SignupForm({ className, ...props }: DivProps) {
</a> </a>
</FieldDescription> </FieldDescription>
<FieldDescription className="text-center text-[13px] text-muted-foreground"> <FieldDescription className="text-center text-sm text-muted-foreground">
<a href="/" className={authLinkClassName}> <a href="/" className={authLinkClassName}>
Voltar para a página inicial Voltar para a página inicial
</a> </a>

View File

@@ -52,7 +52,7 @@ export function BudgetCard({
size="lg" size="lg"
/> />
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-base font-semibold leading-tight"> <h3 className="text-base font-medium leading-tight">
{formatCategoryName(budget)} {formatCategoryName(budget)}
</h3> </h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@@ -244,7 +244,7 @@ export function BudgetDialog({
<div className="space-y-3 rounded-md border p-3"> <div className="space-y-3 rounded-md border p-3">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Limite atual</span> <span className="text-muted-foreground">Limite atual</span>
<span className="font-semibold text-foreground"> <span className="font-medium text-foreground">
{formatCurrency(sliderValue)} {formatCurrency(sliderValue)}
</span> </span>
</div> </div>

View File

@@ -19,7 +19,7 @@ export function CalendarGrid({
}: CalendarGridProps) { }: CalendarGridProps) {
return ( return (
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none"> <div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <div className="grid grid-cols-7 text-sm font-medium uppercase tracking-wide text-muted-foreground">
{WEEK_DAYS_SHORT.map((dayName) => ( {WEEK_DAYS_SHORT.map((dayName) => (
<span key={dayName} className="px-3 py-2 text-center text-primary"> <span key={dayName} className="px-3 py-2 text-center text-primary">
{dayName} {dayName}

View File

@@ -110,9 +110,7 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
</div> </div>
{complement ? ( {complement ? (
<span <span className={cn("shrink-0 font-medium", style.accent ?? "text-xs")}>
className={cn("shrink-0 font-semibold", style.accent ?? "text-xs")}
>
{complement} {complement}
</span> </span>
) : null} ) : null}
@@ -153,7 +151,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<span <span
className={cn( className={cn(
"text-sm font-semibold leading-none", "text-sm font-medium leading-none",
day.isToday day.isToday
? "text-primary-foreground bg-primary size-5 rounded-full flex items-center justify-center" ? "text-primary-foreground bg-primary size-5 rounded-full flex items-center justify-center"
: "text-foreground/90", : "text-foreground/90",

View File

@@ -61,7 +61,7 @@ const renderLancamento = (
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span <span
className={`text-sm font-semibold leading-tight ${ className={`text-sm font-medium leading-tight ${
isPagamentoFatura && "text-success" isPagamentoFatura && "text-success"
}`} }`}
> >
@@ -74,7 +74,7 @@ const renderLancamento = (
</div> </div>
<span <span
className={cn( className={cn(
"text-sm font-semibold whitespace-nowrap", "text-sm font-medium whitespace-nowrap",
isReceita ? "text-success" : "text-foreground", isReceita ? "text-success" : "text-foreground",
)} )}
> >
@@ -103,7 +103,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<span className="text-sm font-semibold leading-tight"> <span className="text-sm font-medium leading-tight">
{event.transaction.name} {event.transaction.name}
</span> </span>
@@ -116,7 +116,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge> <Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
</div> </div>
<span className="font-semibold"> <span className="font-medium">
<MoneyValues amount={event.transaction.amount} /> <MoneyValues amount={event.transaction.amount} />
</span> </span>
</div> </div>
@@ -129,7 +129,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<span className="text-sm font-semibold leading-tight"> <span className="text-sm font-medium leading-tight">
Vencimento Invoice - {event.card.name} Vencimento Invoice - {event.card.name}
</span> </span>
</div> </div>
@@ -137,7 +137,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge> <Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge>
</div> </div>
{event.card.totalDue !== null ? ( {event.card.totalDue !== null ? (
<span className="font-semibold"> <span className="font-medium">
<MoneyValues amount={event.card.totalDue} /> <MoneyValues amount={event.card.totalDue} />
</span> </span>
) : null} ) : null}

View File

@@ -136,7 +136,7 @@ export function CardItem({
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base"> <h3 className="truncate text-sm font-medium text-foreground sm:text-base">
{name} {name}
</h3> </h3>
{note ? ( {note ? (
@@ -188,13 +188,13 @@ export function CardItem({
<div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm"> <div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm">
<span> <span>
Fecha dia{" "} Fecha dia{" "}
<span className="font-semibold text-foreground"> <span className="font-medium text-foreground">
{formatDay(closingDay)} {formatDay(closingDay)}
</span> </span>
</span> </span>
<span> <span>
Vence dia{" "} Vence dia{" "}
<span className="font-semibold text-foreground"> <span className="font-medium text-foreground">
{formatDay(dueDay)} {formatDay(dueDay)}
</span> </span>
</span> </span>
@@ -206,7 +206,7 @@ export function CardItem({
<> <>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-medium text-foreground">
<MoneyValues amount={metrics[0].value} /> <MoneyValues amount={metrics[0].value} />
</p> </p>
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
@@ -215,7 +215,7 @@ export function CardItem({
</div> </div>
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground"> <p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="size-2 rounded-full bg-primary" /> <span className="size-2 rounded-full bg-primary" />
<MoneyValues amount={metrics[1].value} /> <MoneyValues amount={metrics[1].value} />
</p> </p>
@@ -225,7 +225,7 @@ export function CardItem({
</div> </div>
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-medium text-foreground">
<MoneyValues amount={metrics[2].value} /> <MoneyValues amount={metrics[2].value} />
</p> </p>
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">

View File

@@ -80,7 +80,7 @@ export function CategoryDetailHeader({
size="lg" size="lg"
/> />
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-xl font-semibold leading-tight"> <h1 className="text-xl font-medium leading-tight">
{category.name} {category.name}
</h1> </h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
@@ -99,7 +99,7 @@ export function CategoryDetailHeader({
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {currentPeriodLabel} Total em {currentPeriodLabel}
</p> </p>
<p className="mt-1 text-2xl font-semibold"> <p className="mt-1 text-2xl font-medium">
{currencyFormatter.format(currentTotal)} {currencyFormatter.format(currentTotal)}
</p> </p>
</div> </div>
@@ -117,7 +117,7 @@ export function CategoryDetailHeader({
</p> </p>
<div <div
className={cn( className={cn(
"mt-1 flex items-center gap-1 text-xl font-semibold", "mt-1 flex items-center gap-1 text-xl font-medium",
variationColor, variationColor,
)} )}
> >

View File

@@ -3,6 +3,7 @@ import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date"; import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
import { import {
buildFinancialStatusLabel, buildFinancialStatusLabel,
buildRelativeFinancialStatusLabel,
formatFinancialDateLabel, formatFinancialDateLabel,
} from "@/shared/utils/financial-dates"; } from "@/shared/utils/financial-dates";
@@ -24,6 +25,14 @@ export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
}); });
}; };
export const buildBillWidgetStatusLabel = (bill: BillStatusDateItem) => {
return buildRelativeFinancialStatusLabel({
isSettled: bill.isSettled,
dueDate: bill.dueDate,
paidAt: bill.boletoPaymentDate,
});
};
export const getCurrentBillDateString = () => getBusinessDateString(); export const getCurrentBillDateString = () => getBusinessDateString();
export const isBillOverdue = (bill: DashboardBill) => { export const isBillOverdue = (bill: DashboardBill) => {

View File

@@ -1,10 +1,15 @@
"use server"; "use server";
import { and, asc, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { transactions } from "@/db/schema"; import { transactions } from "@/db/schema";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { toDateOnlyString } from "@/shared/utils/date"; import {
compareDateOnly,
getBusinessDateString,
isDateOnlyPast,
toDateOnlyString,
} from "@/shared/utils/date";
import { safeToNumber as toNumber } from "@/shared/utils/number"; import { safeToNumber as toNumber } from "@/shared/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto"; const PAYMENT_METHOD_BOLETO = "Boleto";
@@ -33,10 +38,31 @@ export type DashboardBillsSnapshot = {
pendingCount: number; pendingCount: number;
}; };
const compareDateOnlyAscWithNullsLast = (
left: string | null,
right: string | null,
) => {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return compareDateOnly(left, right);
};
const compareDateOnlyDescWithNullsLast = (
left: string | null,
right: string | null,
) => {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return compareDateOnly(right, left);
};
export async function fetchDashboardBills( export async function fetchDashboardBills(
userId: string, userId: string,
period: string, period: string,
): Promise<DashboardBillsSnapshot> { ): Promise<DashboardBillsSnapshot> {
const today = getBusinessDateString();
const adminPayerId = await getAdminPayerId(userId); const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) { if (!adminPayerId) {
return { bills: [], totalPendingAmount: 0, pendingCount: 0 }; return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
@@ -59,11 +85,6 @@ export async function fetchDashboardBills(
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO), eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(transactions.payerId, adminPayerId), eq(transactions.payerId, adminPayerId),
), ),
)
.orderBy(
asc(transactions.isSettled),
asc(transactions.dueDate),
asc(transactions.name),
); );
const bills = rows.map((row: RawDashboardBill): DashboardBill => { const bills = rows.map((row: RawDashboardBill): DashboardBill => {
@@ -78,6 +99,55 @@ export async function fetchDashboardBills(
}; };
}); });
bills.sort((a, b) => {
if (a.isSettled !== b.isSettled) {
return a.isSettled ? 1 : -1;
}
if (!a.isSettled && !b.isSettled) {
const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false;
const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false;
if (aIsOverdue !== bIsOverdue) {
return aIsOverdue ? -1 : 1;
}
const dueDateDiff = compareDateOnlyAscWithNullsLast(a.dueDate, b.dueDate);
if (dueDateDiff !== 0) {
return dueDateDiff;
}
const amountDiff = b.amount - a.amount;
if (amountDiff !== 0) {
return amountDiff;
}
}
if (a.isSettled && b.isSettled) {
const paidAtDiff = compareDateOnlyDescWithNullsLast(
a.boletoPaymentDate,
b.boletoPaymentDate,
);
if (paidAtDiff !== 0) {
return paidAtDiff;
}
const amountDiff = b.amount - a.amount;
if (amountDiff !== 0) {
return amountDiff;
}
}
const nameDiff = a.name.localeCompare(b.name, "pt-BR", {
sensitivity: "base",
});
if (nameDiff !== 0) {
return nameDiff;
}
return a.id.localeCompare(b.id);
});
let totalPendingAmount = 0; let totalPendingAmount = 0;
let pendingCount = 0; let pendingCount = 0;

View File

@@ -1,5 +1,10 @@
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray, sql } from "drizzle-orm";
import { budgets, categories, transactions } from "@/db/schema"; import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import { import {
buildCategoryBreakdownData, buildCategoryBreakdownData,
type DashboardCategoryBreakdownData, type DashboardCategoryBreakdownData,
@@ -8,6 +13,7 @@ import {
import { import {
buildDashboardAdminFilters, buildDashboardAdminFilters,
excludeAutoInvoiceEntries, excludeAutoInvoiceEntries,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters"; } from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -39,6 +45,10 @@ export async function fetchExpensesByCategory(
}) })
.from(transactions) .from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id)) .innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where( .where(
and( and(
...buildDashboardAdminFilters({ userId, adminPayerId }), ...buildDashboardAdminFilters({ userId, adminPayerId }),
@@ -46,6 +56,7 @@ export async function fetchExpensesByCategory(
eq(transactions.transactionType, "Despesa"), eq(transactions.transactionType, "Despesa"),
eq(categories.type, "despesa"), eq(categories.type, "despesa"),
excludeAutoInvoiceEntries(), excludeAutoInvoiceEntries(),
excludeTransactionsFromExcludedAccounts(),
), ),
) )
.groupBy( .groupBy(

View File

@@ -14,6 +14,7 @@ import {
buildDashboardAdminFilters, buildDashboardAdminFilters,
excludeAutoInvoiceEntries, excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured, excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters"; } from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -57,6 +58,7 @@ export async function fetchIncomeByCategory(
eq(categories.type, "receita"), eq(categories.type, "receita"),
excludeAutoInvoiceEntries(), excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(), excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
), ),
) )
.groupBy( .groupBy(

View File

@@ -20,6 +20,7 @@ import {
buildDashboardAdminFilters, buildDashboardAdminFilters,
excludeAutoInvoiceEntries, excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured, excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters"; } from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -156,6 +157,7 @@ export async function fetchDashboardCategoryOverview(
and( and(
...buildDashboardAdminFilters({ userId, adminPayerId }), ...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]), inArray(transactions.period, [period, previousPeriod]),
excludeTransactionsFromExcludedAccounts(),
or( or(
and( and(
eq(transactions.transactionType, "Despesa"), eq(transactions.transactionType, "Despesa"),

View File

@@ -1,12 +1,18 @@
import { RiCheckboxCircleFill } from "@remixicon/react"; import { RiCheckboxCircleFill } from "@remixicon/react";
import { import {
buildBillStatusLabel, buildBillStatusLabel,
buildBillWidgetStatusLabel,
isBillOverdue, isBillOverdue,
} from "@/features/dashboard/bills-helpers"; } from "@/features/dashboard/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills-queries"; import type { DashboardBill } from "@/features/dashboard/bills-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
type BillListItemProps = { type BillListItemProps = {
@@ -15,8 +21,13 @@ type BillListItemProps = {
}; };
export function BillListItem({ bill, onPay }: BillListItemProps) { export function BillListItem({ bill, onPay }: BillListItemProps) {
const statusLabel = buildBillStatusLabel(bill); const statusLabel = buildBillWidgetStatusLabel(bill);
const absoluteStatusLabel = buildBillStatusLabel(bill);
const overdue = isBillOverdue(bill); const overdue = isBillOverdue(bill);
const statusTooltipLabel =
statusLabel && statusLabel !== absoluteStatusLabel
? absoluteStatusLabel
: null;
return ( return (
<li className="flex items-center justify-between transition-all duration-300 py-1.5"> <li className="flex items-center justify-between transition-all duration-300 py-1.5">
@@ -29,14 +40,32 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
</span> </span>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{statusLabel ? ( {statusLabel ? (
<span statusTooltipLabel ? (
className={cn( <Tooltip>
"rounded-full py-0.5", <TooltipTrigger asChild>
bill.isSettled && "text-success", <span
)} className={cn(
> "cursor-help rounded-full py-0.5",
{statusLabel} bill.isSettled && "text-success",
</span> )}
>
{statusLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{statusTooltipLabel}
</TooltipContent>
</Tooltip>
) : (
<span
className={cn(
"rounded-full py-0.5",
bill.isSettled && "text-success",
)}
>
{statusLabel}
</span>
)
) : null} ) : null}
</div> </div>
</div> </div>

View File

@@ -56,7 +56,7 @@ export function BillPaymentDialog({
}} }}
> >
<DialogContent <DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md" className="max-w-[calc(100%-2rem)] sm:max-w-md sm:p-8"
onEscapeKeyDown={(event) => { onEscapeKeyDown={(event) => {
if (isProcessing) { if (isProcessing) {
event.preventDefault(); event.preventDefault();
@@ -93,11 +93,11 @@ export function BillPaymentDialog({
{bill ? ( {bill ? (
<div className="space-y-3"> <div className="space-y-3">
{/* Card principal */} {/* Card principal */}
<div className="rounded-xl border bg-muted/30 p-4"> <div className="rounded-xl border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide"> <p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Boleto Boleto
</p> </p>
<p className="text-base font-semibold text-foreground"> <p className="text-base font-medium text-foreground">
{bill.name} {bill.name}
</p> </p>
</div> </div>
@@ -107,24 +107,24 @@ export function BillPaymentDialog({
<div className="rounded-xl border p-3"> <div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground"> <div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<RiMoneyDollarCircleLine className="size-3.5" /> <RiMoneyDollarCircleLine className="size-3.5" />
<span className="text-[11px] font-semibold uppercase tracking-wide"> <span className="text-xs font-medium uppercase tracking-wide">
Valor Valor
</span> </span>
</div> </div>
<MoneyValues <MoneyValues
amount={bill.amount} amount={bill.amount}
className="text-lg font-bold" className="text-lg font-medium"
/> />
</div> </div>
<div className="rounded-xl border p-3"> <div className="rounded-xl border p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground"> <div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<RiCalendarLine className="size-3.5" /> <RiCalendarLine className="size-3.5" />
<span className="text-[11px] font-semibold uppercase tracking-wide"> <span className="text-xs font-medium uppercase tracking-wide">
Vencimento Vencimento
</span> </span>
</div> </div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-medium text-foreground">
{dueLabel?.replace("Vencimento: ", "") ?? "—"} {dueLabel?.replace("Vencimento: ", "") ?? "—"}
</p> </p>
</div> </div>

View File

@@ -343,10 +343,10 @@ export function CategoryBreakdownWidgetView({
<div className="rounded-lg border bg-background p-2 shadow-sm"> <div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground"> <span className="text-xs uppercase text-muted-foreground">
{entry.name} {entry.name}
</span> </span>
<span className="font-bold text-foreground"> <span className="font-medium text-foreground">
{formatCurrency(entry.value)} {formatCurrency(entry.value)}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">

View File

@@ -76,6 +76,9 @@ export function DashboardGridEditable({
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>( const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
initialPreferences?.hidden ?? [], initialPreferences?.hidden ?? [],
); );
const [myAccountsShowExcluded, setMyAccountsShowExcluded] = useState(
initialPreferences?.myAccountsShowExcluded ?? true,
);
// Keep track of original state for cancel // Keep track of original state for cancel
const [originalOrder, setOriginalOrder] = useState(widgetOrder); const [originalOrder, setOriginalOrder] = useState(widgetOrder);
@@ -186,6 +189,7 @@ export function DashboardGridEditable({
if (result.success) { if (result.success) {
setWidgetOrder(DEFAULT_WIDGET_ORDER); setWidgetOrder(DEFAULT_WIDGET_ORDER);
setHiddenWidgets([]); setHiddenWidgets([]);
setMyAccountsShowExcluded(true);
toast.success("Preferências restauradas!"); toast.success("Preferências restauradas!");
} else { } else {
toast.error(result.error ?? "Erro ao restaurar"); toast.error(result.error ?? "Erro ao restaurar");
@@ -337,7 +341,7 @@ export function DashboardGridEditable({
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center"> <div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<RiDragMove2Line className="size-8 text-primary" /> <RiDragMove2Line className="size-8 text-primary" />
<span className="text-xs font-bold"> <span className="text-xs font-medium">
Arraste para mover Arraste para mover
</span> </span>
<Button <Button
@@ -361,7 +365,16 @@ export function DashboardGridEditable({
icon={widget.icon} icon={widget.icon}
action={widget.action} action={widget.action}
> >
{widget.component({ data, period })} {widget.component({
data,
period,
widgetPreferences: {
order: widgetOrder,
hidden: hiddenWidgets,
myAccountsShowExcluded,
},
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
})}
</ExpandableWidgetCard> </ExpandableWidgetCard>
</div> </div>
</SortableWidget> </SortableWidget>

View File

@@ -7,6 +7,7 @@ import {
RiScalesLine, RiScalesLine,
RiSubtractLine, RiSubtractLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries"; import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { import {
@@ -36,6 +37,14 @@ const CARDS = [
icon: RiArrowDownLine, icon: RiArrowDownLine,
invertTrend: false, invertTrend: false,
iconClass: "text-success", iconClass: "text-success",
helpTitle: "Como calculamos receitas",
helpLines: [
"Somamos os lançamentos do tipo Receita no período selecionado.",
"Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.",
"Saldo inicial também fica fora quando a conta está marcada para desconsiderá-lo das receitas.",
],
}, },
{ {
label: "Despesas", label: "Despesas",
@@ -44,14 +53,29 @@ const CARDS = [
icon: RiArrowUpLine, icon: RiArrowUpLine,
invertTrend: true, invertTrend: true,
iconClass: "text-destructive", iconClass: "text-destructive",
helpTitle: "Como calculamos despesas",
helpLines: [
"Somamos os lançamentos do tipo Despesa no período selecionado.",
"Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.",
"O valor mostrado é a saída efetiva do período, sempre em número positivo no card.",
],
}, },
{ {
label: "Balanço", label: "Balanço",
subtitle: "Receitas menos despesas", subtitle: "Receitas, despesas e ajustes entre contas",
key: "balanco", key: "balanco",
icon: RiScalesLine, icon: RiScalesLine,
invertTrend: false, invertTrend: false,
iconClass: "text-warning", iconClass: "text-warning",
helpTitle: "Como calculamos o balanço",
helpLines: [
"Partimos de receitas menos despesas do período.",
"Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora do cálculo base.",
"Depois aplicamos ajustes de transferências entre contas consideradas e não consideradas no saldo total.",
"Se a transferência entra em conta considerada, soma. Se sai de conta considerada para conta não considerada, subtrai.",
],
}, },
{ {
label: "Previsto", label: "Previsto",
@@ -60,6 +84,13 @@ const CARDS = [
icon: RiCalendarCheckLine, icon: RiCalendarCheckLine,
invertTrend: false, invertTrend: false,
iconClass: "text-cyan-600", iconClass: "text-cyan-600",
helpTitle: "Como calculamos o previsto",
helpLines: [
"Acumulamos o balanço mês a mês até o período atual.",
"Ele usa a mesma regra do card de balanço em cada mês do histórico.",
"Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora desse acumulado.",
"Por isso também reflete ajustes de transferências entre contas consideradas e não consideradas.",
],
}, },
] as const; ] as const;
@@ -104,7 +135,16 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
return ( return (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"> <div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{CARDS.map( {CARDS.map(
({ label, subtitle, key, icon: Icon, invertTrend, iconClass }) => { ({
label,
subtitle,
key,
icon: Icon,
invertTrend,
iconClass,
helpTitle,
helpLines,
}) => {
const metric = metrics[key]; const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous); const trend = getTrend(metric.current, metric.previous);
const TrendIcon = TREND_ICONS[trend]; const TrendIcon = TREND_ICONS[trend];
@@ -119,9 +159,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<CardTitle className="flex items-center gap-1 tracking-tight"> <CardTitle className="flex items-center gap-1.5 tracking-tight">
<Icon className={cn("size-4", iconClass)} aria-hidden /> <Icon className={cn("size-4", iconClass)} aria-hidden />
{label} {label}
<MetricsCardInfoButton
label={label}
helpTitle={helpTitle}
helpLines={helpLines}
/>
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5 tracking-tight"> <CardDescription className="mt-1.5 tracking-tight">
{subtitle} {subtitle}
@@ -134,12 +179,12 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2 mt-1"> <div className="flex flex-wrap items-center justify-between gap-2 mt-1">
<MoneyValues <MoneyValues
className="text-[1.55rem] leading-none font-medium" className="text-2xl leading-none"
amount={metric.current} amount={metric.current}
/> />
<div <div
className={cn( className={cn(
"inline-flex items-center gap-1 text-xs font-medium", "inline-flex items-center gap-1 text-xs ",
trendBadgeClass, trendBadgeClass,
)} )}
> >
@@ -150,7 +195,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
<MoneyValues <MoneyValues
className="inline text-xs font-medium text-muted-foreground" className="inline text-xs text-muted-foreground"
amount={metric.previous} amount={metric.previous}
/> />
<span className="ml-1">no mês anterior</span> <span className="ml-1">no mês anterior</span>

View File

@@ -8,7 +8,7 @@ export function DashboardWelcome({ name }: { name?: string | null }) {
return ( return (
<section className="py-4"> <section className="py-4">
<div className="tracking-tight"> <div className="tracking-tight">
<h1 className="text-xl"> <h1 className="text-xl font-medium">
{greeting}, {displayName} {greeting}, {displayName}
</h1> </h1>
<h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2> <h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2>

View File

@@ -137,7 +137,7 @@ export function InstallmentAnalysisPage({
</p> </p>
<MoneyValues <MoneyValues
amount={grandTotal} amount={grandTotal}
className="text-3xl font-bold text-primary" className="text-3xl font-medium text-primary"
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "} {selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}

Some files were not shown because too many files have changed in this diff Show More