43 Commits

Author SHA1 Message Date
Felipe Coutinho
bf6adfa3f1 chore(analytics): mover configuração do Umami para variáveis de ambiente
- UMAMI_URL, UMAMI_WEBSITE_ID e UMAMI_DOMAINS carregados via process.env
- script só é injetado se as vars estiverem definidas
- CSP atualizada dinamicamente com base no UMAMI_URL
- documentado no .env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:50:03 +00:00
Felipe Coutinho
e4b9dd4254 chore: versão 2.3.7 — corrigir versão e consolidar CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:49 +00:00
Felipe Coutinho
f1907c8697 fix(settings): ajuste de indentação e texto no formulário de exclusão de conta
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:46 +00:00
Felipe Coutinho
805bcb863d fix(logo-picker): corrigir renderização de miniaturas no modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:43 +00:00
Felipe Coutinho
11b4f8940f feat(landing): aba de insights de IA e screenshots atualizados em webp lossless
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:40 +00:00
Felipe Coutinho
fba9686fdb feat(dashboard): tendências top 10 e padronização de espaçamento do inbox
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:38 +00:00
Felipe Coutinho
9b8ac9f71f feat(payers): upload de avatar via arquivo com redimensionamento client-side
- círculo de upload no final da grade de avatares abre seletor de arquivo
- imagem redimensionada para 200×200px via Canvas e salva como base64
- suporte a data URLs em next/image com prop unoptimized
- object-cover adicionado ao componente base Avatar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 22:43:28 +00:00
Felipe Coutinho
fa41c78a39 feat(navbar): copiar user ID ao lado do nome no menu do usuário
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 18:16:18 +00:00
Felipe Coutinho
5f7bfb98da feat(settings): aba de diagnóstico e cópia de user ID no menu do usuário
- Nova aba "Diagnóstico" em Settings com:
  - Identidade: user ID (copiável), nome, e-mail
  - Sessão: criada em / expira em
  - Aplicação: versão, NODE_ENV, build SHA (se definido)
  - Configuração do servidor: S3, e-mail e domínio público — apenas booleans, sem expor credenciais
  - Saúde: status e latência do banco de dados
  - Uso: contagem de lançamentos, anexos, anotações e itens no inbox
- Botão de cópia do user ID no dropdown do avatar (ao lado do e-mail)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 18:06:40 +00:00
Felipe Coutinho
9ecafdb15f docs: atualizar CLAUDE.md e README; adicionar script de instalação Ubuntu
- CLAUDE.md: rota attachments/ adicionada ao mapa de diretórios (app e features);
  seção Response Style substituída por Security Rules
- README.md: instruções para servidor Ubuntu 24.04, preview do Companion,
  seção de Backup; menção ao Companion atualizada
- scripts/install-deps.sh: prepara VPS Ubuntu limpa instalando Docker,
  Node.js 22 e pnpm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:39 +00:00
Felipe Coutinho
e8cc673e52 style(ui): padronizar tipografia — font-medium para font-semibold
Padronização de peso tipográfico em títulos, rótulos de seção,
nomes de entidades e valores monetários em toda a interface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:34 +00:00
Felipe Coutinho
3bd8117b65 fix(i18n): corrigir textos "Payer" para "Pagador" em mensagens de erro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:21 +00:00
Felipe Coutinho
a7268d8f05 feat(inbox): redesenho do card de pré-lançamento
Logo maior (40px), nome do app em font-semibold, data em linha
separada e valor monetário em destaque — melhor hierarquia visual.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:17 +00:00
Felipe Coutinho
1f9098879e feat(parcelas): redesenho do card de grupo com dialog de detalhes
Card de grupo de parcelas ganhou um dialog ao clicar em "Ver detalhes",
separando parcelas pagas e pendentes, com seleção parcial e logo do
estabelecimento. Substituída lógica de expand inline pelo dialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:14 +00:00
Felipe Coutinho
7a3bff52ac feat(dashboard): novos widgets de anexos, inbox e tendências de categoria
- Widget Anexos: resumo de arquivos do período (total, imagens, PDFs, recentes)
- Widget Inbox: snapshot de pré-lançamentos pendentes do Companion
- Widget Tendências de Categoria: redireciona para relatório de tendências
- fetch-dashboard-data: busca attachmentsSnapshot e inboxSnapshot em paralelo
- widgets-config: tipo DashboardWidgetQuickActionOptions centralizado; props
  adminPayerSlug e quickActionOptions adicionadas ao contrato do widget
- dashboard-grid-editable: usa o novo tipo unificado de quickActionOptions
- proxy.ts: frame-src adicionado à CSP para preview de PDFs via S3
- rota /attachments criada com layout próprio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:51:09 +00:00
Felipe Coutinho
dfb4126b12 feat(lancamentos): filtros de status e anexo; feedback visual de fatura paga
- Novos filtros no drawer: somente pagos, somente não pagos, com anexo
- Filtros de tipo/condição/pagamento agora usam slugs na URL (sem acentos)
- Coluna de liquidação: lançamentos de cartão com fatura paga exibem ícone
  verde com tooltip — diferenciando do estado pendente
- EstabelecimentoInput: popover respeita largura do input ao abrir
- slugify extraído para shared/utils/string.ts
- INVOICE_PAYMENT_CATEGORY_NAME adicionado em categories/constants.ts
- SETTLED_FILTER_VALUES adicionado em transactions/constants.ts
- establishment-logo.tsx removido (não utilizado)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:50:59 +00:00
Felipe Coutinho
ffead579fa feat(fontes): substituir fonte local America por Inter (Google Fonts)
Next.js self-hosta a Inter em build time — elimina os arquivos .woff2
do repositório e a dependência de localFont.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:50:50 +00:00
Felipe Coutinho
aa85cf8b29 fix(docker,s3): corrigir CRLF no entrypoint e região S3 vazia — v2.3.7
- Adicionado .gitattributes com eol=lf para scripts shell e Dockerfile
- Dockerfile: sed -i 's/\r$//' no entrypoint para eliminar CRLF em ambientes Windows/WSL2
- s3-client.ts: substituído ?? por || para tratar string vazia em S3_REGION e demais vars
- CHANGELOG, package.json e lockfile atualizados para v2.3.7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 17:50:44 +00:00
Felipe Coutinho
9a7ae0fa3d fix(docker): adicionar NODE_PATH no entrypoint para resolução do drizzle-orm
Corrige erro "Cannot find module 'drizzle-orm'" ao rodar migrations no
container — o drizzle-kit em /app/migrate/ não encontrava o módulo sem
NODE_PATH apontando para o node_modules isolado.

Closes #34

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:35:52 +00:00
Felipe Coutinho
98fe6a0f4f Update version badge from 2.3.4 to 2.3.5 2026-04-07 10:53:10 -03:00
Felipe Coutinho
d10eae13e5 Revise versioning and commit message guidelines
Updated versioning instructions to include README.md updates and clarified commit message guidelines.
2026-04-07 10:52:39 -03:00
Felipe Coutinho
43697b4fd2 fix(csp): mover CSP para proxy.ts para leitura em runtime
Content-Security-Policy estava em next.config.ts (build time),
então S3_ENDPOINT nunca era incluído no connect-src ao buildar
via Docker no CI. Movido para proxy.ts que avalia em runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:49:23 +00:00
Felipe Coutinho
27e3ba5f0d Update version badge from 2.1.2 to 2.3.4 2026-04-05 20:33:55 -03:00
Felipe Coutinho
31485eec8f fix(csp): permitir upload de anexos para o storage externo
connect-src bloqueava fetch para o Supabase Storage desde o commit
de segurança (10afef9). Adiciona a origin do S3_ENDPOINT na política.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:47:23 +00:00
Felipe Coutinho
3be64aa8d0 fix(auth): corrigir verify e unificar tokens com prefixo opm_
- Corrige /api/auth/device/verify que rejeitava tokens criados via
  Settings (revertido de JWT para hash lookup)
- Renomeia prefixo de tokens de os_ para opm_ (OpenMonetis)
- Remove rotas JWT não utilizadas (token, refresh)
- Simplifica api-token.ts mantendo apenas hashToken e extractBearerToken

BREAKING CHANGE: tokens existentes com prefixo os_ param de funcionar.
Revogar e recriar tokens após o deploy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:05:03 +00:00
Felipe Coutinho
85f6dcfc22 fix(csp): permitir unsafe-eval apenas em desenvolvimento
React precisa de eval() em dev para reconstruir stack traces.
Produção continua sem unsafe-eval.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:21:03 +00:00
Felipe Coutinho
df996df93d fix(segurança): substituir xlsx por exceljs (CVEs sem patch no npm)
xlsx@0.18.5 tem Prototype Pollution e ReDoS sem versão corrigida no
npm. Migrado para exceljs@4.4.0 nos 4 pontos de uso: parser de
importação, geração de template, exportação de lançamentos e
exportação de relatório de categorias.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:12:04 +00:00
Felipe Coutinho
10afef9fec fix(segurança): corrigir 10 vulnerabilidades do relatório de segurança
- tokens: remover aceite de expiresAt NULL e forçar TTL de 1 ano
- tokens: corrigir refresh que invalidava access token anterior
- xlsx: desabilitar parsing de fórmulas (CVE-2024-44294)
- csp: expandir Content-Security-Policy com origens explícitas
- headers: adicionar Referrer-Policy e X-Permitted-Cross-Domain-Policies
- api: retornar 401 JSON em vez de redirect 302 em rotas autenticadas
- health: remover version disclosure do /api/health
- robots.txt: simplificar para não expor rotas internas
- sitemap: corrigir URL com protocolo duplicado
- criar security.txt (RFC 9116)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:47:05 +00:00
Felipe Coutinho
fd4d90a53e ci: forçar Node.js 24 nas actions do workflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:52:52 +00:00
Felipe Coutinho
a24406271c chore: corrigir formatacao do package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:51:14 +00:00
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
261 changed files with 9648 additions and 5000 deletions

