37 Commits

Author SHA1 Message Date
Felipe Coutinho
58db357cde docs: reescrever README com guia de instalação para leigos; atualizar changelog 2.3.7
- README: seção "Como rodar" reescrita com 4 modos explicados para leigos
- README: seção Docker atualizada (sem .env obrigatório, localhost funciona)
- package.json: corrigir env:setup apontando para setup-env.sh deletado → setup.mjs
- CHANGELOG 2.3.7: documentar fix do localhost→db e default DATABASE_URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:49:40 +00:00
Felipe Coutinho
99a9ff5512 fix(docker): resolver DATABASE_URL localhost→db no container automaticamente
- docker-entrypoint.sh: substituir @localhost: por @db: via sed antes das
  migrations e do Next.js subirem — transparente para o usuário
- docker-compose.yml: adicionar valor padrão para DATABASE_URL para
  permitir subir sem .env configurado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:48:20 +00:00
Felipe Coutinho
5bcf4f69d3 chore(scripts): remover órfãos dev.ts e setup-env.sh; atualizar changelog 2.3.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:17:20 +00:00
Felipe Coutinho
95099c1a94 chore(docker): passar PUBLIC_DOMAIN e variáveis Umami para o container
Adiciona PUBLIC_DOMAIN, UMAMI_URL, UMAMI_WEBSITE_ID e UMAMI_DOMAINS
ao bloco de environment do serviço app no docker-compose.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:16:44 +00:00
Felipe Coutinho
94912f7edc fix(scripts): corrigir install-deps.sh — spinner, corepack e PATH
- spinner_stop: adicionar || true em kill/wait para evitar exit com set -e
- suprimir prompt interativo do corepack com COREPACK_ENABLE_DOWNLOAD_PROMPT=0
- exportar PATH do Homebrew antes do resumo para pnpm --version funcionar
- remover mensagem "próximo passo" do final do script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:16:40 +00:00
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
197 changed files with 4787 additions and 2780 deletions

View File

@@ -44,6 +44,12 @@ 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=
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

@@ -13,6 +13,7 @@ on:
env: env:
DOCKER_IMAGE_NAME: openmonetis DOCKER_IMAGE_NAME: openmonetis
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
quality: quality:

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,93 @@ 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)
- Docker: variáveis `PUBLIC_DOMAIN`, `UMAMI_URL`, `UMAMI_WEBSITE_ID` e `UMAMI_DOMAINS` passadas ao container da aplicação no `docker-compose.yml`
### 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
- Scripts: `install-deps.sh` — spinner travava o script por `wait` retornar código não-zero com `set -e` ativo; corrigido com `|| true`
- Scripts: `install-deps.sh` — prompt interativo do corepack suprimido com `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`
- Scripts: `install-deps.sh` — PATH do Homebrew não estava configurado na seção de resumo
### Removido
- Scripts: removidos arquivos órfãos `scripts/dev.ts` e `scripts/setup-env.sh` (substituídos pelo `setup.mjs`)
- Docker: `docker-compose.yml` agora funciona sem arquivo `.env``DATABASE_URL` tem valor padrão com credenciais de desenvolvimento
- Docker: `docker-entrypoint.sh` converte automaticamente `@localhost:` para `@db:` na `DATABASE_URL` ao iniciar o container, eliminando a necessidade de usar hosts diferentes no `.env` para desenvolvimento local e Docker
## [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 ## [2.3.0] - 2026-04-03
### 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

@@ -56,10 +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
# Instalar deps do drizzle-kit em diretório separado ANTES de copiar o standalone
# Isso evita que o pnpm install sobrescreva o node_modules do Next.js standalone
COPY --from=builder /app/package.json /tmp/pkg.json
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 apenas arquivos necessários para produção # Copiar apenas arquivos necessários para produção
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Copiar arquivos de build do Next.js # 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
@@ -68,25 +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
# Instalar apenas as deps necessárias para drizzle-kit migrate
# Gera package.json mínimo a partir do original para evitar version drift
COPY --from=builder /app/package.json /tmp/pkg.json
RUN node -e "\
const p=JSON.parse(require('fs').readFileSync('/tmp/pkg.json','utf8'));\
require('fs').writeFileSync('package.json',JSON.stringify({\
name:'openmonetis',version:p.version,\
dependencies:{\
'drizzle-orm':p.dependencies['drizzle-orm'],\
'pg':p.dependencies['pg']\
},\
devDependencies:{'drizzle-kit':p.devDependencies['drizzle-kit']}\
}));" && \
pnpm install --no-frozen-lockfile --ignore-scripts && \
chown nextjs:nodejs package.json
# Copiar entrypoint de migrations # Copiar entrypoint de migrations
COPY docker-entrypoint.sh ./ COPY docker-entrypoint.sh ./
RUN chmod +x /app/docker-entrypoint.sh && chown nextjs:nodejs /app/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 \