View File

@@ -44,6 +44,13 @@ GOOGLE_CLIENT_SECRET=
# Se não definido, todas as rotas ficam acessíveis. # Se não definido, todas as rotas ficam acessíveis.
# PUBLIC_DOMAIN=openmonetis.com # PUBLIC_DOMAIN=openmonetis.com
# === Analytics (Opcional) ===
# Umami: https://umami.is — self-hosted ou cloud
UMAMI_URL=
UMAMI_WEBSITE_ID=
# Domínios rastreados (ex: openmonetis.com) — corresponde ao data-domains do script
UMAMI_DOMAINS=
# === AI Providers (Opcional) === # === AI Providers (Opcional) ===
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
# Força LF para arquivos que precisam de line endings Unix no container
*.sh text eol=lf
docker-entrypoint.sh text eol=lf
Dockerfile text eol=lf

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

@@ -13,10 +13,35 @@ on:
env: env:
DOCKER_IMAGE_NAME: openmonetis DOCKER_IMAGE_NAME: openmonetis
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
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

View File

@@ -5,6 +5,9 @@ on:
branches: branches:
- main - main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -7,6 +7,124 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [Unreleased]
## [2.3.7] - 2026-04-11
### Adicionado
- Dashboard: novos widgets configuráveis — Anexos (resumo de arquivos do período), Inbox (snapshot de pré-lançamentos pendentes) e Tendências de Categoria
- Lançamentos: filtro por status de pagamento (somente pagos / somente não pagos) e filtro por presença de anexo
- Lançamentos: indicador visual no status de liquidação para lançamentos de cartão de crédito com fatura paga — exibe ícone verde com tooltip explicativo
- Scripts: `scripts/install-deps.sh` — script de preparação para servidores Ubuntu 24.04 limpos (instala Docker, Node.js 22, pnpm via Homebrew)
### Alterado
- Fonte: substituída fonte local `America` por `Inter` (Google Fonts, self-hosted pelo Next.js) — elimina arquivos `.woff2` do repositório
- Tipografia: peso tipográfico padronizado de `font-medium` para `font-semibold` em títulos, rótulos e valores monetários em toda a interface
- Parcelas: redesenho do card de grupo de parcelas — expandindo para dialog de detalhes com parcelas pagas/pendentes separadas
- Inbox: redesenho do card de pré-lançamento — logo maior, hierarquia tipográfica melhorada
- Lançamentos: filtros de tipo, condição e forma de pagamento agora usam slugs em URL (ex: `receita` em vez do valor literal com acentos)
- Estabelecimento: popover de autocomplete agora respeita a largura do input ao abrir
- CSP: adicionado `frame-src` para permitir preview de anexos PDF via S3
### Corrigido
- Docker: corrigido crash loop no container com mensagem `exec /app/docker-entrypoint.sh: no such file or directory` causado por CRLF no `docker-entrypoint.sh` em ambientes Windows/WSL2 — adicionado `sed -i 's/\r$//'` no Dockerfile e `.gitattributes` com `eol=lf` para scripts shell
- S3: corrigido `Error: Region is missing` ao usar o app sem S3 configurado — `S3_REGION` vazio (string vazia) não era tratado pelo operador `??`; substituído por `||` em todo o `s3-client.ts`
- i18n: corrigidas mensagens de erro que exibiam "Payer" em inglês em vez de "Pagador"
- Logos: corrigido modal seletor de logos de cartões e contas para renderizar miniaturas sem avisos de proporção
## [2.3.6] - 2026-04-09
### Corrigido
- Docker: adicionado `NODE_PATH=/app/migrate/node_modules` no entrypoint para que o `drizzle-kit` consiga resolver `drizzle-orm` ao executar as migrations no container
## [2.3.5] - 2026-04-07
### Corrigido
- CSP: movido `Content-Security-Policy` do `next.config.ts` (build time) para `proxy.ts` (runtime), corrigindo bloqueio de upload de anexos quando `S3_ENDPOINT` não estava disponível durante o build do Docker
## [2.3.4] - 2026-04-05
### Corrigido
- Anexos: corrigido upload que falhava com `NetworkError` — CSP `connect-src` bloqueava fetch para o Storage
## [2.3.3] - 2026-04-05
### Corrigido
- Tokens: corrigido `/api/auth/device/verify` que rejeitava tokens criados via Settings (revertido de JWT para hash lookup)
### Alterado
- Tokens: prefixo renomeado de `os_` para `opm_` (OpenMonetis); tokens existentes precisam ser recriados
- Tokens: removidas rotas JWT não utilizadas (`/api/auth/device/token` e `/api/auth/device/refresh`)
- Tokens: `api-token.ts` simplificado para conter apenas `hashToken` e `extractBearerToken`
## [2.3.2] - 2026-04-04
### Segurança
- Tokens: removido aceite de tokens sem expiração (`expiresAt NULL`); tokens criados via settings agora expiram em 1 ano
- Tokens: corrigido refresh que sobrescrevia hash e invalidava access token anterior; verify agora valida JWT por assinatura
- xlsx: desabilitado parsing de fórmulas (`cellFormula: false`) para mitigar CVE-2024-44294
- CSP: expandida Content-Security-Policy com `default-src`, `script-src`, `style-src`, `img-src`, `font-src` e `connect-src`
- Headers: adicionados `Referrer-Policy` e `X-Permitted-Cross-Domain-Policies`
- API: rotas autenticadas agora retornam `401 JSON` em vez de redirect `302` para clientes não autenticados
- Health: removido campo `version` da resposta do `/api/health`
- robots.txt: simplificado para não expor mapa de rotas internas
- Sitemap: corrigida URL com protocolo duplicado (`https://https://`)
- Criado `security.txt` (RFC 9116)
## [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 ## [2.2.0] - 2026-04-01
### Adicionado ### Adicionado

View File

@@ -16,9 +16,10 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`. 3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`. 4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations. 5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json`. 6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json` e `readme.md` (Badges do README.md).
7. **Comunicacao**: responder em portugues clara e direta com o time. 7. **Comunicacao**: responder em portugues clara e direta com o time.
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema. 8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.
--- ---
@@ -84,6 +85,7 @@ src/
│ │ ├── insights/ │ │ ├── insights/
│ │ ├── calendar/ │ │ ├── calendar/
│ │ ├── inbox/ │ │ ├── inbox/
│ │ ├── attachments/
│ │ ├── changelog/ │ │ ├── changelog/
│ │ ├── reports/ │ │ ├── reports/
│ │ │ ├── category-trends/ │ │ │ ├── category-trends/
@@ -110,6 +112,7 @@ src/
│ ├── insights/ │ ├── insights/
│ ├── calendar/ │ ├── calendar/
│ ├── inbox/ │ ├── inbox/
│ ├── attachments/
│ ├── reports/ │ ├── reports/
│ └── settings/ │ └── settings/
├── shared/ ├── shared/
@@ -306,18 +309,29 @@ export async function fetchData(userId: string, period: string) {
--- ---
## Response Style ## Security Rules
Quando o time pedir avaliacao de plano ou feature: Regras aplicadas automaticamente ao gerar codigo.
1. Responder em portugues simples. ### Secrets
2. Listar 3-5 problemas principais. Nunca colocar API keys, credenciais de banco ou tokens em codigo frontend. Evitar variaveis prefixadas com `NEXT_PUBLIC_` para dados sensiveis — estas sao bundladas no cliente. Usar variaveis server-side apenas. `.env` deve estar no `.gitignore` antes do primeiro commit. `.env.example` deve ter apenas placeholders.
3. Fechar com decisao pratica:
- aprova agora
- nao aprova agora
- o que ajustar antes de comecar codigo
Exemplo: ### Autenticacao & Autorizacao
Toda rota protegida em `src/app/api/` requer `getUser()` ou `getOptionalUserSession()` antes de qualquer logica, retornando 401 para nao autenticados. Rotas com IDs de recursos devem verificar ownership: `eq(table.userId, userId)`. Rotas admin devem checar role e retornar 403 para nao-admins. Session cookies em Better Auth ja tem `httpOnly`, `secure` e `sameSite` configurados — nao alterar.
- "Nao aprovaria para comecar codigo imediatamente." ### Input & Output
- "Primeiro ajustaria o doc com estes 5 pontos." Usar Drizzle ORM (parametrizado por padrao) — nunca concatenar input de usuario em SQL. Validar todo input com Zod antes de usar. Upload de arquivos: usar whitelist de MIME types (`ALLOWED_MIME_TYPES`), presigned URLs para S3, token de upload assinado com verificacao pos-upload. Nunca usar `dangerouslySetInnerHTML` com conteudo de usuario.
### Headers & CSP
CSP definida em `src/proxy.ts` via middleware — alterar la, nao em `next.config.ts`. Headers de seguranca (HSTS, X-Frame-Options, etc.) definidos em `next.config.ts`. Nao remover nem enfraquecer essas configuracoes.
### Rate Limiting
Login: 5 tentativas/min. Signup: 3 tentativas/min. API tokens: 100 req/min (inbox), 20 req/min (batch). Configurado em `src/shared/lib/auth/config.ts` e nas rotas de inbox. Nao remover.
### Tratamento de Erros
Erros nao devem expor stack traces, paths ou nomes de bibliotecas ao cliente. Usar mensagens genericas: `"Algo deu errado"`. Logar detalhes apenas no servidor com `console.error()`.
### Dependencias
Verificar pacotes novos sugeridos pela IA em npmjs.com antes de instalar. Red flags: menos de 1.000 downloads/semana, publicado nos ultimos 30 dias, nome muito parecido com pacote popular. Rodar `pnpm audit` periodicamente.
---

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,11 @@ 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 sed -i 's/\r$//' /app/docker-entrypoint.sh && \
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 +100,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"]

169
README.md
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.1.2-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.3.7-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/)
@@ -23,15 +23,21 @@
<img src="./public/images/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" /> <img src="./public/images/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" />
</p> </p>
<p align="center">
<img src="./public/images/preview-lancamentos-light.webp" alt="Lançamentos" width="800" />
</p>
--- ---
## 📖 Índice ## 📖 Índice
- [Sobre o Projeto](#-sobre-o-projeto) - [Sobre o Projeto](#-sobre-o-projeto)
- [Instalação via Script](#-instalação-via-script) - [Instalação via Script](#-instalação-via-script)
- [Preparar o servidor (Ubuntu 24.04)](#-preparar-o-servidor-ubuntu-2404)
- [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)
- [Backup](#-backup)
- [Storage S3 Compatível](#-storage-s3-compatível) - [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)
@@ -53,7 +59,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
**1. Não há versão hospedada online** — Este projeto é self-hosted. Você precisa rodar no seu próprio computador ou servidor. **1. Não há versão hospedada online** — Este projeto é self-hosted. Você precisa rodar no seu próprio computador ou servidor.
**2. Não há Open Finance** — Não há conexão automática com bancos. Você pode registrar transações manualmente ou importar extratos nos formatos OFX e XLS/XLSX. **2. Não há Open Finance** — Não há conexão automática com bancos. Você pode registrar transações manualmente, usar o app companion para capturar notificações bancárias ou importar extratos nos formatos OFX e XLS/XLSX.
**3. Requer disciplina** — O OpenMonetis funciona melhor para quem tem disciplina de registrar os gastos regularmente, quer controle total sobre seus dados e gosta de entender exatamente onde o dinheiro está indo. **3. Requer disciplina** — O OpenMonetis funciona melhor para quem tem disciplina de registrar os gastos regularmente, quer controle total sobre seus dados e gosta de entender exatamente onde o dinheiro está indo.
@@ -77,7 +83,11 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
📅 **Calendário financeiro** — Visualize todos os lançamentos em um calendário mensal. 📅 **Calendário financeiro** — Visualize todos os lançamentos em um calendário mensal.
📲 **OpenMonetis Companion** — App Android que captura notificações bancárias (Nubank, Itaú, Bradesco, Inter, C6 e outros) e envia como pré-lançamentos para revisão. [Repositório](https://github.com/felipegcoutinho/openmonetis-companion). 📲 **OpenMonetis Companion** — App Android que captura notificações bancárias (Nubank, Itaú, Bradesco, Inter, C6 e outros) e envia automaticamente como pré-lançamentos para revisão — sem digitar nada. [Repositório](https://github.com/felipegcoutinho/openmonetis-companion).
<p align="center">
<img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" />
</p>
⚙️ **Personalização** — Tema dark/light e modo privacidade. ⚙️ **Personalização** — Tema dark/light e modo privacidade.
@@ -97,6 +107,31 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
A forma mais rápida de instalar. O script verifica dependências, configura o `.env` interativamente e sobe o banco automaticamente. A forma mais rápida de instalar. O script verifica dependências, configura o `.env` interativamente e sobe o banco automaticamente.
### 🖥️ Preparar o servidor (Ubuntu 24.04)
Se você está num **servidor Ubuntu limpo** (VPS, Oracle Cloud, DigitalOcean...) sem Node.js, Docker ou pnpm instalados, use o script de preparação antes de continuar.
> ⚠️ **Testado apenas em Ubuntu Server 24.04 LTS.** Em outras distribuições ou versões é necessário testar ou ajustar o script.
```bash
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
sudo sh install-deps.sh
```
O script instala (e pula o que já estiver presente):
| Ferramenta | Como instala |
|---|---|
| `git`, `curl`, `ca-certificates` | apt |
| Docker Engine + Docker Compose | Repositório oficial do Docker |
| Homebrew | Script oficial (como usuário não-root) |
| Node.js 22 | Via Homebrew |
| pnpm | Via corepack |
Após a conclusão, adiciona o usuário atual ao grupo `docker` — faça logout/login para ativar. Em seguida, prossiga com o `setup.mjs` abaixo.
---
**Pré-requisito:** Node.js 22+ **Pré-requisito:** Node.js 22+
```bash ```bash
@@ -168,7 +203,7 @@ O script irá:
5. Acesse `http://localhost:3000` 5. Acesse `http://localhost:3000`
> **Docker completo** (app + banco em containers): use `pnpm docker:up` ao invés dos passos 3-4. > **Docker completo** (app + banco em containers): use `pnpm docker:up:local` ao invés dos passos 3-4.
--- ---
@@ -190,26 +225,30 @@ pnpm lint:fix # Biome auto-fix
pnpm db:generate # Gerar migrations pnpm db:generate # Gerar migrations
pnpm db:migrate # Executar migrations pnpm db:migrate # Executar migrations
pnpm db:push # Push schema direto (dev) pnpm db:push # Push schema direto (dev)
pnpm db:extensions # Habilitar extensões PostgreSQL (rodar uma vez)
pnpm db:studio # Drizzle Studio (UI visual) pnpm db:studio # Drizzle Studio (UI visual)
``` ```
### Utilitários ### Utilitários
```bash ```bash
pnpm backup # Backup do banco (requer scripts/backup.sh configurado) pnpm backup # Backup completo do banco (ver seção Backup)
``` ```
### Docker ### Docker
```bash ```bash
pnpm docker:up # Subir app + banco pnpm docker:up:local # Sobe app + banco PostgreSQL juntos (imagem do Hub)
pnpm docker:up:d # Subir em background pnpm docker:up # Sobe apenas o app com build local
pnpm docker:up:db # Subir apenas o banco pnpm docker:up:d # Sobe apenas o app com build local em background
pnpm docker:down # Parar containers pnpm docker:up:db # Sobe apenas o banco em background
pnpm docker:down:volumes # Parar e remover volumes (⚠️ apaga dados!) pnpm docker:down # Para e remove os containers
pnpm docker:logs # Logs em tempo real pnpm docker:down:volumes # Para containers e remove volumes (⚠️ apaga dados!)
pnpm docker:restart # Reiniciar pnpm docker:logs # Logs em tempo real (todos os containers)
pnpm docker:rebuild # Rebuild completo pnpm docker:logs:app # Logs do container da aplicação
pnpm docker:logs:db # Logs do container do banco
pnpm docker:restart # Reinicia todos os containers
pnpm docker:rebuild # Rebuild completo forçando recriação
``` ```
--- ---
@@ -220,6 +259,46 @@ O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem fi
Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`). Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
### Modos de uso
**Modo 1 — App + banco local (recomendado para self-hosting)**
Puxa a imagem pronta do Docker Hub e sobe app + banco juntos. Não precisa de Node.js instalado.
```bash
# 1. Baixar o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
# 2. Criar o .env (copie o .env.example como referência)
# DATABASE_URL=postgresql://openmonetis:SUA_SENHA@db:5432/openmonetis_db
# POSTGRES_PASSWORD=SUA_SENHA
# BETTER_AUTH_SECRET=string-longa-aleatoria
# BETTER_AUTH_URL=http://localhost:3000
# 3. Subir
docker compose --profile local up
# ou, se tiver o projeto clonado:
pnpm docker:up:local
```
**Modo 2 — App com banco remoto**
Use quando o banco está em um provider externo (Supabase, Neon, Railway...).
```bash
# DATABASE_URL deve apontar para o banco remoto no .env
docker compose up
```
**Modo 3 — Build local (desenvolvimento)**
Builda a imagem localmente a partir do código-fonte.
```bash
pnpm docker:up # app apenas (banco separado)
pnpm docker:up:db # sobe o banco em background
```
### Comandos úteis ### Comandos úteis
```bash ```bash
@@ -239,6 +318,68 @@ DB_PORT=5433 # Padrão: 5432
--- ---
## 💾 Backup
O backup é uma rotina de infraestrutura — não é uma tela no app. Ele opera diretamente sobre o banco PostgreSQL e é executado via linha de comando.
```bash
pnpm backup
```
### O que é salvo
Cada execução gera **3 arquivos** em `backup/`:
| Arquivo | Conteúdo | Uso |
|---|---|---|
| `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom do PostgreSQL (binário) | Restore completo via `pg_restore` |
| `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL completo compactado | Inspeção manual, portabilidade |
| `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados da schema `public` | Migração parcial, seed de outro ambiente |
### Modos de conexão
Configure `DB_MODE` no topo de `scripts/backup.sh`:
| Modo | Quando usar | Fonte de dados |
|---|---|---|
| `remote` (padrão) | Banco em Supabase, Neon, Railway, etc. | `DATABASE_URL` do `.env` |
| `docker` | Banco no container local | Container `openmonetis_postgres` |
### Upload para Google Drive (opcional)
Se o [rclone](https://rclone.org/) estiver instalado e configurado com um remote chamado `gdrive`, os arquivos são enviados automaticamente para `gdrive:BACKUP OPENMONETIS`. Sem o rclone, o backup funciona normalmente e fica apenas local.
**Retenção:**
- Local: 7 dias
- Google Drive: 30 dias
### Automatizar com cron
Para rodar o backup automaticamente todo dia às 3h:
```bash
crontab -e
```
```cron
0 3 * * * cd /caminho/para/openmonetis && pnpm backup >> /var/log/openmonetis-backup.log 2>&1
```
### Restore
```bash
# A partir do .dump (recomendado — mais rápido)
pg_restore --clean --no-owner --no-privileges \
-d "postgresql://user:senha@host:5432/openmonetis_db" \
backup/openmonetis_YYYY-MM-DD_HH-MM.dump
# A partir do .sql.gz (banco local via Docker)
gunzip -c backup/openmonetis_YYYY-MM-DD_HH-MM.sql.gz | \
docker compose exec -T db psql -U openmonetis -d openmonetis_db
```
---
## ☁️ Storage S3 Compatível ## ☁️ 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. 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.
@@ -256,7 +397,7 @@ S3_BUCKET=
### Compatibilidade ### 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. - 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). - A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](./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. - 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 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. - Se as variáveis de S3 não forem configuradas, mantenha os anexos desabilitados no seu fluxo de uso.

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.9/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 NODE_PATH=/app/migrate/node_modules /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 "$@"

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

@@ -8,12 +8,22 @@ const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
cacheComponents: true, cacheComponents: true,
reactCompiler: true, reactCompiler: true,
images: { images: {
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")], remotePatterns: [
new URL("https://lh3.googleusercontent.com/**"),
{ protocol: "https", hostname: "**" },
{ protocol: "http", hostname: "**" },
],
}, },
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 [
@@ -37,8 +47,12 @@ const nextConfig: NextConfig = {
value: "DENY", value: "DENY",
}, },
{ {
key: "Content-Security-Policy", key: "Referrer-Policy",
value: "frame-ancestors 'none';", value: "strict-origin-when-cross-origin",
},
{
key: "X-Permitted-Cross-Domain-Policies",
value: "none",
}, },
{ {
key: "Permissions-Policy", key: "Permissions-Policy",

View File

@@ -1,13 +1,15 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.2.0", "version": "2.3.7",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"db:seed": "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:deadcode": "knip --reporter compact",
"lint:fix": "biome check --write .", "lint:fix": "biome check --write .",
"env:setup": "bash scripts/setup-env.sh", "env:setup": "bash scripts/setup-env.sh",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
@@ -15,30 +17,56 @@
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:extensions": "tsx scripts/postgres/enable-extensions.ts", "db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"docker:up": "docker compose up --build",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs", "postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"docker:up:db": "docker compose up -d db",
"// --- Docker ---": "---",
"docker:up:local": "docker compose --profile local up",
"//docker:up:local": "Sobe app + banco PostgreSQL local juntos (imagem do Docker Hub)",
"docker:up": "docker compose up --build",
"//docker:up": "Sobe apenas o app com build local (banco deve estar rodando separado)",
"docker:up:d": "docker compose up --build -d", "docker:up:d": "docker compose up --build -d",
"//docker:up:d": "Sobe apenas o app com build local em background (detached)",
"docker:up:db": "docker compose up -d db",
"//docker:up:db": "Sobe apenas o banco PostgreSQL em background",
"docker:down": "docker compose down", "docker:down": "docker compose down",
"//docker:down": "Para e remove os containers",
"docker:down:volumes": "docker compose down -v", "docker:down:volumes": "docker compose down -v",
"//docker:down:volumes": "Para containers e remove volumes (APAGA os dados!)",
"docker:logs": "docker compose logs -f", "docker:logs": "docker compose logs -f",
"//docker:logs": "Acompanha logs de todos os containers em tempo real",
"docker:logs:app": "docker compose logs -f app", "docker:logs:app": "docker compose logs -f app",
"//docker:logs:app": "Acompanha logs do container da aplicação",
"docker:logs:db": "docker compose logs -f db", "docker:logs:db": "docker compose logs -f db",
"//docker:logs:db": "Acompanha logs do container do banco",
"docker:restart": "docker compose restart", "docker:restart": "docker compose restart",
"//docker:restart": "Reinicia todos os containers",
"docker:rebuild": "docker compose up --build --force-recreate", "docker:rebuild": "docker compose up --build --force-recreate",
"//docker:rebuild": "Rebuild completo forçando recriação dos containers",
"backup": "bash scripts/backup.sh" "backup": "bash scripts/backup.sh"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.64", "@ai-sdk/anthropic": "^3.0.68",
"@ai-sdk/google": "^3.0.53", "@ai-sdk/google": "^3.0.61",
"@ai-sdk/openai": "^3.0.48", "@ai-sdk/openai": "^3.0.52",
"@aws-sdk/client-s3": "^3.1019.0", "@aws-sdk/client-s3": "^3.1027.0",
"@aws-sdk/s3-request-presigner": "^3.1019.0", "@aws-sdk/s3-request-presigner": "^3.1027.0",
"@better-auth/passkey": "^1.5.6", "@better-auth/passkey": "^1.6.2",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@openrouter/ai-sdk-provider": "^2.3.3", "@openrouter/ai-sdk-provider": "^2.5.1",
"@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-checkbox": "1.3.3",
@@ -59,44 +87,51 @@
"@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.97.0",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.23",
"ai": "^6.0.141", "ai": "^6.0.154",
"better-auth": "1.5.6", "better-auth": "1.6.2",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"exceljs": "^4.4.0",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7", "jspdf-autotable": "^5.0.7",
"next": "16.1.7", "next": "16.2.3",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205", "pdfjs-dist": "^5.6.205",
"pg": "8.20.0", "pg": "8.20.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.5",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
"react-dom": "19.2.4", "react-dom": "19.2.5",
"recharts": "3.8.1", "recharts": "3.8.1",
"resend": "^6.9.4", "resend": "^6.10.0",
"sonner": "2.0.7", "sonner": "2.0.7",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"xlsx": "^0.18.5",
"zod": "4.3.6" "zod": "4.3.6"
}, },
"pnpm": {
"overrides": {
"defu": "6.1.7"
}
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.9", "@biomejs/biome": "2.4.10",
"@tailwindcss/postcss": "4.2.2", "@tailwindcss/postcss": "4.2.2",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "25.5.0", "@types/node": "25.5.2",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"dotenv": "^17.3.1", "dotenv": "^17.4.1",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"knip": "^6.3.1",
"tailwindcss": "4.2.2", "tailwindcss": "4.2.2",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "6.0.2" "typescript": "6.0.2"

4246
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
Contact: https://github.com/felipegcoutinho/openmonetis/security/advisories
Expires: 2027-04-04T00:00:00.000Z
Preferred-Languages: pt-BR, en
Canonical: https://openmonetis.com/.well-known/security.txt

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,20 +1,9 @@
import localFont from "next/font/local"; import { Inter } from "next/font/google";
export const america = localFont({ export const inter = Inter({
src: [ subsets: ["latin"],
{
path: "./america-regular.woff2",
weight: "400",
style: "normal",
},
{
path: "./america-medium.woff2",
weight: "500",
style: "normal",
},
],
display: "swap", display: "swap",
variable: "--font-america", variable: "--font-inter",
fallback: ["ui-sans-serif", "system-ui"],
preload: true,
}); });
export const americaFontVariable = america.variable;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 109 KiB

241
scripts/install-deps.sh Executable file
View File

@@ -0,0 +1,241 @@
#!/bin/sh
# install-deps.sh — Instala pré-requisitos do OpenMonetis
# Testado apenas em Ubuntu Server 24.04 LTS
# Uso: curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
# sudo sh install-deps.sh
set -e
LOG_FILE="/tmp/openmonetis-install.log"
> "$LOG_FILE"
# ── Cores ──────────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
ok() { printf "${GREEN}${RESET} %s\n" "$1"; }
warn() { printf "${YELLOW}!${RESET} %s\n" "$1"; }
info() { printf "${CYAN}${RESET} %s\n" "$1"; }
fail() { printf "${RED}${RESET} %s\n" "$1"; exit 1; }
# ── Contador de etapas ─────────────────────────────────────────────────────────
_STEP=0
_TOTAL=5
section() {
_STEP=$((_STEP + 1))
printf "\n${BOLD}[%d/%d] %s${RESET}\n" "$_STEP" "$_TOTAL" "$1"
}
# ── Spinner ────────────────────────────────────────────────────────────────────
_spin_pid=""
spinner_start() {
_spin_label="$1"
( i=0
while true; do
case $((i % 4)) in
0) d=" " ;; 1) d=". " ;; 2) d=".. " ;; *) d="..." ;;
esac
printf "\r${CYAN}${RESET} %s%s" "$_spin_label" "$d"
i=$((i + 1))
sleep 0.4
done
) &
_spin_pid=$!
}
spinner_stop() {
if [ -n "$_spin_pid" ]; then
kill "$_spin_pid" 2>/dev/null
wait "$_spin_pid" 2>/dev/null
_spin_pid=""
printf "\r\033[2K"
fi
}
# ── Executores silenciosos com spinner ─────────────────────────────────────────
# run_quiet "label" cmd [args...] — roda comando com spinner, falha mostra log
run_quiet() {
_rq_label="$1"; shift
spinner_start "$_rq_label"
if ! "$@" >> "$LOG_FILE" 2>&1; then
spinner_stop
printf "${RED}✗ Falha em: %s${RESET}\n" "$_rq_label"
printf " Log completo: %s\n\n" "$LOG_FILE"
tail -20 "$LOG_FILE"
exit 1
fi
spinner_stop
}
# run_as_user "label" "comando_shell" — roda comando como $CURRENT_USER com spinner
run_as_user() {
_ru_label="$1"; shift
spinner_start "$_ru_label"
if ! su - "$CURRENT_USER" -c "$*" >> "$LOG_FILE" 2>&1; then
spinner_stop
printf "${RED}✗ Falha em: %s${RESET}\n" "$_ru_label"
printf " Log completo: %s\n\n" "$LOG_FILE"
tail -20 "$LOG_FILE"
exit 1
fi
spinner_stop
}
# ── Cleanup no Ctrl+C ──────────────────────────────────────────────────────────
cleanup() {
spinner_stop
printf "\n${YELLOW}Instalação interrompida.${RESET} Log em: %s\n" "$LOG_FILE"
exit 1
}
trap cleanup INT TERM
# ── Tempo total ────────────────────────────────────────────────────────────────
_START=$(date +%s)
elapsed() {
_secs=$(( $(date +%s) - _START ))
printf "%dm%ds" $((_secs / 60)) $((_secs % 60))
}
# ── Root check ─────────────────────────────────────────────────────────────────
if [ "$(id -u)" -ne 0 ]; then
fail "Execute como root ou com sudo: sudo sh install-deps.sh"
fi
CURRENT_USER="${SUDO_USER:-$(whoami)}"
printf "\n${BOLD}OpenMonetis — Instalação de Dependências${RESET}\n"
printf "Usuário: ${CYAN}%s${RESET} | Log: %s\n" "$CURRENT_USER" "$LOG_FILE"
# ── [1/5] Dependências base ────────────────────────────────────────────────────
section "Dependências base"
run_quiet "Atualizando lista de pacotes" apt-get update -qq
run_quiet "Instalando git, curl, ca-certificates" apt-get install -y -qq ca-certificates curl git
ok "git $(git --version | cut -d' ' -f3) · curl · ca-certificates"
# ── [2/5] Docker ───────────────────────────────────────────────────────────────
section "Docker"
if command -v docker > /dev/null 2>&1; then
ok "Docker já instalado: $(docker --version | cut -d',' -f1)"
else
info "Adicionando repositório oficial do Docker..."
install -m 0755 -d /etc/apt/keyrings
run_quiet "Baixando chave GPG do Docker" \
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
mkdir -p /etc/apt/sources.list.d
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu %s stable\n' \
"$(dpkg --print-architecture)" "$VERSION_CODENAME" \
> /etc/apt/sources.list.d/docker.list
run_quiet "Atualizando lista de pacotes" apt-get update -qq
run_quiet "Instalando Docker Engine (pode levar alguns minutos)" \
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker > /dev/null 2>&1 || true
systemctl start docker > /dev/null 2>&1 || true
ok "Docker $(docker --version | cut -d',' -f1 | cut -d' ' -f3) instalado"
fi
if docker compose version > /dev/null 2>&1; then
ok "Docker Compose $(docker compose version | cut -d' ' -f4)"
else
run_quiet "Instalando Docker Compose plugin" \
sh -c 'mkdir -p /usr/local/lib/docker/cli-plugins && curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" -o /usr/local/lib/docker/cli-plugins/docker-compose && chmod +x /usr/local/lib/docker/cli-plugins/docker-compose'
ok "Docker Compose $(docker compose version | cut -d' ' -f4) instalado"
fi
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
if ! groups "$CURRENT_USER" | grep -q docker; then
usermod -aG docker "$CURRENT_USER"
warn "Usuário '$CURRENT_USER' adicionado ao grupo docker — faça logout/login para aplicar"
else
ok "Usuário '$CURRENT_USER' já está no grupo docker"
fi
fi
# ── [3/5] Homebrew ─────────────────────────────────────────────────────────────
section "Homebrew"
if command -v brew > /dev/null 2>&1; then
ok "Homebrew já instalado: $(brew --version | head -1)"
else
warn "Esta etapa pode levar de 5 a 10 minutos."
run_quiet "Instalando dependências de compilação" \
apt-get install -y -qq build-essential procps file
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
run_as_user "Instalando Homebrew" \
'NONINTERACTIVE=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
BREW_PROFILE="/home/$CURRENT_USER/.bashrc"
BREW_EVAL='eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"'
grep -qxF "$BREW_EVAL" "$BREW_PROFILE" 2>/dev/null || echo "$BREW_EVAL" >> "$BREW_PROFILE"
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
else
fail "Homebrew não pode ser instalado como root. Use sudo com um usuário normal."
fi
ok "Homebrew instalado"
fi
# ── [4/5] Node.js 22 ───────────────────────────────────────────────────────────
section "Node.js 22"
NODE_MAJOR=0
if command -v node > /dev/null 2>&1; then
NODE_MAJOR=$(node -e "process.stdout.write(String(parseInt(process.versions.node)))")
fi
if [ "$NODE_MAJOR" -ge 22 ] 2>/dev/null; then
ok "Node.js já instalado: $(node --version)"
else
warn "Node.js via Homebrew pode levar alguns minutos."
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
run_as_user "Instalando Node.js 22" \
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && brew install node@22 && brew link node@22 --force --overwrite'
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
else
fail "Node.js via Homebrew não pode ser instalado como root."
fi
ok "Node.js $(node --version) instalado"
fi
# ── [5/5] pnpm ─────────────────────────────────────────────────────────────────
section "pnpm"
if command -v pnpm > /dev/null 2>&1; then
ok "pnpm já instalado: $(pnpm --version)"
else
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
run_as_user "Instalando pnpm via corepack" \
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && corepack enable && corepack prepare pnpm@latest --activate'
else
run_quiet "Instalando pnpm via corepack" \
sh -c 'corepack enable && corepack prepare pnpm@latest --activate'
fi
ok "pnpm instalado"
fi
# ── Resumo ─────────────────────────────────────────────────────────────────────
printf "\n${BOLD}Concluído em $(elapsed)${RESET}\n"
ok "git: $(git --version | cut -d' ' -f3)"
ok "docker: $(docker --version | cut -d',' -f1 | cut -d' ' -f3)"
ok "docker compose: $(docker compose version | cut -d' ' -f4)"
ok "node: $(node --version)"
ok "pnpm: $(pnpm --version)"
printf "\n${CYAN}Próximo passo:${RESET}\n"
printf " node setup.mjs\n\n"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiBankLine />} icon={<RiBankLine />}
title="Contas" title="Contas"

View File

@@ -0,0 +1,26 @@
import { RiAttachmentLine } from "@remixicon/react";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Anexos",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6">
<PageDescription
icon={<RiAttachmentLine />}
title="Anexos"
subtitle="Gerencie os anexos das suas transações"
/>
<MonthNavigation />
{children}
</section>
);
}

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiBarChart2Line />} icon={<RiBarChart2Line />}
title="Orçamentos" title="Orçamentos"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiCalendarEventLine />} icon={<RiCalendarEventLine />}
title="Calendário" title="Calendário"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiBankCard2Line />} icon={<RiBankCard2Line />}
title="Cartões" title="Cartões"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiPriceTag3Line />} icon={<RiPriceTag3Line />}
title="Categorias" title="Categorias"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiHistoryLine />} icon={<RiHistoryLine />}
title="Changelog" title="Changelog"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiAtLine />} icon={<RiAtLine />}
title="Pré-Lançamentos" title="Pré-Lançamentos"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiSparklingLine />} icon={<RiSparklingLine />}
title="Insights" title="Insights"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiTodoLine />} icon={<RiTodoLine />}
title="Anotações" title="Anotações"