289
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,24 @@
<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) - [Como rodar o OpenMonetis](#-como-rodar-o-openmonetis)
- [Início Rápido (manual)](#-início-rápido) - [Antes de tudo: preparar o servidor](#-antes-de-tudo-preparar-o-servidor-ubuntu-2404)
- [Modo 1 — Só quero usar](#modo-1--só-quero-usar-sem-mexer-no-código)
- [Modo 2 — Quero configurar opcionais](#modo-2--quero-configurar-opcionais-oauth-e-mail-ia)
- [Modo 3 — Quero contribuir com o código](#modo-3--quero-contribuir-com-o-código)
- [Modo 4 — Banco remoto](#modo-4--banco-remoto-supabase-neon-railway)
- [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 +62,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 +86,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.
@@ -93,9 +106,60 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
--- ---
## ⚡ Instalação via Script ## 🚀 Como rodar o OpenMonetis
A forma mais rápida de instalar. O script verifica dependências, configura o `.env` interativamente e sobe o banco automaticamente. O OpenMonetis precisa de dois serviços funcionando ao mesmo tempo para operar: **o app** (a interface e a lógica do sistema) e **o banco de dados** (onde ficam guardados seus lançamentos, contas, categorias etc.). Existem algumas formas de subir os dois, dependendo do que você quer fazer.
---
### 🖥️ Antes de tudo: preparar o servidor (Ubuntu 24.04)
Se você está em um **servidor Ubuntu limpo** (VPS, Oracle Cloud, DigitalOcean...) sem Docker, Node.js ou pnpm instalados, rode esse script primeiro. Ele instala tudo que é necessário automaticamente e pula o que já estiver presente.
> ⚠️ **Testado apenas em Ubuntu Server 24.04 LTS.** Em outras distribuições ou versões, instale as dependências manualmente.
```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:
| 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 |
Ao final, o usuário atual é adicionado ao grupo `docker` — faça **logout e login** para a mudança ter efeito antes de continuar.
Se já tiver Docker e Node.js 22+ instalados, pule essa etapa.
---
### Modo 1 — Só quero usar (sem mexer no código)
Esse é o caminho mais simples. Você baixa apenas um arquivo de configuração e o Docker faz o resto: baixa a imagem do app pronta do Docker Hub e sobe o banco automaticamente. **Não precisa clonar o repositório nem instalar dependências.**
```bash
# 1. Baixa o arquivo de configuração
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
# 2. Sobe o app e o banco juntos
docker compose --profile local up
```
Acesse em: `http://localhost:3000`
> O banco sobe com as credenciais padrão. Se quiser usar uma senha personalizada, crie um arquivo `.env` na mesma pasta antes de subir — veja a seção [Variáveis de Ambiente](#-variáveis-de-ambiente).
---
### Modo 2 — Quero configurar opcionais (OAuth, e-mail, IA)
Se quiser ativar login com Google, envio de e-mails, integrações com IA ou outras configurações, use o assistente interativo. Ele faz perguntas passo a passo e gera o arquivo `.env` com tudo preenchido corretamente.
**Pré-requisito:** Node.js 22+ **Pré-requisito:** Node.js 22+
@@ -107,68 +171,64 @@ curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/se
curl -o setup.mjs https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs ; node setup.mjs curl -o setup.mjs https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs ; node setup.mjs
``` ```
O script irá: O assistente vai perguntar:
- Verificar Node, pnpm, Git e Docker - Banco local (Docker) ou remoto (Supabase, Neon, Railway...)?
- Perguntar se quer banco local (Docker) ou remoto (Supabase, Neon, etc.) - URL da aplicação
- Gerar o `BETTER_AUTH_SECRET` automaticamente - Google OAuth (opcional)
- Configurar opcionais: Google OAuth, e-mail, IA, domínio público - E-mail via Resend (opcional)
- Clonar o repositório, instalar dependências e aplicar o schema - Chaves de IA — Claude, GPT, Gemini, OpenRouter (opcional)
- Domínio público separado para landing page (opcional)
Ao final, ele clona o repositório, instala as dependências e aplica o schema no banco. Depois é só subir:
```bash
cd openmonetis
docker compose --profile local up
```
--- ---
## 🚀 Início Rápido (manual) ### Modo 3 — Quero contribuir com o código
### Pré-requisitos Nesse modo o Next.js roda **direto no seu servidor com hot-reload** — toda vez que você salva um arquivo, o sistema atualiza automaticamente sem precisar reiniciar nada. O banco continua rodando dentro do Docker (mais simples), só o app fica fora.
- Node.js 22+ e pnpm ```bash
- Docker e Docker Compose # 1. Clona o repositório e roda o assistente de configuração
git clone https://github.com/felipegcoutinho/openmonetis.git
cd openmonetis
node setup.mjs
### Passo a Passo # 2. Sobe apenas o banco em background (o app vai rodar fora do Docker)
pnpm docker:up:db
1. **Clone e instale** # 3. Inicia o app com hot-reload
pnpm dev
```
```bash Acesse em: `http://localhost:3000`
git clone https://github.com/felipegcoutinho/openmonetis.git
cd openmonetis
pnpm install
```
2. **Configure o `.env`** ---
```bash ### Modo 4 — Banco remoto (Supabase, Neon, Railway...)
cp .env.example .env
```
Edite o `.env` com suas credenciais. O principal é o `DATABASE_URL` e o `BETTER_AUTH_SECRET`: Se você já tem um banco PostgreSQL hospedado em algum serviço externo, não precisa rodar o banco localmente. Informe a URL do banco durante o `setup.mjs` (escolha a opção "URL remota") e suba só o app:
```env ```bash
# Banco local (Docker): use host "localhost" node setup.mjs # escolha "URL remota" e cole a URL do seu banco
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
# Banco remoto (Supabase, Neon, etc): use a URL completa do provider docker compose up
# DATABASE_URL=postgresql://user:password@host:5432/database?sslmode=require ```
BETTER_AUTH_SECRET=seu-secret-aqui # gere com: openssl rand -base64 32 ---
BETTER_AUTH_URL=http://localhost:3000
```
3. **Suba o banco de dados** (pule se estiver usando banco remoto) ### Comparativo
```bash | O que quero fazer | Comandos principais |
docker compose up db -d |---|---|
pnpm db:extensions | Só usar, da forma mais simples | `docker-compose.yml``docker compose --profile local up` |
``` | Usar com Google OAuth, e-mail ou IA | `node setup.mjs``docker compose --profile local up` |
| Desenvolver e contribuir com o código | `node setup.mjs``pnpm docker:up:db``pnpm dev` |
4. **Execute as migrations e inicie** | Usar com banco remoto (Supabase etc.) | `node setup.mjs``docker compose up` |
```bash
pnpm db:push
pnpm dev
```
5. Acesse `http://localhost:3000`
> **Docker completo** (app + banco em containers): use `pnpm docker:up` ao invés dos passos 3-4.
--- ---
@@ -190,26 +250,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 +284,43 @@ 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 nem de arquivo `.env` para começar — as credenciais padrão já estão configuradas.
```bash
# 1. Baixar o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
# 2. Subir (sem precisar de .env)
docker compose --profile local up
# ou, se tiver o projeto clonado:
pnpm docker:up:local
```
> Se quiser personalizar senhas ou ativar opcionais, crie um arquivo `.env` na mesma pasta antes de subir. Veja a seção [Variáveis de Ambiente](#-variáveis-de-ambiente).
**Modo 2 — App com banco remoto**
Use quando o banco está em um provider externo (Supabase, Neon, Railway...). Configure o `DATABASE_URL` no `.env` com a URL do seu banco e suba só o app.
```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 em vez de usar a imagem do Docker Hub. Útil para testar mudanças antes de publicar.
```bash
pnpm docker:up:db # sobe o banco em background
pnpm docker:up # builda e sobe o app localmente
```
### Comandos úteis ### Comandos úteis
```bash ```bash
@@ -239,6 +340,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 +419,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

@@ -81,7 +81,8 @@ services:
NODE_ENV: production NODE_ENV: production
# Banco local: use host "db" | Banco remoto: URL completa do provider # Banco local: use host "db" | Banco remoto: URL completa do provider
DATABASE_URL: ${DATABASE_URL} # O entrypoint converte automaticamente "localhost" → "db" se necessário
DATABASE_URL: ${DATABASE_URL:-postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db}
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}
@@ -109,6 +110,14 @@ 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:-}
# Multi-domínio (opcional)
PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-}
# Analytics (opcional)
UMAMI_URL: ${UMAMI_URL:-}
UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
UMAMI_DOMAINS: ${UMAMI_DOMAINS:-}
# required: false permite subir sem banco local (banco remoto via DATABASE_URL) # required: false permite subir sem banco local (banco remoto via DATABASE_URL)
depends_on: depends_on:
db: db:

View File

@@ -1,8 +1,23 @@
#!/bin/sh #!/bin/sh
set -e
echo "Rodando migrations do banco de dados..." # Dentro do container Docker, "localhost" não alcança o serviço de banco.
./node_modules/.bin/drizzle-kit push # Substitui automaticamente para o nome do serviço "db" da rede Docker.
echo "Migrations concluídas." # Não afeta URLs de bancos remotos (não contêm "@localhost:").
if [ -n "$DATABASE_URL" ]; then
DATABASE_URL=$(echo "$DATABASE_URL" | sed 's|@localhost:|@db:|g')
export DATABASE_URL
fi
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 "$@" exec "$@"

View File

@@ -10,7 +10,11 @@ const nextConfig: NextConfig = {
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",
@@ -43,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,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.3.0", "version": "2.3.7",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
"scripts": { "scripts": {
@@ -11,36 +11,62 @@
"lint": "biome check .", "lint": "biome check .",
"lint:deadcode": "knip --reporter compact", "lint:deadcode": "knip --reporter compact",
"lint:fix": "biome check --write .", "lint:fix": "biome check --write .",
"env:setup": "bash scripts/setup-env.sh", "env:setup": "node setup.mjs",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:extensions": "tsx scripts/postgres/enable-extensions.ts", "db: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.65", "@ai-sdk/anthropic": "^3.0.68",
"@ai-sdk/google": "^3.0.55", "@ai-sdk/google": "^3.0.61",
"@ai-sdk/openai": "^3.0.49", "@ai-sdk/openai": "^3.0.52",
"@aws-sdk/client-s3": "^3.1022.0", "@aws-sdk/client-s3": "^3.1027.0",
"@aws-sdk/s3-request-presigner": "^3.1022.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",
@@ -61,46 +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.96.2", "@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.143", "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.2.2", "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.10.0", "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.10", "@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.4.0", "dotenv": "^17.4.1",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"knip": "^6.3.0", "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"

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

View File

@@ -1,18 +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"],
{ display: "swap",
path: "./america-regular.woff2", variable: "--font-inter",
weight: "400", fallback: ["ui-sans-serif", "system-ui"],
style: "normal", preload: true,
},
{
path: "./america-medium.woff2",
weight: "500",
style: "normal",
},
],
display: "fallback",
variable: "--font-america",
}); });

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

View File

@@ -1,17 +0,0 @@
#!/usr/bin/env tsx
import { execSync } from "node:child_process";
import { config } from "dotenv";
// Carregar variáveis de ambiente
config();
const port = process.env.PORT || "3000";
console.log(`Starting Next.js development server on port ${port}...`);
// Executar next dev com a porta especificada
execSync(`npx next dev --turbopack --port ${port}`, {
stdio: "inherit",
env: { ...process.env, PORT: port },
});

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

@@ -0,0 +1,245 @@
#!/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"
# Suprimir prompt interativo do corepack ao chamar pnpm/node versioning
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# ── 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 || true
wait "$_spin_pid" 2>/dev/null || true
_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_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
else
run_quiet "Instalando pnpm via corepack" \
sh -c 'corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
fi
ok "pnpm instalado"
fi
# ── Resumo ─────────────────────────────────────────────────────────────────────
# Garantir que node/pnpm do brew estejam no PATH para o resumo
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
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)"

View File

@@ -1,49 +0,0 @@
#!/bin/bash
# Script para configurar ambiente de forma segura
# Cria backup do .env atual antes de sobrescrever
set -e
echo "🔧 Configurando ambiente..."
# Se .env já existe, criar backup
if [ -f .env ]; then
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
echo "⚠️ Arquivo .env existente detectado!"
echo "📦 Criando backup em: $BACKUP_FILE"
cp .env "$BACKUP_FILE"
echo "✅ Backup criado com sucesso!"
echo ""
read -p "Deseja sobrescrever o .env atual com .env.example? (s/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Ss]$ ]]; then
echo "❌ Operação cancelada. Seu .env não foi modificado."
exit 0
fi
fi
# Copiar .env.example para .env
if [ -f .env.example ]; then
cp .env.example .env
echo "✅ Arquivo .env criado a partir de .env.example"
else
echo "❌ Erro: .env.example não encontrado!"
exit 1
fi
# Gerar BETTER_AUTH_SECRET automaticamente
if command -v openssl &> /dev/null; then
SECRET=$(openssl rand -base64 32)
sed -i.bak "s|BETTER_AUTH_SECRET=.*|BETTER_AUTH_SECRET=$SECRET|" .env && rm -f .env.bak
echo "✅ BETTER_AUTH_SECRET gerado automaticamente"
else
echo "⚠️ openssl não encontrado — configure BETTER_AUTH_SECRET manualmente:"
echo " openssl rand -base64 32"
fi
echo ""
echo "⚠️ IMPORTANTE: Edite o arquivo .env e configure:"
echo " - DATABASE_URL"
echo " - BETTER_AUTH_URL"
echo " - Demais variáveis opcionais (OAuth, e-mail, IA)"

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,7 +1,7 @@
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";
@@ -13,7 +13,19 @@ 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 })

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,74 +0,0 @@
import { headers } from "next/headers";
import { connection, 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) {
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 {
// 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

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

@@ -3,7 +3,7 @@ import {
fetchSavedInsights, fetchSavedInsights,
savedInsightsPeriodSchema, savedInsightsPeriodSchema,
} from "@/features/insights/queries"; } from "@/features/insights/queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = { const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store", "Cache-Control": "private, no-store",
@@ -25,8 +25,18 @@ export async function GET(request: Request) {
); );
} }
const userId = await getUserId(); const session = await getOptionalUserSession();
const insights = await fetchSavedInsights(userId, validatedPeriod.data); 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, { return NextResponse.json(insights, {
headers: PRIVATE_RESPONSE_HEADERS, headers: PRIVATE_RESPONSE_HEADERS,

View File

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

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries"; import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
import { getUserId } from "@/shared/lib/auth/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = { const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store", "Cache-Control": "private, no-store",
@@ -11,7 +11,19 @@ export async function GET(
{ params }: { params: Promise<{ seriesId: string }> }, { params }: { params: Promise<{ seriesId: string }> },
) { ) {
try { try {
const [userId, { seriesId }] = await Promise.all([getUserId(), params]); 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); const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
return NextResponse.json(anticipations, { return NextResponse.json(anticipations, {

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

@@ -4,7 +4,7 @@ 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: {
@@ -24,19 +24,23 @@ export default function RootLayout({
<html <html
data-scroll-behavior="smooth" 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">
<QueryProvider> <QueryProvider>
<Suspense>{children}</Suspense> <Suspense>{children}</Suspense>

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

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

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

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

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

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

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

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

@@ -46,7 +46,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
<span <span
className={cn( className={cn(
"cursor-help rounded-full py-0.5", "cursor-help rounded-full py-0.5",
bill.isSettled && "text-success", bill.isSettled && "text-success font-semibold",
)} )}
> >
{statusLabel} {statusLabel}
@@ -60,7 +60,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
<span <span
className={cn( className={cn(
"rounded-full py-0.5", "rounded-full py-0.5",
bill.isSettled && "text-success", bill.isSettled && "text-success font-semibold",
)} )}
> >
{statusLabel} {statusLabel}
@@ -72,7 +72,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
</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"

View File

@@ -97,7 +97,7 @@ export function BillPaymentDialog({
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide"> <p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Boleto Boleto
</p> </p>
<p className="text-base font-medium text-foreground"> <p className="text-base font-semibold text-foreground">
{bill.name} {bill.name}
</p> </p>
</div> </div>
@@ -113,7 +113,7 @@ export function BillPaymentDialog({
</div> </div>
<MoneyValues <MoneyValues
amount={bill.amount} amount={bill.amount}
className="text-lg font-medium" className="text-lg font-semibold"
/> />
</div> </div>

View File

@@ -281,12 +281,12 @@ export function CategoryBreakdownWidgetView({
<div className="flex shrink-0 flex-col items-end gap-0.5"> <div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues <MoneyValues
className="text-foreground" className="text-foreground font-medium"
amount={category.currentAmount} amount={category.currentAmount}
/> />
{category.percentageChange !== null ? ( {category.percentageChange !== null ? (
<span <span
className={`flex items-center gap-0.5 text-xs ${changeClassName}`} className={`flex items-center gap-0.5 text-xs font-medium ${changeClassName}`}
> >
{hasIncrease ? ( {hasIncrease ? (
<RiArrowUpSFill className="size-3" /> <RiArrowUpSFill className="size-3" />

View File

@@ -197,7 +197,9 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
/> />
)} )}
<span className="text-foreground">{category.name}</span> <span className="text-sm font-medium text-foreground">
{category.name}
</span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -398,7 +400,7 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
{config?.label} {config?.label}
</span> </span>
</div> </div>
<span className="text-xs font-medium tabular-nums"> <span className="text-xs font-medium">
{formatCurrency(value)} {formatCurrency(value)}
</span> </span>
</div> </div>

View File

@@ -0,0 +1,84 @@
"use client";
import {
RiArrowDownSFill,
RiArrowUpSFill,
RiLineChartLine,
} from "@remixicon/react";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { cn } from "@/shared/utils/ui";
type CategoryTrendsWidgetProps = {
categories: DashboardCategoryBreakdownItem[];
};
export function CategoryTrendsWidget({
categories,
}: CategoryTrendsWidgetProps) {
const trending = categories
.filter((c) => c.percentageChange !== null && c.previousAmount > 0)
.sort(
(a, b) =>
Math.abs(b.percentageChange ?? 0) - Math.abs(a.percentageChange ?? 0),
)
.slice(0, 10);
if (trending.length === 0) {
return (
<WidgetEmptyState
icon={<RiLineChartLine className="size-6 text-muted-foreground" />}
title="Dados insuficientes"
description="As variações aparecem após lançamentos em dois meses consecutivos."
/>
);
}
return (
<ul className="flex flex-col space-y-1">
{trending.map((category) => {
const change = category.percentageChange ?? 0;
const isUp = change > 0;
return (
<li key={category.categoryId}>
<div className="-mx-2 flex items-center gap-3 rounded-md p-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
size="md"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{category.categoryName}
</p>
<p className="text-xs text-muted-foreground">
<MoneyValues amount={category.previousAmount} /> vs{" "}
<MoneyValues
amount={category.currentAmount}
className="font-semibold"
/>
</p>
</div>
<span
className={cn(
"inline-flex shrink-0 items-center gap-0.5 font-semibold text-sm",
isUp ? " text-destructive" : " text-success",
)}
>
{isUp ? (
<RiArrowUpSFill className="size-3.5" />
) : (
<RiArrowDownSFill className="size-3.5" />
)}
{Math.abs(change).toFixed(0)}%
</span>
</div>
</li>
);
})}
</ul>
);
}

View File

@@ -34,12 +34,12 @@ import {
type WidgetPreferences, type WidgetPreferences,
} from "@/features/dashboard/widgets/actions"; } from "@/features/dashboard/widgets/actions";
import { import {
type DashboardWidgetQuickActionOptions,
type WidgetConfig, type WidgetConfig,
widgetsConfig, widgetsConfig,
} from "@/features/dashboard/widgets/widgets-config"; } from "@/features/dashboard/widgets/widgets-config";
import { NoteDialog } from "@/features/notes/components/note-dialog"; import { NoteDialog } from "@/features/notes/components/note-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog"; import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import type { SelectOption } from "@/features/transactions/components/types";
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card"; import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
@@ -47,15 +47,7 @@ type DashboardGridEditableProps = {
data: DashboardData; data: DashboardData;
period: string; period: string;
initialPreferences: WidgetPreferences | null; initialPreferences: WidgetPreferences | null;
quickActionOptions: { quickActionOptions: DashboardWidgetQuickActionOptions;
payerOptions: SelectOption[];
splitPayerOptions: SelectOption[];
defaultPayerId: string | null;
accountOptions: SelectOption[];
cardOptions: SelectOption[];
categoryOptions: SelectOption[];
estabelecimentos: string[];
};
}; };
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id); const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
@@ -368,11 +360,16 @@ export function DashboardGridEditable({
{widget.component({ {widget.component({
data, data,
period, period,
adminPayerSlug:
quickActionOptions.payerOptions.find(
(p) => p.value === quickActionOptions.defaultPayerId,
)?.slug ?? null,
widgetPreferences: { widgetPreferences: {
order: widgetOrder, order: widgetOrder,
hidden: hiddenWidgets, hidden: hiddenWidgets,
myAccountsShowExcluded, myAccountsShowExcluded,
}, },
quickActionOptions,
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded, onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
})} })}
</ExpandableWidgetCard> </ExpandableWidgetCard>

View File

@@ -116,13 +116,14 @@ const getPercentChange = (current: number, previous: number): string => {
} }
const change = ((current - previous) / Math.abs(previous)) * 100; const change = ((current - previous) / Math.abs(previous)) * 100;
return Number.isFinite(change) && Math.abs(change) < 1000000 if (!Number.isFinite(change)) return "—";
? formatPercentage(change, { if (change > 999) return "+999%";
maximumFractionDigits: 1, if (change < -999) return "-999%";
minimumFractionDigits: 1, return formatPercentage(change, {
signDisplay: "always", maximumFractionDigits: 2,
}) minimumFractionDigits: 2,
: "—"; signDisplay: "always",
});
}; };
const getTrendBadgeClass = (trend: Trend, invertTrend: boolean): string => { const getTrendBadgeClass = (trend: Trend, invertTrend: boolean): string => {
@@ -159,7 +160,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<CardTitle className="flex items-center gap-1.5 tracking-tight"> <CardTitle className="flex items-center gap-1.5 ">
<Icon className={cn("size-4", iconClass)} aria-hidden /> <Icon className={cn("size-4", iconClass)} aria-hidden />
{label} {label}
<MetricsCardInfoButton <MetricsCardInfoButton
@@ -179,12 +180,12 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2 mt-1"> <div className="flex flex-wrap items-center justify-between gap-2 mt-1">
<MoneyValues <MoneyValues
className="text-2xl leading-none" className="text-2xl leading-none font-medium"
amount={metric.current} amount={metric.current}
/> />
<div <div
className={cn( className={cn(
"inline-flex items-center gap-1 text-xs ", "inline-flex items-center gap-1 text-xs font-medium",
trendBadgeClass, trendBadgeClass,
)} )}
> >
@@ -195,7 +196,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
<MoneyValues <MoneyValues
className="inline text-xs text-muted-foreground" className="inline text-xs font-medium text-muted-foreground"
amount={metric.previous} amount={metric.previous}
/> />
<span className="ml-1">no mês anterior</span> <span className="ml-1">no mês anterior</span>

View File

@@ -1,17 +1,21 @@
import { formatCurrentDate, getGreeting } from "./welcome-widget"; import { formatCurrentDate, getGreeting } from "./welcome-widget";
export function DashboardWelcome({ name }: { name?: string | null }) { type DashboardWelcomeProps = {
name?: string | null;
};
export function DashboardWelcome({ name }: DashboardWelcomeProps) {
const displayName = name && name.trim().length > 0 ? name : "Administrador"; const displayName = name && name.trim().length > 0 ? name : "Administrador";
const formattedDate = formatCurrentDate(); const formattedDate = formatCurrentDate();
const greeting = getGreeting(); const greeting = getGreeting();
return ( return (
<section className="py-4"> <section className="py-4">
<div className="tracking-tight"> <div>
<h1 className="text-xl font-medium"> <h1 className="text-xl tracking-tight">
{greeting}, {displayName} {greeting}, {displayName}
</h1> </h1>
<h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2> <h2 className="mt-1 text-sm text-muted-foreground">{formattedDate}</h2>
</div> </div>
</section> </section>
); );

View File

@@ -44,8 +44,9 @@ export function GoalProgressItem({
{item.categoryName} {item.categoryName}
</p> </p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
<MoneyValues amount={item.spentAmount} /> de{" "} <MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
<MoneyValues amount={item.budgetAmount} /> de{" "}
<MoneyValues className="font-medium" amount={item.budgetAmount} />
<span className={`ml-1.5 font-medium ${deltaColor}`}> <span className={`ml-1.5 font-medium ${deltaColor}`}>
{formatGoalProgressPercentage(percentageDelta, true)} {formatGoalProgressPercentage(percentageDelta, true)}
</span> </span>

View File

@@ -0,0 +1,268 @@
"use client";
import {
RiCheckboxCircleFill,
RiCheckLine,
RiDeleteBinLine,
} from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { DashboardInboxSnapshot } from "@/features/dashboard/inbox-snapshot-queries";
import type { DashboardWidgetQuickActionOptions } from "@/features/dashboard/widgets/widgets-config";
import {
discardInboxItemAction,
markInboxAsProcessedAction,
} from "@/features/inbox/actions";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { resolveLogoSrc } from "@/shared/lib/logo";
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
function relativeTime(date: Date): string {
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "agora";
if (minutes < 60) return `${minutes}min`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
type InboxWidgetProps = {
snapshot: DashboardInboxSnapshot;
quickActionOptions: DashboardWidgetQuickActionOptions;
};
function getDateString(date: Date | string | null | undefined): string | null {
if (!date) return null;
if (typeof date === "string") return date.slice(0, 10);
return date.toISOString().slice(0, 10);
}
export function InboxWidget({
snapshot,
quickActionOptions,
}: InboxWidgetProps) {
const router = useRouter();
const [processOpen, setProcessOpen] = useState(false);
const [discardOpen, setDiscardOpen] = useState(false);
const [itemToProcess, setItemToProcess] = useState<
DashboardInboxSnapshot["recentItems"][number] | null
>(null);
const [itemToDiscard, setItemToDiscard] = useState<
DashboardInboxSnapshot["recentItems"][number] | null
>(null);
const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open);
if (!open) setItemToProcess(null);
};
const handleDiscardOpenChange = (open: boolean) => {
setDiscardOpen(open);
if (!open) setItemToDiscard(null);
};
const handleProcessRequest = (
item: DashboardInboxSnapshot["recentItems"][number],
) => {
setItemToProcess(item);
setProcessOpen(true);
};
const handleDiscardRequest = (
item: DashboardInboxSnapshot["recentItems"][number],
) => {
setItemToDiscard(item);
setDiscardOpen(true);
};
const refreshWidget = () => {
router.refresh();
};
const handleDiscardConfirm = async () => {
if (!itemToDiscard) return;
const result = await discardInboxItemAction({
inboxItemId: itemToDiscard.id,
});
if (result.success) {
toast.success(result.message);
refreshWidget();
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleLancamentoSuccess = async () => {
if (!itemToProcess) return;
const result = await markInboxAsProcessedAction({
inboxItemId: itemToProcess.id,
});
if (result.success) {
toast.success("Notificação processada!");
refreshWidget();
return;
}
toast.error(result.error);
};
const defaultPurchaseDate =
getDateString(itemToProcess?.notificationTimestamp) ?? null;
const defaultName = itemToProcess?.parsedName
? itemToProcess.parsedName
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase())
: null;
const defaultAmount = itemToProcess?.parsedAmount
? String(Math.abs(Number(itemToProcess.parsedAmount)))
: null;
const matchedCardId = useMemo(() => {
const appName = itemToProcess?.sourceAppName?.toLowerCase();
if (!appName) return null;
for (const option of quickActionOptions.cardOptions) {
const label = option.label.toLowerCase();
if (label.includes(appName) || appName.includes(label)) {
return option.value;
}
}
return null;
}, [itemToProcess?.sourceAppName, quickActionOptions.cardOptions]);
if (snapshot.pendingCount === 0) {
return (
<WidgetEmptyState
icon={<RiCheckboxCircleFill color="green" className="size-6" />}
title="Tudo em dia"
description="Nenhum pré-lançamento aguardando revisão."
/>
);
}
return (
<div className="flex flex-col">
{snapshot.recentItems.map((item) => {
const displayName = item.parsedName ?? item.originalText.slice(0, 40);
const parsedAmount =
item.parsedAmount !== null
? Number.parseFloat(item.parsedAmount)
: null;
const amount =
parsedAmount !== null && Number.isFinite(parsedAmount)
? parsedAmount
: null;
const logoKey = item.sourceAppName?.toLowerCase() ?? "";
const rawLogo = snapshot.logoMap[logoKey] ?? null;
const logoSrc = resolveLogoSrc(rawLogo);
const displayLogo = logoSrc ?? DEFAULT_INBOX_APP_LOGO;
return (
<div
key={item.id}
className="flex items-center justify-between py-1.5"
>
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<Image
src={displayLogo}
alt={item.sourceAppName ?? ""}
width={38}
height={38}
className="size-9.5 shrink-0 rounded-full object-contain"
unoptimized
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{displayName}
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{item.sourceAppName && <span>{item.sourceAppName}</span>}
<span className="text-muted-foreground/60">
{relativeTime(item.createdAt)}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
{amount !== null && (
<MoneyValues className="font-medium" amount={amount} />
)}
<div className="flex items-center">
<Button
size="icon-sm"
variant="ghost"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => handleProcessRequest(item)}
aria-label="Processar notificação"
title="Processar"
>
<RiCheckLine className="size-3.5" />
</Button>
<Button
size="icon-sm"
variant="ghost"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() => handleDiscardRequest(item)}
aria-label="Descartar notificação"
title="Descartar"
>
<RiDeleteBinLine className="size-3.5" />
</Button>
</div>
</div>
</div>
);
})}
<TransactionDialog
mode="create"
open={processOpen}
onOpenChange={handleProcessOpenChange}
payerOptions={quickActionOptions.payerOptions}
splitPayerOptions={quickActionOptions.splitPayerOptions}
defaultPayerId={quickActionOptions.defaultPayerId}
accountOptions={quickActionOptions.accountOptions}
cardOptions={quickActionOptions.cardOptions}
categoryOptions={quickActionOptions.categoryOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPurchaseDate={defaultPurchaseDate}
defaultName={defaultName}
defaultAmount={defaultAmount}
defaultCardId={matchedCardId}
defaultPaymentMethod={matchedCardId ? "Cartão de crédito" : null}
defaultTransactionType="Despesa"
forceShowTransactionType
onSuccess={handleLancamentoSuccess}
/>
<ConfirmActionDialog
open={discardOpen}
onOpenChange={handleDiscardOpenChange}
title="Descartar notificação?"
description="A notificação será marcada como descartada e não aparecerá mais na lista de pendentes."
confirmLabel="Descartar"
confirmVariant="destructive"
pendingLabel="Descartando..."
onConfirm={handleDiscardConfirm}
/>
</div>
);
}

View File

@@ -132,12 +132,12 @@ export function InstallmentAnalysisPage({
{/* Card de resumo principal */} {/* Card de resumo principal */}
<Card className="border-none bg-primary/15"> <Card className="border-none bg-primary/15">
<CardContent className="flex flex-col items-start justify-center gap-2 py-2"> <CardContent className="flex flex-col items-start justify-center gap-2 py-2">
<p className="text-sm font-medium text-muted-foreground"> <p className="text-sm text-muted-foreground">
Se você pagar tudo que está selecionado: Se você pagar tudo que está selecionado:
</p> </p>
<MoneyValues <MoneyValues
amount={grandTotal} amount={grandTotal}
className="text-3xl font-medium text-primary" className="text-3xl font-semibold text-primary"
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "} {selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
@@ -167,7 +167,7 @@ export function InstallmentAnalysisPage({
{/* Seção de Lançamentos Parcelados */} {/* Seção de Lançamentos Parcelados */}
{data.installmentGroups.length > 0 && ( {data.installmentGroups.length > 0 && (
<div className="flex flex-col gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{data.installmentGroups.map((group) => ( {data.installmentGroups.map((group) => (
<InstallmentGroupCard <InstallmentGroupCard
key={group.seriesId} key={group.seriesId}

View File

@@ -1,19 +1,35 @@
"use client"; "use client";
import { import {
RiArrowDownSLine, RiBankCard2Line,
RiArrowRightSLine,
RiCheckboxCircleFill, RiCheckboxCircleFill,
RiEyeLine,
RiTimeLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { format } from "date-fns"; import { format } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Checkbox } from "@/shared/components/ui/checkbox"; import { Checkbox } from "@/shared/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Progress } from "@/shared/components/ui/progress"; import { Progress } from "@/shared/components/ui/progress";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils";
import type { InstallmentGroup } from "./types"; import type { InstallmentGroup } from "./types";
type InstallmentGroupCardProps = { type InstallmentGroupCardProps = {
@@ -29,18 +45,22 @@ export function InstallmentGroupCard({
onToggleGroup, onToggleGroup,
onToggleInstallment, onToggleInstallment,
}: InstallmentGroupCardProps) { }: InstallmentGroupCardProps) {
const [isExpanded, setIsExpanded] = useState(false); const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const unpaidInstallments = group.pendingInstallments.filter( const unpaidInstallments = group.pendingInstallments.filter(
(i) => !i.isSettled, (i) => !i.isSettled,
); );
const paidInstallments = group.pendingInstallments.filter((i) => i.isSettled);
const unpaidCount = unpaidInstallments.length; const unpaidCount = unpaidInstallments.length;
const isFullySelected = const isFullySelected =
selectedInstallments.size === unpaidInstallments.length && selectedInstallments.size === unpaidInstallments.length &&
unpaidInstallments.length > 0; unpaidInstallments.length > 0;
const isPartiallySelected = selectedInstallments.size > 0 && !isFullySelected;
const hasSelection = selectedInstallments.size > 0;
const progress = const progress =
group.totalInstallments > 0 group.totalInstallments > 0
? (group.paidInstallments / group.totalInstallments) * 100 ? (group.paidInstallments / group.totalInstallments) * 100
@@ -50,186 +70,304 @@ export function InstallmentGroupCard({
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled) .filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
.reduce((sum, i) => sum + Number(i.amount), 0); .reduce((sum, i) => sum + Number(i.amount), 0);
// Calcular valor total de todas as parcelas (pagas + pendentes)
const totalAmount = group.pendingInstallments.reduce( const totalAmount = group.pendingInstallments.reduce(
(sum, i) => sum + i.amount, (sum, i) => sum + i.amount,
0, 0,
); );
// Calcular valor pendente (apenas não pagas)
const pendingAmount = unpaidInstallments.reduce( const pendingAmount = unpaidInstallments.reduce(
(sum, i) => sum + i.amount, (sum, i) => sum + i.amount,
0, 0,
); );
return ( return (
<Card className={cn(isFullySelected && "border-primary/50")}> <>
<CardContent className="flex flex-col gap-2"> <Card
{/* Header do card */} className={cn(
<div className="flex items-start gap-3"> "overflow-hidden transition-all duration-300",
<Checkbox isFullySelected && "ring-2 ring-primary/30 border-primary/50",
checked={isFullySelected} isPartiallySelected && "border-primary/30",
onCheckedChange={onToggleGroup} )}
className="mt-1" >
aria-label={`Selecionar todas as parcelas de ${group.name}`} {/* Header Section */}
/> <CardHeader className="pb-0">
<div className="flex items-start gap-2">
{/* Checkbox de seleção do grupo */}
<div className="pt-1">
<Checkbox
checked={
isFullySelected
? true
: isPartiallySelected
? "indeterminate"
: false
}
onCheckedChange={onToggleGroup}
className="size-4"
aria-label={`Selecionar todas as parcelas de ${group.name}`}
/>
</div>
<div className="min-w-0 flex-1"> {/* Info principal */}
<div className="flex flex-col gap-1"> <div className="flex-1 min-w-0">
<div className="flex gap-1 items-center flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{group.cartaoLogo && ( {group.cartaoLogo ? (
<img <Image
src={`/logos/${group.cartaoLogo}`} src={`/logos/${group.cartaoLogo}`}
alt={group.cartaoName ?? "Cartão"} alt={group.cartaoName ?? "Cartão"}
className="h-6 w-auto object-contain rounded" width={40}
height={40}
className="size-10 rounded-full object-cover"
/> />
) : (
<div className="size-10 flex items-center justify-center">
<RiBankCard2Line className="size-5 text-muted-foreground" />
</div>
)} )}
<span className="font-medium truncate">{group.name}</span> <div className="flex-1 min-w-0">
<span className="text-xs text-muted-foreground"> <CardTitle className="text-base truncate">
| {group.cartaoName} {group.name}
</span> </CardTitle>
</div> <CardDescription className="text-xs">
{group.cartaoName ?? "Compra parcelada"}
<div className="flex items-center gap-3 flex-wrap"> </CardDescription>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">Total:</span>
<MoneyValues
amount={totalAmount}
className="text-base font-medium"
/>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
Pendente:
</span>
<MoneyValues
amount={pendingAmount}
className="text-sm font-medium text-primary"
/>
</div> </div>
</div> </div>
</div> </div>
{/* Progress bar */} {/* Badge de status */}
<div className="mt-3"> <Badge
<div className="mb-2 flex flex-wrap items-center px-1 justify-between gap-x-2 gap-y-0.5 text-xs text-muted-foreground"> variant={progress === 100 ? "default" : "outline"}
className={cn("shrink-0", progress === 100 && "bg-success")}
>
{progress === 100 ? "Quitado" : `${Math.round(progress)}% pago`}
</Badge>
</div>
</CardHeader>
<CardContent>
{/* Grid de valores */}
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-muted/50 mb-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground font-medium">
Valor total
</p>
<MoneyValues
amount={totalAmount}
className="text-lg font-semibold text-foreground"
/>
</div>
<div className="space-y-1 text-right">
<p className="text-xs text-muted-foreground font-medium">
Pendente
</p>
<MoneyValues
amount={pendingAmount}
className={cn(
"text-lg font-semibold",
pendingAmount > 0 ? "text-amber-600" : "text-success-600",
)}
/>
</div>
</div>
{/* Barra de progresso */}
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1 text-muted-foreground">
<RiCheckboxCircleFill className="size-3.5 text-success" />
<span> <span>
{group.paidInstallments} de {group.totalInstallments} pagas {group.paidInstallments} de {group.totalInstallments} parcelas
pagas
</span> </span>
<div className="flex items-center gap-2 flex-wrap"> </div>
{unpaidCount > 0 && (
<div className="flex items-center gap-1 text-muted-foreground">
<RiTimeLine className="size-3.5 text-amber-600" />
<span> <span>
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"} {unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
</span> </span>
{selectedInstallments.size > 0 && (
<span className="text-primary font-medium">
Selecionado:{" "}
<MoneyValues
amount={selectedAmount}
className="text-xs font-medium text-primary inline"
/>
</span>
)}
</div> </div>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar parcelas ({group.pendingInstallments.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver parcelas ({group.pendingInstallments.length})
</>
)} )}
</button> </div>
<Progress value={progress} className="h-2.5" />
</div> </div>
</div>
{/* Lista de parcelas expandida */} {/* Valor selecionado */}
{isExpanded && ( {hasSelection && (
<div className="px-2 sm:px-8 mt-2 flex flex-col gap-2"> <div className="flex items-center justify-between p-3 rounded-lg bg-primary/5 border border-primary/20 mb-4">
{group.pendingInstallments.map((installment) => { <span className="text-sm font-medium text-foreground">
const isSelected = selectedInstallments.has(installment.id); {selectedInstallments.size}{" "}
const isPaid = installment.isSettled; {selectedInstallments.size === 1
const dueDate = installment.dueDate ? "parcela selecionada"
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR }) : "parcelas selecionadas"}
: format(installment.purchaseDate, "dd/MM/yyyy", { </span>
locale: ptBR, <MoneyValues
}); amount={selectedAmount}
className="text-base font-semibold text-primary"
/>
</div>
)}
return ( {/* Botão para abrir detalhes */}
<div <Button
key={installment.id} type="button"
className={cn( variant="outline"
"flex items-center gap-3 rounded-md border p-2 transition-colors", size="sm"
isSelected && !isPaid && "border-primary/50 bg-primary/5", className="w-full gap-1.5"
isPaid && onClick={() => setIsDetailsOpen(true)}
"border-success/40 bg-success/5 dark:border-success/20 dark:bg-success/5", >
)} <RiEyeLine className="size-4" />
> Ver detalhes ({group.pendingInstallments.length} parcelas)
<Checkbox </Button>
checked={isPaid ? false : isSelected} </CardContent>
disabled={isPaid} </Card>
onCheckedChange={() =>
!isPaid && onToggleInstallment(installment.id)
}
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3"> {/* Modal de detalhes */}
<div className="min-w-0"> <Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
<p <DialogContent className="max-w-md max-h-[80vh] flex flex-col">
className={cn( <DialogHeader>
"text-xs font-medium", <div className="flex items-center gap-3">
isPaid && {group.cartaoLogo ? (
"text-success line-through decoration-success/50", <img
)} src={`/logos/${group.cartaoLogo}`}
> alt={group.cartaoName ?? "Cartão"}
Parcela {installment.currentInstallment}/ className="size-8 rounded-full object-cover"
{group.totalInstallments} />
{isPaid && ( ) : (
<Badge <div className="size-8 rounded-full bg-muted flex items-center justify-center">
variant="outline" <RiBankCard2Line className="size-4 text-muted-foreground" />
className="ml-1 text-xs border-none text-success"
>
<RiCheckboxCircleFill /> Pago
</Badge>
)}
</p>
<p
className={cn(
"text-xs mt-1",
isPaid ? "text-success" : "text-muted-foreground",
)}
>
Vencimento: {dueDate}
</p>
</div>
<MoneyValues
amount={installment.amount}
className={cn(
"shrink-0 text-sm",
isPaid && "text-success",
)}
/>
</div>
</div> </div>
); )}
})} <DialogTitle className="text-base">{group.name}</DialogTitle>
</div>
<DialogDescription className="sr-only">
Detalhes das parcelas do grupo {group.name}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto flex-1 space-y-4 pr-1">
{/* Parcelas pagas */}
{paidInstallments.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Parcelas pagas
</p>
{paidInstallments.map((installment) => {
const dueDate = installment.dueDate
? format(installment.dueDate, "dd MMM yyyy", {
locale: ptBR,
})
: format(installment.purchaseDate, "dd MMM yyyy", {
locale: ptBR,
});
return (
<div
key={installment.id}
className="flex items-center gap-3 p-3 rounded-lg bg-success/5 dark:bg-success/10 border border-success/20 dark:border-success/10"
>
<div className="size-8 rounded-full flex items-center justify-center shrink-0">
<RiCheckboxCircleFill className="size-6 text-success" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-success">
Parcela {installment.currentInstallment}/
{group.totalInstallments}
</p>
<p className="text-xs text-success/80">
Vencimento: {dueDate}
</p>
</div>
<MoneyValues
amount={installment.amount}
className="text-sm font-semibold text-success shrink-0"
/>
</div>
);
})}
</div>
)}
{/* Parcelas pendentes */}
{unpaidInstallments.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Parcelas pendentes
</p>
{unpaidInstallments.map((installment) => {
const isSelected = selectedInstallments.has(installment.id);
const dueDate = installment.dueDate
? format(installment.dueDate, "dd MMM yyyy", {
locale: ptBR,
})
: format(installment.purchaseDate, "dd MMM yyyy", {
locale: ptBR,
});
return (
<label
key={installment.id}
className={cn(
"flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all duration-200",
isSelected
? "bg-primary/5 border-primary/30 shadow-sm"
: "bg-card hover:bg-muted/50 hover:border-border",
)}
>
<Checkbox
checked={isSelected}
onCheckedChange={() =>
onToggleInstallment(installment.id)
}
className="size-5"
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
Parcela {installment.currentInstallment}/
{group.totalInstallments}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<RiTimeLine className="size-3 text-amber-600" />
Vencimento: {dueDate}
</p>
</div>
<MoneyValues
amount={installment.amount}
className={cn(
"text-sm font-semibold shrink-0",
isSelected ? "text-primary" : "text-foreground",
)}
/>
</label>
);
})}
</div>
)}
</div> </div>
)}
</CardContent> {/* Footer com resumo da seleção */}
</Card> {hasSelection && (
<div className="border-t pt-3 mt-1 flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{selectedInstallments.size}{" "}
{selectedInstallments.size === 1
? "parcela selecionada"
: "parcelas selecionadas"}
</span>
<MoneyValues
amount={selectedAmount}
className="text-base font-bold text-primary"
/>
</div>
)}
</DialogContent>
</Dialog>
</>
); );
} }

View File

@@ -59,7 +59,10 @@ export function InstallmentExpenseListItem({
</span> </span>
) : null} ) : null}
</div> </div>
<MoneyValues amount={expense.amount} className="shrink-0" /> <MoneyValues
amount={expense.amount}
className="shrink-0 font-medium"
/>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -67,7 +70,7 @@ export function InstallmentExpenseListItem({
{" · Restante "} {" · Restante "}
<MoneyValues <MoneyValues
amount={remainingAmount} amount={remainingAmount}
className="inline-block font-medium" className="inline-block font-semibold"
/>{" "} />{" "}
({remainingInstallments}) ({remainingInstallments})
</p> </p>

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