View File

@@ -80,6 +80,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
categoryFilter: null, categoryFilter: null,
accountCardFilter: null, accountCardFilter: null,
searchFilter: null, searchFilter: null,
settledFilter: null,
attachmentFilter: null,
}; };
const createEmptySlugMaps = (): SlugMaps => ({ const createEmptySlugMaps = (): SlugMaps => ({

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiGroupLine />} icon={<RiGroupLine />}
title="Pagadores" title="Pagadores"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiBankCard2Line />} icon={<RiBankCard2Line />}
title="Uso de Cartões" title="Uso de Cartões"

View File

@@ -71,7 +71,7 @@ export default async function RelatorioCartoesPage({
<div className="flex size-14 items-center justify-center rounded-full bg-muted mb-4"> <div className="flex size-14 items-center justify-center rounded-full bg-muted mb-4">
<RiBankCard2Line className="size-7 text-muted-foreground" /> <RiBankCard2Line className="size-7 text-muted-foreground" />
</div> </div>
<p className="text-base font-medium">Nenhum cartão selecionado</p> <p className="text-base font-semibold">Nenhum cartão selecionado</p>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Selecione um cartão para ver os detalhes de uso. Selecione um cartão para ver os detalhes de uso.
</p> </p>

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiFileChartLine />} icon={<RiFileChartLine />}
title="Tendências" title="Tendências"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiStore2Line />} icon={<RiStore2Line />}
title="Top Estabelecimentos" title="Top Estabelecimentos"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiSecurePaymentLine />} icon={<RiSecurePaymentLine />}
title="Análise de Parcelas" title="Análise de Parcelas"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiSettings2Line />} icon={<RiSettings2Line />}
title="Ajustes" title="Ajustes"

View File

@@ -67,7 +67,7 @@ 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-xl font-medium mb-1">Preferências</h2> <h2 className="text-xl font-semibold mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground"> <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.
@@ -92,7 +92,9 @@ export default async function Page() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h2 className="text-xl font-medium">OpenMonetis Companion</h2> <h2 className="text-xl font-semibold">
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"> <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" /> <RiAndroidLine className="h-3 w-3" />
Android Android
@@ -114,7 +116,7 @@ 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-xl font-medium mb-1">Alterar nome</h2> <h2 className="text-xl font-semibold mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground"> <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.
@@ -130,7 +132,7 @@ 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-xl font-medium mb-1">Alterar senha</h2> <h2 className="text-xl font-semibold mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground"> <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.
@@ -146,7 +148,7 @@ 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-xl font-medium mb-1">Passkeys</h2> <h2 className="text-xl font-semibold mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground"> <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.
@@ -162,7 +164,7 @@ 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-xl font-medium mb-1">Alterar e-mail</h2> <h2 className="text-xl font-semibold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground"> <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
@@ -182,9 +184,7 @@ 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-xl font-medium mb-1 text-destructive"> <h2 className="text-xl font-semibold mb-1">Ações perigosas</h2>
Ações perigosas
</h2>
<p className="text-sm text-muted-foreground"> <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.

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 pt-4"> <section className="space-y-6">
<PageDescription <PageDescription
icon={<RiArrowLeftRightLine />} icon={<RiArrowLeftRightLine />}
title="Lançamentos" title="Lançamentos"

View File

@@ -120,13 +120,13 @@ export default async function Page() {
</div> </div>
<div className="max-w-8xl mx-auto px-4 relative"> <div className="max-w-8xl mx-auto px-4 relative">
<div className="mx-auto flex max-w-3xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14"> <div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14">
<Badge variant="outline"> <Badge variant="outline">
<RiGithubFill className="size-4 mr-1" /> <RiGithubFill className="size-4 mr-1" />
Projeto Open Source Projeto Open Source
</Badge> </Badge>
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-medium tracking-tight"> <h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold">
Suas finanças, Suas finanças,
<span className="text-primary"> do seu jeito</span> <span className="text-primary"> do seu jeito</span>
</h1> </h1>
@@ -207,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-medium"> <span className="text-2xl md:text-3xl font-semibold">
{value} {value}
</span> </span>
<span className="text-xs md:text-sm text-muted-foreground"> <span className="text-xs md:text-sm text-muted-foreground">
@@ -229,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-medium tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
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">
@@ -254,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-medium tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
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">
@@ -282,7 +282,7 @@ export default async function Page() {
/> />
</div> </div>
<div> <div>
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2"> <h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
{feature.title} {feature.title}
</h3> </h3>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
@@ -298,7 +298,7 @@ export default async function Page() {
<AnimateOnScroll> <AnimateOnScroll>
<div className="mt-8 md:mt-12"> <div className="mt-8 md:mt-12">
<h3 className="text-sm font-medium text-center mb-4 md:mb-6 text-muted-foreground"> <h3 className="text-sm font-semibold text-center mb-4 md:mb-6 text-muted-foreground">
Também inclui Também inclui
</h3> </h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@@ -319,7 +319,7 @@ export default async function Page() {
/> />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h4 className="font-medium text-sm mb-0.5"> <h4 className="font-semibold text-sm mb-0.5">
{feature.title} {feature.title}
</h4> </h4>
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
@@ -346,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-medium tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
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">
@@ -529,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-medium tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
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">
@@ -556,7 +556,7 @@ export default async function Page() {
/> />
</div> </div>
<div> <div>
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2"> <h3 className="font-semibold 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">
@@ -582,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-medium tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
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">
@@ -617,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-medium tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
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">
@@ -644,7 +644,7 @@ export default async function Page() {
/> />
</div> </div>
<div> <div>
<h3 className="font-medium mb-1">{item.title}</h3> <h3 className="font-semibold 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>
@@ -664,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-medium tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
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">
@@ -715,7 +715,7 @@ export default async function Page() {
</div> </div>
<div> <div>
<h3 className="font-medium mb-3 md:mb-4">Projeto</h3> <h3 className="font-semibold 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
@@ -749,7 +749,7 @@ export default async function Page() {
</div> </div>
<div> <div>
<h3 className="font-medium mb-3 md:mb-4">Companion</h3> <h3 className="font-semibold 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

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

View File

@@ -1,87 +0,0 @@
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import {
extractBearerToken,
hashToken,
refreshAccessToken,
verifyJwt,
} from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
export async function POST(request: Request) {
try {
// Extrair refresh token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Refresh token não fornecido" },
{ status: 401 },
);
}
// Validar refresh token
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_refresh") {
return NextResponse.json(
{ error: "Refresh token inválido ou expirado" },
{ status: 401 },
);
}
// Verificar se token não foi revogado
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt),
gt(apiTokens.expiresAt, new Date()),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token revogado ou não encontrado" },
{ status: 401 },
);
}
// Gerar novo access token
const result = refreshAccessToken(token);
if (!result) {
return NextResponse.json(
{ error: "Não foi possível renovar o token" },
{ status: 401 },
);
}
// Atualizar hash do token e último uso
await db
.update(apiTokens)
.set({
tokenHash: hashToken(result.accessToken),
lastUsedAt: new Date(),
lastUsedIp:
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null,
expiresAt: result.expiresAt,
})
.where(eq(apiTokens.id, payload.tokenId));
return NextResponse.json({
accessToken: result.accessToken,
expiresAt: result.expiresAt.toISOString(),
});
} catch (error) {
console.error("[API] Error refreshing device token:", error);
return NextResponse.json(
{ error: "Erro ao renovar token" },
{ status: 500 },
);
}
}

View File

@@ -1,71 +0,0 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens } from "@/db/schema";
import {
generateTokenPair,
getTokenPrefix,
hashToken,
} from "@/shared/lib/auth/api-token";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
const createTokenSchema = z.object({
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
deviceId: z.string().optional(),
});
export async function POST(request: Request) {
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
const body = await request.json();
const { name, deviceId } = createTokenSchema.parse(body);
// Gerar par de tokens
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
session.user.id,
deviceId,
);
// Salvar hash do token no banco
await db.insert(apiTokens).values({
id: tokenId,
userId: session.user.id,
name,
tokenHash: hashToken(accessToken),
tokenPrefix: getTokenPrefix(accessToken),
expiresAt,
});
// Retornar tokens (mostrados apenas uma vez)
return NextResponse.json(
{
accessToken,
refreshToken,
tokenId,
name,
expiresAt: expiresAt.toISOString(),
message:
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating device token:", error);
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
}
}

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

@@ -17,15 +17,14 @@ export async function POST(request: Request) {
); );
} }
// Validar token os_xxx via hash lookup // Validar token opm_xxx via hash
if (!token.startsWith("os_")) { if (!token.startsWith("opm_")) {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "Formato de token inválido" }, { valid: false, error: "Formato de token inválido" },
{ status: 401 }, { status: 401 },
); );
} }
// Hash do token para buscar no DB
const tokenHash = hashToken(token); const tokenHash = hashToken(token);
// Buscar token no banco // Buscar token no banco

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { version as APP_VERSION } from "@/package.json";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
/** /**
@@ -20,7 +19,6 @@ export async function GET() {
{ {
status: "ok", status: "ok",
name: "OpenMonetis", name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
{ status: 200 }, { status: 200 },
@@ -33,7 +31,6 @@ export async function GET() {
{ {
status: "error", status: "error",
name: "OpenMonetis", name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
message: "Database connection failed", message: "Database connection failed",
}, },

View File

@@ -1,4 +1,4 @@
import { and, eq, gt, isNull, or } from "drizzle-orm"; import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema"; import { apiTokens, inboxItems } from "@/db/schema";
@@ -48,8 +48,8 @@ export async function POST(request: Request) {
); );
} }
// Validar token os_xxx via hash // Validar token opm_xxx via hash
if (!token.startsWith("os_")) { if (!token.startsWith("opm_")) {
return NextResponse.json( return NextResponse.json(
{ error: "Formato de token inválido" }, { error: "Formato de token inválido" },
{ status: 401 }, { status: 401 },
@@ -63,7 +63,7 @@ export async function POST(request: Request) {
where: and( where: and(
eq(apiTokens.tokenHash, tokenHash), eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt), isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())), gt(apiTokens.expiresAt, new Date()),
), ),
}); });

View File

@@ -1,4 +1,4 @@
import { and, eq, gt, isNull, or } from "drizzle-orm"; import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema"; import { apiTokens, inboxItems } from "@/db/schema";
@@ -41,8 +41,8 @@ export async function POST(request: Request) {
); );
} }
// Validar token os_xxx via hash // Validar token opm_xxx via hash
if (!token.startsWith("os_")) { if (!token.startsWith("opm_")) {
return NextResponse.json( return NextResponse.json(
{ error: "Formato de token inválido" }, { error: "Formato de token inválido" },
{ status: 401 }, { status: 401 },
@@ -56,7 +56,7 @@ export async function POST(request: Request) {
where: and( where: and(
eq(apiTokens.tokenHash, tokenHash), eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt), isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())), gt(apiTokens.expiresAt, new Date()),
), ),
}); });

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import {
fetchSavedInsights,
savedInsightsPeriodSchema,
} from "@/features/insights/queries";
import { getOptionalUserSession } 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 session = await getOptionalUserSession();
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const insights = await fetchSavedInsights(
session.user.id,
validatedPeriod.data,
);
return NextResponse.json(insights, {
headers: PRIVATE_RESPONSE_HEADERS,
});
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries";
import { getOptionalUserSession } 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 [session, { transactionId }] = await Promise.all([
getOptionalUserSession(),
params,
]);
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const userId = session.user.id;
const attachments = await fetchTransactionAttachments(userId, transactionId);
return NextResponse.json(attachments, {
headers: PRIVATE_RESPONSE_HEADERS,
});
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
import { getOptionalUserSession } 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 [session, { seriesId }] = await Promise.all([
getOptionalUserSession(),
params,
]);
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const userId = session.user.id;
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

@@ -177,7 +177,7 @@
} }
@theme inline { @theme inline {
--default-font-family: var(--font-america); --default-font-family: var(--font-inter);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);

View File

@@ -1,9 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Suspense } from "react"; 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";
import { america } from "@/public/fonts/font_index"; import { inter } from "@/public/fonts/font_index";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -21,23 +22,30 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html <html
data-scroll-behavior="smooth"
lang="pt-BR" lang="pt-BR"
className={`${america.variable} ${america.className} `} className={`${inter.variable}`}
suppressHydrationWarning suppressHydrationWarning
> >
<head> <head>
<meta name="apple-mobile-web-app-title" content="OpenMonetis" /> <meta name="apple-mobile-web-app-title" content="OpenMonetis" />
<script {process.env.UMAMI_URL && process.env.UMAMI_WEBSITE_ID && (
defer <script
src="https://umami.felipecoutinho.com/script.js" defer
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f" src={`${process.env.UMAMI_URL}/script.js`}
data-domains="openmonetis.com" data-website-id={process.env.UMAMI_WEBSITE_ID}
/> {...(process.env.UMAMI_DOMAINS
? { "data-domains": process.env.UMAMI_DOMAINS }
: {})}
/>
)}
</head> </head>
<body className="antialiased" suppressHydrationWarning> <body className="subpixel-antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light"> <ThemeProvider attribute="class" defaultTheme="light">
<Suspense>{children}</Suspense> <QueryProvider>
<Toaster position="top-right" /> <Suspense>{children}</Suspense>
<Toaster position="top-right" />
</QueryProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -6,25 +6,7 @@ export default function robots(): MetadataRoute.Robots {
{ {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
disallow: [ disallow: "/api/",
"/dashboard",
"/transactions",
"/accounts",
"/cards",
"/categories",
"/budgets",
"/payers",
"/notes",
"/insights",
"/calendar",
"/attachments",
"/settings",
"/reports",
"/inbox",
"/login",
"/signup",
"/api/",
],
}, },
], ],
}; };

View File

@@ -1,7 +1,7 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
const BASE_URL = process.env.PUBLIC_DOMAIN const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}` ? `https://${process.env.PUBLIC_DOMAIN.replace(/^https?:\/\//, "")}`
: "https://openmonetis.com"; : "https://openmonetis.com";
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {

View File

@@ -139,6 +139,7 @@ export const userPreferences = pgTable("preferencias_usuario", {
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",

View File

@@ -99,7 +99,7 @@ export async function createAccountAction(
if (hasInitialBalance && !adminPayerId) { if (hasInitialBalance && !adminPayerId) {
throw new Error( throw new Error(
"Payer com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.", "Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
); );
} }
@@ -299,7 +299,7 @@ export async function transferBetweenAccountsAction(
if (!adminPayerId) { if (!adminPayerId) {
throw new Error( throw new Error(
"Payer administrador não encontrado. Por favor, crie um pagador admin.", "Pagador administrador não encontrado. Por favor, crie um pagador admin.",
); );
} }

View File

@@ -88,7 +88,9 @@ export function AccountCard({
{icon} {icon}
</div> </div>
) : null} ) : null}
<h2 className="text-lg font-medium text-foreground">{accountName}</h2> <h2 className="text-lg font-semibold text-foreground">
{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-medium text-foreground"> <h2 className="truncate text-sm font-semibold text-foreground">
{accountName} {accountName}
</h2> </h2>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -81,12 +81,12 @@ export function AccountStatementCard({
{/* Linha 2 — saldo final (hero) */} {/* Linha 2 — saldo final (hero) */}
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm font-medium text-muted-foreground "> <p className="text-sm text-muted-foreground ">
Saldo ao final do período Saldo ao final do período
</p> </p>
<MoneyValues <MoneyValues
amount={currentBalance} amount={currentBalance}
className="text-3xl leading-none font-medium tracking-tight sm:text-[2rem]" className="text-3xl leading-none tracking-tighter sm:text-[2rem]"
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge

View File

@@ -69,9 +69,7 @@ function PdfCanvas({ url }: PdfCanvasProps) {
return ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50"> <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" /> <RiFilePdf2Line className="size-12 text-muted-foreground/40" />
<span className="text-xs font-medium text-muted-foreground/60"> <span className="text-xs text-muted-foreground/60">PDF Protegido</span>
PDF Protegido
</span>
</div> </div>
); );
} }
@@ -117,6 +115,7 @@ export function AttachmentGridItem({
src={url} src={url}
alt={attachment.fileName} alt={attachment.fileName}
fill fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw"
unoptimized unoptimized
className="object-cover transition-transform duration-300 group-hover:scale-105" className="object-cover transition-transform duration-300 group-hover:scale-105"
/> />
@@ -152,7 +151,7 @@ export function AttachmentGridItem({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<p className="truncate text-sm font-medium leading-tight text-foreground"> <p className="truncate text-sm font-semibold leading-tight text-foreground">
{attachment.fileName} {attachment.fileName}
</p> </p>
</TooltipTrigger> </TooltipTrigger>
@@ -179,25 +178,21 @@ export function AttachmentGridItem({
{attachment.transactionName} {attachment.transactionName}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<span <span className={cn("shrink-0 text-sm font-medium tracking-tighter")}>
className={cn(
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
)}
>
{formatCurrency(amount)} {formatCurrency(amount)}
</span> </span>
</div> </div>
{/* Footer: Tamanho + Botão Detalhes */} {/* Footer: Tamanho + Botão Detalhes */}
<div className="mt-auto flex items-center justify-between border-t pt-3"> <div className="mt-auto flex items-center justify-between border-t pt-3">
<span className="text-xs font-medium text-muted-foreground/70"> <span className="text-xs text-muted-foreground/70">
{formatBytes(attachment.fileSize)} {formatBytes(attachment.fileSize)}
</span> </span>
<button <button
type="button" type="button"
onClick={onDetails} onClick={onDetails}
disabled={isLoadingDetails} disabled={isLoadingDetails}
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50" className="text-xs text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
> >
{isLoadingDetails ? "Carregando..." : "Detalhes"} {isLoadingDetails ? "Carregando..." : "Detalhes"}
</button> </button>

View File

@@ -8,6 +8,7 @@ import {
RiExternalLinkLine, RiExternalLinkLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useAttachmentUrlQuery } from "@/features/attachments/hooks/use-attachment-url";
import type { AttachmentForPeriod } from "@/features/attachments/queries"; import type { AttachmentForPeriod } from "@/features/attachments/queries";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { import {
@@ -30,7 +31,6 @@ export function AttachmentPreview({
onClose, onClose,
}: AttachmentPreviewProps) { }: AttachmentPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(selectedIndex); const [currentIndex, setCurrentIndex] = useState(selectedIndex);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const open = selectedIndex >= 0; const open = selectedIndex >= 0;
useEffect(() => { useEffect(() => {
@@ -52,17 +52,11 @@ export function AttachmentPreview({
const attachment = attachments[currentIndex]; const attachment = attachments[currentIndex];
const attachmentId = attachment?.attachmentId; const attachmentId = attachment?.attachmentId;
const {
// Busca URL fresca a cada troca de anexo data: previewUrl,
useEffect(() => { isLoading: isPreviewLoading,
if (!attachmentId) return; isError: isPreviewError,
setPreviewUrl(null); } = useAttachmentUrlQuery(attachmentId ?? "", open && Boolean(attachmentId));
fetch(`/api/attachments/${attachmentId}/presign`)
.then((r) => r.json())
.then((data: { url: string }) => setPreviewUrl(data.url))
.catch(() => {});
}, [attachmentId]);
if (!attachment) return null; if (!attachment) return null;
@@ -111,7 +105,7 @@ export function AttachmentPreview({
> >
<RiArrowLeftSLine className="size-4" /> <RiArrowLeftSLine className="size-4" />
</Button> </Button>
<span className="select-none text-xs text-muted-foreground tabular-nums"> <span className="select-none text-xs text-muted-foreground">
{currentIndex + 1} / {attachments.length} {currentIndex + 1} / {attachments.length}
</span> </span>
<Button <Button
@@ -170,11 +164,16 @@ export function AttachmentPreview({
</DialogHeader> </DialogHeader>
<div className="min-h-0 min-w-0 flex-1"> <div className="min-h-0 min-w-0 flex-1">
{!previewUrl && ( {isPreviewLoading && (
<div className="flex h-full w-full items-center justify-center"> <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 className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
</div> </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 && ( {isPdf && previewUrl && (
<iframe <iframe
key={attachment.attachmentId} key={attachment.attachmentId}

View File

@@ -19,8 +19,6 @@ import { TransactionDetailsDialog } from "@/features/transactions/components/dia
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog"; import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import type { TransactionItem } from "@/features/transactions/components/types"; import type { TransactionItem } from "@/features/transactions/components/types";
import { EmptyState } from "@/shared/components/empty-state"; 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 { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
@@ -143,14 +141,6 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
return ( return (
<div className="w-full space-y-6"> <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> <Card>
<CardContent> <CardContent>
{attachments.length === 0 ? ( {attachments.length === 0 ? (

View File

@@ -1,13 +1,37 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react"; 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) { export function useAttachmentUrl(attachmentId: string) {
const [url, setUrl] = useState<string | null>(null); const [isVisible, setIsVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
setUrl(null); void attachmentId;
setIsVisible(false);
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
@@ -15,10 +39,7 @@ export function useAttachmentUrl(attachmentId: string) {
(entries) => { (entries) => {
if (!entries[0].isIntersecting) return; if (!entries[0].isIntersecting) return;
observer.disconnect(); observer.disconnect();
fetch(`/api/attachments/${attachmentId}/presign`) setIsVisible(true);
.then((r) => r.json())
.then((data: { url: string }) => setUrl(data.url))
.catch(() => {});
}, },
{ rootMargin: "150px" }, { rootMargin: "150px" },
); );
@@ -27,5 +48,7 @@ export function useAttachmentUrl(attachmentId: string) {
return () => observer.disconnect(); return () => observer.disconnect();
}, [attachmentId]); }, [attachmentId]);
return { url, containerRef }; const { data: url } = useAttachmentUrlQuery(attachmentId, isVisible);
return { url: url ?? null, containerRef };
} }

View File

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

View File

@@ -14,13 +14,13 @@ const MOCK_INVOICES: MockInvoice[] = [
cardName: "Nubank", cardName: "Nubank",
logo: "nubank.png", logo: "nubank.png",
amount: 1898, amount: 1898,
dueLabel: "Vence em 3 dias", dueLabel: "Vence hoje",
}, },
{ {
cardName: "Itaú", cardName: "Itaú",
logo: "itau.png", logo: "itau.png",
amount: 1923, amount: 1923,
dueLabel: "Vence em 8 dias", dueLabel: "Vence amanhã",
}, },
]; ];

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-medium leading-tight"> <h3 className="text-base font-semibold leading-tight">
{formatCategoryName(budget)} {formatCategoryName(budget)}
</h3> </h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@@ -19,9 +19,9 @@ 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-medium uppercase tracking-wide text-muted-foreground"> <div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{WEEK_DAYS_SHORT.map((dayName) => ( {WEEK_DAYS_SHORT.map((dayName) => (
<span key={dayName} className="px-3 py-2 text-center text-primary"> <span key={dayName} className="px-3 py-2 text-center">
{dayName} {dayName}
</span> </span>
))} ))}

View File

@@ -130,7 +130,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
<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-medium leading-tight"> <span className="text-sm font-medium leading-tight">
Vencimento Invoice - {event.card.name} Vencimento Fatura - {event.card.name}
</span> </span>
</div> </div>

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-medium text-foreground sm:text-base"> <h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
{name} {name}
</h3> </h3>
{note ? ( {note ? (
@@ -206,29 +206,29 @@ 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-medium text-foreground"> <p className="text-sm font-semibold 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 text-muted-foreground">
{metrics[0].label} {metrics[0].label}
</span> </span>
</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-medium text-foreground"> <p className="flex items-center gap-1.5 text-sm font-semibold 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>
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs text-muted-foreground">
{metrics[1].label} {metrics[1].label}
</span> </span>
</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-medium text-foreground"> <p className="text-sm font-semibold 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 text-muted-foreground">
{metrics[2].label} {metrics[2].label}
</span> </span>
</div> </div>

View File

@@ -183,7 +183,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
<TableCell className="font-medium"> <TableCell className="font-medium">
<Link <Link
href={`/categories/${category.id}`} href={`/categories/${category.id}`}
className="inline-flex items-center gap-1 underline-offset-2 hover:text-primary hover:underline" className="inline-flex items-center gap-1 underline-offset-2 hover:text-primary hover:underline font-semibold"
> >
{category.name} {category.name}
<RiExternalLinkLine <RiExternalLinkLine

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-medium leading-tight"> <h1 className="text-xl font-semibold 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-medium"> <p className="mt-1 text-2xl font-semibold">
{currencyFormatter.format(currentTotal)} {currencyFormatter.format(currentTotal)}
</p> </p>
</div> </div>
@@ -107,7 +107,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 {previousPeriodLabel} Total em {previousPeriodLabel}
</p> </p>
<p className="mt-1 text-lg font-medium text-muted-foreground"> <p className="mt-1 text-lg font-semibold text-muted-foreground">
{currencyFormatter.format(previousTotal)} {currencyFormatter.format(previousTotal)}
</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-medium", "mt-1 flex items-center gap-1 text-lg font-semibold",
variationColor, variationColor,
)} )}
> >

View File

@@ -80,7 +80,7 @@ export function CategoryPickerDialog({
<div className="flex max-h-96 flex-col gap-4 overflow-y-auto pr-1"> <div className="flex max-h-96 flex-col gap-4 overflow-y-auto pr-1">
{filteredGroups.map((group) => ( {filteredGroups.map((group) => (
<div key={group.label}> <div key={group.label}>
<p className="mb-2 text-xs font-medium text-muted-foreground"> <p className="mb-2 text-xs text-muted-foreground">
{group.label} {group.label}
</p> </p>
<div className="grid grid-cols-8 gap-1.5"> <div className="grid grid-cols-8 gap-1.5">

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

@@ -5,7 +5,10 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants"; } from "@/shared/lib/accounts/constants";
import type { CategoryType } from "@/shared/lib/categories/constants"; import {
type CategoryType,
INVOICE_PAYMENT_CATEGORY_NAME,
} from "@/shared/lib/categories/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { calculatePercentageChange } from "@/shared/utils/math"; import { calculatePercentageChange } from "@/shared/utils/math";
@@ -45,6 +48,7 @@ export async function fetchCategoryDetails(
const previousPeriod = getPreviousPeriod(period); const previousPeriod = getPreviousPeriod(period);
const transactionType = category.type === "receita" ? "Receita" : "Despesa"; const transactionType = category.type === "receita" ? "Receita" : "Despesa";
const adminPayerId = await getAdminPayerId(userId); const adminPayerId = await getAdminPayerId(userId);
const isInvoiceCategory = category.name === INVOICE_PAYMENT_CATEGORY_NAME;
const sanitizedNote = or( const sanitizedNote = or(
isNull(transactions.note), isNull(transactions.note),
@@ -59,7 +63,7 @@ export async function fetchCategoryDetails(
eq(transactions.transactionType, transactionType), eq(transactions.transactionType, transactionType),
eq(transactions.period, period), eq(transactions.period, period),
eq(transactions.payerId, adminPayerId), eq(transactions.payerId, adminPayerId),
sanitizedNote, ...(isInvoiceCategory ? [] : [sanitizedNote]),
), ),
with: { with: {
payer: true, payer: true,
@@ -108,7 +112,7 @@ export async function fetchCategoryDetails(
eq(transactions.categoryId, categoryId), eq(transactions.categoryId, categoryId),
eq(transactions.transactionType, transactionType), eq(transactions.transactionType, transactionType),
eq(transactions.payerId, adminPayerId), eq(transactions.payerId, adminPayerId),
sanitizedNote, ...(isInvoiceCategory ? [] : [sanitizedNote]),
eq(transactions.period, previousPeriod), eq(transactions.period, previousPeriod),
or( or(
isNull(transactions.note), isNull(transactions.note),

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

@@ -0,0 +1,128 @@
"use client";
import {
RiAttachmentLine,
RiFileLine,
RiFilePdf2Line,
RiImageLine,
} from "@remixicon/react";
import { useState } from "react";
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { formatDateOnly } from "@/shared/utils/date";
import { formatBytes } from "@/shared/utils/number";
type AttachmentsSnapshot = {
totalCount: number;
totalBytes: number;
imageCount: number;
pdfCount: number;
recentAttachments: AttachmentForPeriod[];
};
type AttachmentsWidgetProps = {
snapshot: AttachmentsSnapshot;
};
export function AttachmentsWidget({ snapshot }: AttachmentsWidgetProps) {
const [selectedIndex, setSelectedIndex] = useState(-1);
if (snapshot.totalCount === 0) {
return (
<WidgetEmptyState
icon={<RiAttachmentLine className="size-6 text-muted-foreground" />}
title="Nenhum anexo no período"
description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
/>
);
}
return (
<>
<div className="mb-2 flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
<RiAttachmentLine className="size-3.5" />
{snapshot.totalCount} {snapshot.totalCount === 1 ? "anexo" : "anexos"}
</span>
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
{formatBytes(snapshot.totalBytes)}
</span>
{snapshot.imageCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
<RiImageLine className="size-3.5 text-blue-500" />
{snapshot.imageCount}
</span>
)}
{snapshot.pdfCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
<RiFilePdf2Line className="size-3.5 text-red-500" />
{snapshot.pdfCount}
</span>
)}
</div>
<ul className="flex flex-col">
{snapshot.recentAttachments.map((attachment, index) => {
const isPdf = attachment.mimeType === "application/pdf";
const isImage = attachment.mimeType.startsWith("image/");
return (
<li key={`${attachment.attachmentId}-${attachment.transactionId}`}>
<button
type="button"
onClick={() => setSelectedIndex(index)}
className="flex w-full items-center gap-2 py-2 text-left"
>
<div className="shrink-0">
{isPdf && <RiFilePdf2Line className="size-6 text-red-500" />}
{isImage && <RiImageLine className="size-6 text-blue-500" />}
{!isPdf && !isImage && (
<RiFileLine className="size-6 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="block truncate text-sm font-medium text-foreground hover:underline">
{attachment.fileName}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs break-all">
{attachment.fileName}
</TooltipContent>
</Tooltip>
<span className="block truncate text-xs text-muted-foreground">
{attachment.transactionName}
</span>
</div>
<div className="shrink-0 text-right">
<span className="block text-xs text-muted-foreground">
{formatDateOnly(attachment.purchaseDate, {
day: "2-digit",
month: "2-digit",
}) ?? "—"}
</span>
<span className="block text-xs text-muted-foreground/60">
{formatBytes(attachment.fileSize)}
</span>
</div>
</button>
</li>
);
})}
</ul>
<AttachmentPreview
attachments={snapshot.recentAttachments}
selectedIndex={selectedIndex}
onClose={() => setSelectedIndex(-1)}
/>
</>
);
}

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,21 +40,39 @@ 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 font-semibold",
</span> )}
>
{statusLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{statusTooltipLabel}
</TooltipContent>
</Tooltip>
) : (
<span
className={cn(
"rounded-full py-0.5",
bill.isSettled && "text-success font-semibold",
)}
>
{statusLabel}
</span>
)
) : null} ) : null}
</div> </div>
</div> </div>
</div> </div>
<div className="flex shrink-0 flex-col items-end"> <div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={bill.amount} /> <MoneyValues className="font-medium" amount={bill.amount} />
<Button <Button
type="button" type="button"
size="sm" size="sm"

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