Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1161e97d9e | ||
|
|
55d7dedd9a | ||
|
|
ad2752b7b0 | ||
|
|
58db357cde | ||
|
|
99a9ff5512 | ||
|
|
5bcf4f69d3 | ||
|
|
95099c1a94 | ||
|
|
94912f7edc | ||
|
|
bf6adfa3f1 | ||
|
|
e4b9dd4254 | ||
|
|
f1907c8697 | ||
|
|
805bcb863d | ||
|
|
11b4f8940f | ||
|
|
fba9686fdb | ||
|
|
9b8ac9f71f | ||
|
|
fa41c78a39 | ||
|
|
5f7bfb98da | ||
|
|
9ecafdb15f | ||
|
|
e8cc673e52 | ||
|
|
3bd8117b65 | ||
|
|
a7268d8f05 | ||
|
|
1f9098879e | ||
|
|
7a3bff52ac | ||
|
|
dfb4126b12 | ||
|
|
ffead579fa | ||
|
|
aa85cf8b29 | ||
|
|
9a7ae0fa3d | ||
|
|
98fe6a0f4f | ||
|
|
d10eae13e5 |
@@ -44,6 +44,12 @@ GOOGLE_CLIENT_SECRET=
|
||||
# Se não definido, todas as rotas ficam acessíveis.
|
||||
# PUBLIC_DOMAIN=openmonetis.com
|
||||
|
||||
# === Analytics (Opcional) ===
|
||||
# Umami: https://umami.is — self-hosted ou cloud
|
||||
UMAMI_URL=
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_DOMAINS=
|
||||
|
||||
# === AI Providers (Opcional) ===
|
||||
ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
|
||||
4
.gitattributes
vendored
Normal 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
|
||||
51
CHANGELOG.md
@@ -7,6 +7,57 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.3.8] - 2026-04-12
|
||||
|
||||
### Alterado
|
||||
|
||||
- Docker: `docker-compose.yml` refatorado — removidos profiles, build e dependência de arquivo externo; compose agora é standalone (basta `curl` + `docker compose up -d`)
|
||||
- Docker: `docker-entrypoint.sh` simplificado — extensão `pgcrypto` criada via Node.js antes das migrations; loop de retry reescrito; removido hack `@localhost → @db`
|
||||
- Docker: scripts reduzidos de 10 para 5 — `docker:up`, `docker:db`, `docker:down`, `docker:logs`, `docker:update`
|
||||
- Docs: README reestruturado em dois perfis claros — **Usar** (só Docker) e **Desenvolver** (hot-reload)
|
||||
|
||||
## [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
|
||||
|
||||
38
CLAUDE.md
@@ -16,9 +16,10 @@
|
||||
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`.
|
||||
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.
|
||||
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/
|
||||
│ │ ├── calendar/
|
||||
│ │ ├── inbox/
|
||||
│ │ ├── attachments/
|
||||
│ │ ├── changelog/
|
||||
│ │ ├── reports/
|
||||
│ │ │ ├── category-trends/
|
||||
@@ -110,6 +112,7 @@ src/
|
||||
│ ├── insights/
|
||||
│ ├── calendar/
|
||||
│ ├── inbox/
|
||||
│ ├── attachments/
|
||||
│ ├── reports/
|
||||
│ └── settings/
|
||||
├── 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.
|
||||
2. Listar 3-5 problemas principais.
|
||||
3. Fechar com decisao pratica:
|
||||
- aprova agora
|
||||
- nao aprova agora
|
||||
- o que ajustar antes de comecar codigo
|
||||
### Secrets
|
||||
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.
|
||||
|
||||
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."
|
||||
- "Primeiro ajustaria o doc com estes 5 pontos."
|
||||
### Input & Output
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +87,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
|
||||
|
||||
# Copiar entrypoint de migrations
|
||||
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
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
242
README.md
@@ -8,7 +8,7 @@
|
||||
|
||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
@@ -23,15 +23,21 @@
|
||||
<img src="./public/images/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/images/preview-lancamentos-light.webp" alt="Lançamentos" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📖 Índice
|
||||
|
||||
- [Sobre o Projeto](#-sobre-o-projeto)
|
||||
- [Instalação via Script](#-instalação-via-script)
|
||||
- [Início Rápido (manual)](#-início-rápido)
|
||||
- [Como rodar o OpenMonetis](#-como-rodar-o-openmonetis)
|
||||
- [Perfil 1 — Usar](#perfil-1--usar-self-hosting)
|
||||
- [Perfil 2 — Desenvolver](#perfil-2--desenvolver)
|
||||
- [Scripts Disponíveis](#-scripts-disponíveis)
|
||||
- [Docker](#-docker)
|
||||
- [Backup](#-backup)
|
||||
- [Storage S3 Compatível](#-storage-s3-compatível)
|
||||
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
|
||||
- [Arquitetura](#-arquitetura)
|
||||
@@ -53,7 +59,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
||||
|
||||
**1. Não há versão hospedada online** — Este projeto é self-hosted. Você precisa rodar no seu próprio computador ou servidor.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -77,7 +83,11 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
||||
|
||||
📅 **Calendário financeiro** — Visualize todos os lançamentos em um calendário mensal.
|
||||
|
||||
📲 **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.
|
||||
|
||||
@@ -93,82 +103,87 @@ 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.
|
||||
|
||||
**Pré-requisito:** Node.js 22+
|
||||
|
||||
```bash
|
||||
# Mac / Linux / WSL
|
||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs -o setup.mjs && node setup.mjs
|
||||
|
||||
# Windows (PowerShell)
|
||||
curl -o setup.mjs https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs ; node setup.mjs
|
||||
```
|
||||
|
||||
O script irá:
|
||||
- Verificar Node, pnpm, Git e Docker
|
||||
- Perguntar se quer banco local (Docker) ou remoto (Supabase, Neon, etc.)
|
||||
- Gerar o `BETTER_AUTH_SECRET` automaticamente
|
||||
- Configurar opcionais: Google OAuth, e-mail, IA, domínio público
|
||||
- Clonar o repositório, instalar dependências e aplicar o schema
|
||||
Escolha o perfil que corresponde ao seu objetivo:
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Início Rápido (manual)
|
||||
### Perfil 1 — Usar (self-hosting)
|
||||
|
||||
### Pré-requisitos
|
||||
Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar Node.js** — apenas Docker.
|
||||
|
||||
- Node.js 22+ e pnpm
|
||||
- Docker e Docker Compose
|
||||
```bash
|
||||
# 1. Baixe o compose
|
||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||
|
||||
### Passo a Passo
|
||||
# 2. Suba tudo
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
1. **Clone e instale**
|
||||
Acesse em: `http://localhost:3000`
|
||||
|
||||
```bash
|
||||
git clone https://github.com/felipegcoutinho/openmonetis.git
|
||||
cd openmonetis
|
||||
pnpm install
|
||||
```
|
||||
O banco sobe com credenciais padrão. Para personalizar (senha, Google OAuth, e-mail, IA...), crie um `.env` na mesma pasta **antes** de subir:
|
||||
|
||||
2. **Configure o `.env`**
|
||||
```bash
|
||||
# .env mínimo recomendado para produção
|
||||
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
|
||||
BETTER_AUTH_URL=https://seu-dominio.com
|
||||
```
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Veja todas as variáveis disponíveis em [Variáveis de Ambiente](#-variáveis-de-ambiente).
|
||||
|
||||
Edite o `.env` com suas credenciais. O principal é o `DATABASE_URL` e o `BETTER_AUTH_SECRET`:
|
||||
**Banco remoto (Supabase, Neon, Railway...):** defina `DATABASE_URL` no `.env` e suba só o app:
|
||||
|
||||
```env
|
||||
# Banco local (Docker): use host "localhost"
|
||||
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
||||
```bash
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
# Banco remoto (Supabase, Neon, etc): use a URL completa do provider
|
||||
# DATABASE_URL=postgresql://user:password@host:5432/database?sslmode=require
|
||||
**Não tem Docker instalado?** Em servidores Ubuntu 24.04 limpos, use o script de instalação:
|
||||
|
||||
BETTER_AUTH_SECRET=seu-secret-aqui # gere com: openssl rand -base64 32
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
```
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
|
||||
sudo sh install-deps.sh
|
||||
```
|
||||
|
||||
3. **Suba o banco de dados** (pule se estiver usando banco remoto)
|
||||
> Ao final, faça **logout e login** para as permissões do grupo `docker` terem efeito.
|
||||
|
||||
```bash
|
||||
docker compose up db -d
|
||||
pnpm db:extensions
|
||||
```
|
||||
---
|
||||
|
||||
4. **Execute as migrations e inicie**
|
||||
### Perfil 2 — Desenvolver
|
||||
|
||||
```bash
|
||||
pnpm db:push
|
||||
pnpm dev
|
||||
```
|
||||
Quer modificar o código com hot-reload. O banco roda no Docker, o app roda direto no seu servidor.
|
||||
|
||||
5. Acesse `http://localhost:3000`
|
||||
**Requisitos:** Docker + Node.js 22+ + pnpm
|
||||
|
||||
> **Docker completo** (app + banco em containers): use `pnpm docker:up` ao invés dos passos 3-4.
|
||||
```bash
|
||||
# 1. Clone o repositório
|
||||
git clone https://github.com/felipegcoutinho/openmonetis.git
|
||||
cd openmonetis
|
||||
|
||||
# 2. Instale as dependências
|
||||
pnpm install
|
||||
|
||||
# 3. Configure o ambiente
|
||||
cp .env.example .env
|
||||
# Edite o .env com suas configurações
|
||||
|
||||
# 4. Suba o banco
|
||||
pnpm docker:db
|
||||
|
||||
# 5. Habilite extensões do PostgreSQL (apenas no primeiro setup)
|
||||
pnpm db:extensions
|
||||
|
||||
# 6. Aplique o schema no banco (apenas no primeiro setup)
|
||||
pnpm db:push
|
||||
|
||||
# 7. Inicie o app com hot-reload
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Acesse em: `http://localhost:3000`
|
||||
|
||||
Toda vez que salvar um arquivo, o app atualiza automaticamente sem precisar reiniciar.
|
||||
|
||||
---
|
||||
|
||||
@@ -190,47 +205,62 @@ pnpm lint:fix # Biome auto-fix
|
||||
pnpm db:generate # Gerar migrations
|
||||
pnpm db:migrate # Executar migrations
|
||||
pnpm db:push # Push schema direto (dev)
|
||||
pnpm db:extensions # Habilitar extensões PostgreSQL (rodar uma vez)
|
||||
pnpm db:studio # Drizzle Studio (UI visual)
|
||||
```
|
||||
|
||||
### Utilitários
|
||||
|
||||
```bash
|
||||
pnpm backup # Backup do banco (requer scripts/backup.sh configurado)
|
||||
pnpm backup # Backup completo do banco (ver seção Backup)
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
pnpm docker:up # Subir app + banco
|
||||
pnpm docker:up:d # Subir em background
|
||||
pnpm docker:up:db # Subir apenas o banco
|
||||
pnpm docker:down # Parar containers
|
||||
pnpm docker:down:volumes # Parar e remover volumes (⚠️ apaga dados!)
|
||||
pnpm docker:logs # Logs em tempo real
|
||||
pnpm docker:restart # Reiniciar
|
||||
pnpm docker:rebuild # Rebuild completo
|
||||
pnpm docker:up # Sobe app (Docker Hub) + banco em background
|
||||
pnpm docker:db # Sobe apenas o banco em background (usar com pnpm dev)
|
||||
pnpm docker:down # Para e remove os containers
|
||||
pnpm docker:logs # Logs em tempo real (todos os containers)
|
||||
pnpm docker:update # Atualiza para a imagem mais recente do Hub e reinicia
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root.
|
||||
O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root. 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`).
|
||||
### Self-hosting (recomendado)
|
||||
|
||||
Baixe apenas o `docker-compose.yml` e suba tudo — sem clonar o repositório, sem instalar dependências:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
As credenciais padrão do banco já estão configuradas. Para personalizar (senhas, opcionais), crie um `.env` na mesma pasta antes de subir — veja [Variáveis de Ambiente](#-variáveis-de-ambiente).
|
||||
|
||||
### Banco remoto (Supabase, Neon, Railway...)
|
||||
|
||||
Suba apenas o app e aponte para o banco externo via `DATABASE_URL` no `.env`:
|
||||
|
||||
```bash
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
### Comandos úteis
|
||||
|
||||
```bash
|
||||
docker compose exec app sh # Shell da aplicação
|
||||
docker compose exec app sh # Shell da aplicação
|
||||
docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco
|
||||
docker compose ps # Status
|
||||
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup
|
||||
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup
|
||||
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
|
||||
```
|
||||
|
||||
### Customizando Portas
|
||||
### Customizando portas
|
||||
|
||||
```env
|
||||
APP_PORT=3001 # Padrão: 3000
|
||||
@@ -239,6 +269,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
|
||||
|
||||
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 +348,7 @@ S3_BUCKET=
|
||||
### Compatibilidade
|
||||
|
||||
- O código atual espera um provider com API compatível com S3 e suporte a `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` e URLs pré-assinadas.
|
||||
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](/home/ubuntu/github/openmonetis/src/shared/lib/storage/s3-client.ts).
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -1,138 +1,51 @@
|
||||
# Docker Compose para Next.js + PostgreSQL
|
||||
name: openmonetis
|
||||
|
||||
# MODOS DE USO:
|
||||
# 1. Banco LOCAL (PostgreSQL em container):
|
||||
# - Configure DATABASE_URL com host "db" no .env
|
||||
# - Execute: docker compose --profile local up
|
||||
#
|
||||
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
|
||||
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
||||
# - Execute: docker compose up
|
||||
#
|
||||
# 3. Build local (desenvolvimento):
|
||||
# - Execute: docker compose --profile local up --build
|
||||
#
|
||||
# 4. Para parar todos os serviços:
|
||||
# - Execute: docker compose down
|
||||
#
|
||||
# 5. Para remover volumes (CUIDADO: apaga dados do banco local):
|
||||
# - Execute: docker compose down -v
|
||||
|
||||
services:
|
||||
# ============================================
|
||||
# Serviço: PostgreSQL (Banco de dados local)
|
||||
# Ativado apenas com: --profile local
|
||||
# ============================================
|
||||
db:
|
||||
profiles: ["local"]
|
||||
image: postgres:18-alpine
|
||||
container_name: openmonetis_postgres
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
# Cria extensão pgcrypto inline (necessária para gen_random_bytes no schema)
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
echo 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' > /docker-entrypoint-initdb.d/init.sql
|
||||
exec docker-entrypoint.sh postgres
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}",
|
||||
]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# Para ativar logs de queries (debug), adicione ao command acima:
|
||||
# exec docker-entrypoint.sh postgres -c log_statement=all
|
||||
|
||||
# ============================================
|
||||
# Serviço: Aplicação Next.js
|
||||
# ============================================
|
||||
app:
|
||||
build: .
|
||||
image: felipegcoutinho/openmonetis:latest
|
||||
|
||||
container_name: openmonetis_app
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
|
||||
# Banco local: use host "db" | Banco remoto: URL completa do provider
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
|
||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||
|
||||
# S3 (opcional)
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||
S3_REGION: ${S3_REGION:-}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||
S3_BUCKET: ${S3_BUCKET:-}
|
||||
|
||||
# Email (opcional)
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
||||
|
||||
# OAuth (opcional)
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
|
||||
# AI providers (opcional)
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||
|
||||
# required: false permite subir sem banco local (banco remoto via DATABASE_URL)
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--quiet",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:3000/api/health",
|
||||
]
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Volumes
|
||||
# ============================================
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Habilitando extensão pgcrypto..."
|
||||
node -e "
|
||||
const { Client } = require('/app/migrate/node_modules/pg');
|
||||
const c = new Client({ connectionString: process.env.DATABASE_URL });
|
||||
c.connect()
|
||||
.then(() => c.query('CREATE EXTENSION IF NOT EXISTS pgcrypto'))
|
||||
.then(() => c.end())
|
||||
.catch((e) => { console.error('Aviso pgcrypto:', e.message); process.exit(0); });
|
||||
"
|
||||
|
||||
echo "Rodando migrations..."
|
||||
RETRIES=5
|
||||
until /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
|
||||
RETRIES=$((RETRIES - 1))
|
||||
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
|
||||
MIGRATED=0
|
||||
for i in 1 2 3 4 5; do
|
||||
if NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push; then
|
||||
MIGRATED=1
|
||||
break
|
||||
fi
|
||||
echo "Tentativa $i/5 falhou. Aguardando 5s..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$RETRIES" -eq 0 ]; then
|
||||
echo "Aviso: migrations nao foram aplicadas"
|
||||
fi
|
||||
[ "$MIGRATED" -eq 0 ] && echo "Aviso: migrations não foram aplicadas."
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -10,7 +10,11 @@ const nextConfig: NextConfig = {
|
||||
reactCompiler: true,
|
||||
|
||||
images: {
|
||||
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
|
||||
remotePatterns: [
|
||||
new URL("https://lh3.googleusercontent.com/**"),
|
||||
{ protocol: "https", hostname: "**" },
|
||||
{ protocol: "http", hostname: "**" },
|
||||
],
|
||||
},
|
||||
devIndicators: {
|
||||
position: "bottom-right",
|
||||
|
||||
65
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.3.5",
|
||||
"version": "2.3.8",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
@@ -11,36 +11,44 @@
|
||||
"lint": "biome check .",
|
||||
"lint:deadcode": "knip --reporter compact",
|
||||
"lint:fix": "biome check --write .",
|
||||
"env:setup": "bash scripts/setup-env.sh",
|
||||
"env:setup": "node setup.mjs",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
||||
"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",
|
||||
"docker:up:db": "docker compose up -d db",
|
||||
"docker:up:d": "docker compose up --build -d",
|
||||
|
||||
"// --- Docker ---": "---",
|
||||
|
||||
"docker:up": "docker compose up -d",
|
||||
"//docker:up": "Sobe app (Docker Hub) + banco PostgreSQL em background",
|
||||
|
||||
"docker:db": "docker compose up -d db",
|
||||
"//docker:db": "Sobe apenas o banco em background (para usar com pnpm dev)",
|
||||
|
||||
"docker:down": "docker compose down",
|
||||
"docker:down:volumes": "docker compose down -v",
|
||||
"//docker:down": "Para e remove os containers",
|
||||
|
||||
"docker:logs": "docker compose logs -f",
|
||||
"docker:logs:app": "docker compose logs -f app",
|
||||
"docker:logs:db": "docker compose logs -f db",
|
||||
"docker:restart": "docker compose restart",
|
||||
"docker:rebuild": "docker compose up --build --force-recreate",
|
||||
"//docker:logs": "Acompanha logs de todos os containers em tempo real",
|
||||
|
||||
"docker:update": "docker compose pull && docker compose up -d",
|
||||
"//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia",
|
||||
|
||||
"backup": "bash scripts/backup.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.65",
|
||||
"@ai-sdk/google": "^3.0.55",
|
||||
"@ai-sdk/openai": "^3.0.49",
|
||||
"@aws-sdk/client-s3": "^3.1022.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1022.0",
|
||||
"@better-auth/passkey": "^1.5.6",
|
||||
"@ai-sdk/anthropic": "^3.0.68",
|
||||
"@ai-sdk/google": "^3.0.61",
|
||||
"@ai-sdk/openai": "^3.0.52",
|
||||
"@aws-sdk/client-s3": "^3.1027.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1027.0",
|
||||
"@better-auth/passkey": "^1.6.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@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-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
@@ -61,11 +69,11 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@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-virtual": "^3.13.23",
|
||||
"ai": "^6.0.143",
|
||||
"better-auth": "1.5.6",
|
||||
"ai": "^6.0.154",
|
||||
"better-auth": "1.6.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -75,14 +83,14 @@
|
||||
"exceljs": "^4.4.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"next": "16.2.2",
|
||||
"next": "16.2.3",
|
||||
"next-themes": "0.4.6",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"pg": "8.20.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react": "19.2.5",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dom": "19.2.5",
|
||||
"recharts": "3.8.1",
|
||||
"resend": "^6.10.0",
|
||||
"sonner": "2.0.7",
|
||||
@@ -90,17 +98,22 @@
|
||||
"vaul": "1.1.2",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"defu": "6.1.7"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.10",
|
||||
"@tailwindcss/postcss": "4.2.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "25.5.0",
|
||||
"@types/node": "25.5.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"dotenv": "^17.4.0",
|
||||
"dotenv": "^17.4.1",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"knip": "^6.3.0",
|
||||
"knip": "^6.3.1",
|
||||
"tailwindcss": "4.2.2",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "6.0.2"
|
||||
|
||||
2802
pnpm-lock.yaml
generated
@@ -1,18 +1,9 @@
|
||||
import localFont from "next/font/local";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
export const america = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "./america-regular.woff2",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./america-medium.woff2",
|
||||
weight: "500",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
display: "fallback",
|
||||
variable: "--font-america",
|
||||
export const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-inter",
|
||||
fallback: ["ui-sans-serif", "system-ui"],
|
||||
preload: true,
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 109 KiB |
@@ -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
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiBankLine />}
|
||||
title="Contas"
|
||||
|
||||
26
src/app/(dashboard)/attachments/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiBarChart2Line />}
|
||||
title="Orçamentos"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiCalendarEventLine />}
|
||||
title="Calendário"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiBankCard2Line />}
|
||||
title="Cartões"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiPriceTag3Line />}
|
||||
title="Categorias"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiHistoryLine />}
|
||||
title="Changelog"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiAtLine />}
|
||||
title="Pré-Lançamentos"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiSparklingLine />}
|
||||
title="Insights"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiTodoLine />}
|
||||
title="Anotações"
|
||||
|
||||
@@ -80,6 +80,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
||||
categoryFilter: null,
|
||||
accountCardFilter: null,
|
||||
searchFilter: null,
|
||||
settledFilter: null,
|
||||
attachmentFilter: null,
|
||||
};
|
||||
|
||||
const createEmptySlugMaps = (): SlugMaps => ({
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiGroupLine />}
|
||||
title="Pagadores"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiBankCard2Line />}
|
||||
title="Uso de Cartões"
|
||||
|
||||
@@ -71,7 +71,7 @@ export default async function RelatorioCartoesPage({
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-muted mb-4">
|
||||
<RiBankCard2Line className="size-7 text-muted-foreground" />
|
||||
</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">
|
||||
Selecione um cartão para ver os detalhes de uso.
|
||||
</p>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiFileChartLine />}
|
||||
title="Tendências"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiStore2Line />}
|
||||
title="Top Estabelecimentos"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiSecurePaymentLine />}
|
||||
title="Análise de Parcelas"
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiSettings2Line />}
|
||||
title="Ajustes"
|
||||
|
||||
@@ -67,7 +67,7 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
Personalize sua experiência no OpenMonetis ajustando as
|
||||
configurações de acordo com suas necessidades.
|
||||
@@ -92,7 +92,9 @@ export default async function Page() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<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">
|
||||
<RiAndroidLine className="h-3 w-3" />
|
||||
Android
|
||||
@@ -114,7 +116,7 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
|
||||
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">
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
Defina uma nova senha para sua conta. Guarde-a em local
|
||||
seguro.
|
||||
@@ -146,7 +148,7 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
Passkeys permitem login sem senha, usando biometria (Face ID,
|
||||
Touch ID, Windows Hello) ou chaves de segurança.
|
||||
@@ -162,7 +164,7 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
Atualize o e-mail associado à sua conta. Você precisará
|
||||
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">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-medium mb-1 text-destructive">
|
||||
Ações perigosas
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold mb-1">Ações perigosas</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Você pode zerar os dados do OpenMonetis e manter seu acesso,
|
||||
ou excluir sua conta inteira de forma irreversível.
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 pt-4">
|
||||
<section className="space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiArrowLeftRightLine />}
|
||||
title="Lançamentos"
|
||||
|
||||
@@ -120,13 +120,13 @@ export default async function Page() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<RiGithubFill className="size-4 mr-1" />
|
||||
Projeto Open Source
|
||||
</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,
|
||||
<span className="text-primary"> do seu jeito</span>
|
||||
</h1>
|
||||
@@ -207,7 +207,7 @@ export default async function Page() {
|
||||
className="flex flex-col items-center text-center gap-1.5"
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
<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">
|
||||
Conheça as telas
|
||||
</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
|
||||
</h2>
|
||||
<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">
|
||||
O que tem aqui
|
||||
</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
|
||||
</h2>
|
||||
<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>
|
||||
<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}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
@@ -298,7 +298,7 @@ export default async function Page() {
|
||||
|
||||
<AnimateOnScroll>
|
||||
<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
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -319,7 +319,7 @@ export default async function Page() {
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
</h4>
|
||||
<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" />
|
||||
Mobile
|
||||
</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
|
||||
</h2>
|
||||
<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">
|
||||
Stack técnica
|
||||
</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
|
||||
</h2>
|
||||
<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>
|
||||
<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}
|
||||
</h3>
|
||||
<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">
|
||||
Como usar
|
||||
</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
|
||||
</h2>
|
||||
<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">
|
||||
Para quem é?
|
||||
</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
|
||||
</h2>
|
||||
<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>
|
||||
<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">
|
||||
{item.description}
|
||||
</p>
|
||||
@@ -664,7 +664,7 @@ export default async function Page() {
|
||||
<div className="max-w-8xl mx-auto px-4">
|
||||
<AnimateOnScroll>
|
||||
<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?
|
||||
</h2>
|
||||
<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>
|
||||
<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">
|
||||
<li>
|
||||
<Link
|
||||
@@ -749,7 +749,7 @@ export default async function Page() {
|
||||
</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">
|
||||
<li>
|
||||
<Link
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--default-font-family: var(--font-america);
|
||||
--default-font-family: var(--font-inter);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { QueryProvider } from "@/shared/components/providers/query-provider";
|
||||
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
||||
import { Toaster } from "@/shared/components/ui/sonner";
|
||||
import "./globals.css";
|
||||
import { america } from "@/public/fonts/font_index";
|
||||
import { inter } from "@/public/fonts/font_index";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -24,19 +24,23 @@ export default function RootLayout({
|
||||
<html
|
||||
data-scroll-behavior="smooth"
|
||||
lang="pt-BR"
|
||||
className={`${america.variable} ${america.className} `}
|
||||
className={`${inter.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
|
||||
<script
|
||||
defer
|
||||
src="https://umami.felipecoutinho.com/script.js"
|
||||
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
|
||||
data-domains="openmonetis.com"
|
||||
/>
|
||||
{process.env.UMAMI_URL && process.env.UMAMI_WEBSITE_ID && (
|
||||
<script
|
||||
defer
|
||||
src={`${process.env.UMAMI_URL}/script.js`}
|
||||
data-website-id={process.env.UMAMI_WEBSITE_ID}
|
||||
{...(process.env.UMAMI_DOMAINS
|
||||
? { "data-domains": process.env.UMAMI_DOMAINS }
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className="antialiased" suppressHydrationWarning>
|
||||
<body className="subpixel-antialiased" suppressHydrationWarning>
|
||||
<ThemeProvider attribute="class" defaultTheme="light">
|
||||
<QueryProvider>
|
||||
<Suspense>{children}</Suspense>
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function createAccountAction(
|
||||
|
||||
if (hasInitialBalance && !adminPayerId) {
|
||||
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) {
|
||||
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.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,9 @@ export function AccountCard({
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h2 className="text-lg font-medium text-foreground">{accountName}</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{accountName}
|
||||
</h2>
|
||||
|
||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||
<Tooltip>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function AccountStatementCard({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-medium text-foreground">
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">
|
||||
{accountName}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -81,12 +81,12 @@ export function AccountStatementCard({
|
||||
|
||||
{/* Linha 2 — saldo final (hero) */}
|
||||
<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
|
||||
</p>
|
||||
<MoneyValues
|
||||
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">
|
||||
<Badge
|
||||
|
||||
@@ -69,9 +69,7 @@ function PdfCanvas({ url }: PdfCanvasProps) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
|
||||
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
|
||||
<span className="text-xs font-medium text-muted-foreground/60">
|
||||
PDF Protegido
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60">PDF Protegido</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -153,7 +151,7 @@ export function AttachmentGridItem({
|
||||
|
||||
<Tooltip>
|
||||
<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}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
@@ -180,25 +178,21 @@ export function AttachmentGridItem({
|
||||
{attachment.transactionName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
|
||||
)}
|
||||
>
|
||||
<span className={cn("shrink-0 text-sm font-medium tracking-tighter")}>
|
||||
{formatCurrency(amount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Footer: Tamanho + Botão Detalhes */}
|
||||
<div className="mt-auto flex items-center justify-between border-t pt-3">
|
||||
<span className="text-xs font-medium text-muted-foreground/70">
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{formatBytes(attachment.fileSize)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDetails}
|
||||
disabled={isLoadingDetails}
|
||||
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
|
||||
className="text-xs text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
{isLoadingDetails ? "Carregando..." : "Detalhes"}
|
||||
</button>
|
||||
|
||||
@@ -105,7 +105,7 @@ export function AttachmentPreview({
|
||||
>
|
||||
<RiArrowLeftSLine className="size-4" />
|
||||
</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}
|
||||
</span>
|
||||
<Button
|
||||
|
||||
@@ -19,8 +19,6 @@ import { TransactionDetailsDialog } from "@/features/transactions/components/dia
|
||||
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||
import type { TransactionItem } from "@/features/transactions/components/types";
|
||||
import { EmptyState } from "@/shared/components/empty-state";
|
||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
import PageDescription from "@/shared/components/page-description";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
@@ -143,14 +141,6 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiAttachmentLine className="size-5" />}
|
||||
title="Anexos"
|
||||
subtitle="Comprovantes e documentos dos seus lançamentos no mês."
|
||||
/>
|
||||
|
||||
<MonthNavigation />
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
{attachments.length === 0 ? (
|
||||
|
||||
@@ -8,7 +8,7 @@ interface AuthHeaderProps {
|
||||
export function AuthHeader({ title, description }: AuthHeaderProps) {
|
||||
return (
|
||||
<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}
|
||||
</h1>
|
||||
{description ? (
|
||||
|
||||
@@ -52,7 +52,7 @@ export function BudgetCard({
|
||||
size="lg"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-medium leading-tight">
|
||||
<h3 className="text-base font-semibold leading-tight">
|
||||
{formatCategoryName(budget)}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -19,9 +19,9 @@ export function CalendarGrid({
|
||||
}: CalendarGridProps) {
|
||||
return (
|
||||
<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) => (
|
||||
<span key={dayName} className="px-3 py-2 text-center text-primary">
|
||||
<span key={dayName} className="px-3 py-2 text-center">
|
||||
{dayName}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -130,7 +130,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
Vencimento Invoice - {event.card.name}
|
||||
Vencimento Fatura - {event.card.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ export function CardItem({
|
||||
|
||||
<div className="min-w-0">
|
||||
<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}
|
||||
</h3>
|
||||
{note ? (
|
||||
@@ -206,29 +206,29 @@ export function CardItem({
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<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} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{metrics[0].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<MoneyValues amount={metrics[1].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{metrics[1].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{metrics[2].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -183,7 +183,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
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}
|
||||
<RiExternalLinkLine
|
||||
|
||||
@@ -80,7 +80,7 @@ export function CategoryDetailHeader({
|
||||
size="lg"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-medium leading-tight">
|
||||
<h1 className="text-xl font-semibold leading-tight">
|
||||
{category.name}
|
||||
</h1>
|
||||
<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">
|
||||
Total em {currentPeriodLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-medium">
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
{currencyFormatter.format(currentTotal)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -107,7 +107,7 @@ export function CategoryDetailHeader({
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Total em {previousPeriodLabel}
|
||||
</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)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@ export function CategoryDetailHeader({
|
||||
</p>
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function CategoryPickerDialog({
|
||||
<div className="flex max-h-96 flex-col gap-4 overflow-y-auto pr-1">
|
||||
{filteredGroups.map((group) => (
|
||||
<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}
|
||||
</p>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||
INITIAL_BALANCE_NOTE,
|
||||
} 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 { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { calculatePercentageChange } from "@/shared/utils/math";
|
||||
@@ -45,6 +48,7 @@ export async function fetchCategoryDetails(
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
const isInvoiceCategory = category.name === INVOICE_PAYMENT_CATEGORY_NAME;
|
||||
|
||||
const sanitizedNote = or(
|
||||
isNull(transactions.note),
|
||||
@@ -59,7 +63,7 @@ export async function fetchCategoryDetails(
|
||||
eq(transactions.transactionType, transactionType),
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.payerId, adminPayerId),
|
||||
sanitizedNote,
|
||||
...(isInvoiceCategory ? [] : [sanitizedNote]),
|
||||
),
|
||||
with: {
|
||||
payer: true,
|
||||
@@ -108,7 +112,7 @@ export async function fetchCategoryDetails(
|
||||
eq(transactions.categoryId, categoryId),
|
||||
eq(transactions.transactionType, transactionType),
|
||||
eq(transactions.payerId, adminPayerId),
|
||||
sanitizedNote,
|
||||
...(isInvoiceCategory ? [] : [sanitizedNote]),
|
||||
eq(transactions.period, previousPeriod),
|
||||
or(
|
||||
isNull(transactions.note),
|
||||
|
||||
128
src/features/dashboard/components/attachments-widget.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||
<span
|
||||
className={cn(
|
||||
"cursor-help rounded-full py-0.5",
|
||||
bill.isSettled && "text-success",
|
||||
bill.isSettled && "text-success font-semibold",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
@@ -60,7 +60,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full py-0.5",
|
||||
bill.isSettled && "text-success",
|
||||
bill.isSettled && "text-success font-semibold",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
@@ -72,7 +72,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={bill.amount} />
|
||||
<MoneyValues className="font-medium" amount={bill.amount} />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -97,7 +97,7 @@ export function BillPaymentDialog({
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Boleto
|
||||
</p>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
{bill.name}
|
||||
</p>
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@ export function BillPaymentDialog({
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={bill.amount}
|
||||
className="text-lg font-medium"
|
||||
className="text-lg font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -281,12 +281,12 @@ export function CategoryBreakdownWidgetView({
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<MoneyValues
|
||||
className="text-foreground"
|
||||
className="text-foreground font-medium"
|
||||
amount={category.currentAmount}
|
||||
/>
|
||||
{category.percentageChange !== null ? (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${changeClassName}`}
|
||||
className={`flex items-center gap-0.5 text-xs font-medium ${changeClassName}`}
|
||||
>
|
||||
{hasIncrease ? (
|
||||
<RiArrowUpSFill className="size-3" />
|
||||
|
||||
@@ -197,7 +197,9 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-foreground">{category.name}</span>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{category.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -398,7 +400,7 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
||||
{config?.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium tabular-nums">
|
||||
<span className="text-xs font-medium">
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
84
src/features/dashboard/components/category-trends-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -34,12 +34,12 @@ import {
|
||||
type WidgetPreferences,
|
||||
} from "@/features/dashboard/widgets/actions";
|
||||
import {
|
||||
type DashboardWidgetQuickActionOptions,
|
||||
type WidgetConfig,
|
||||
widgetsConfig,
|
||||
} from "@/features/dashboard/widgets/widgets-config";
|
||||
import { NoteDialog } from "@/features/notes/components/note-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 { Button } from "@/shared/components/ui/button";
|
||||
|
||||
@@ -47,15 +47,7 @@ type DashboardGridEditableProps = {
|
||||
data: DashboardData;
|
||||
period: string;
|
||||
initialPreferences: WidgetPreferences | null;
|
||||
quickActionOptions: {
|
||||
payerOptions: SelectOption[];
|
||||
splitPayerOptions: SelectOption[];
|
||||
defaultPayerId: string | null;
|
||||
accountOptions: SelectOption[];
|
||||
cardOptions: SelectOption[];
|
||||
categoryOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
};
|
||||
quickActionOptions: DashboardWidgetQuickActionOptions;
|
||||
};
|
||||
|
||||
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
|
||||
@@ -368,11 +360,16 @@ export function DashboardGridEditable({
|
||||
{widget.component({
|
||||
data,
|
||||
period,
|
||||
adminPayerSlug:
|
||||
quickActionOptions.payerOptions.find(
|
||||
(p) => p.value === quickActionOptions.defaultPayerId,
|
||||
)?.slug ?? null,
|
||||
widgetPreferences: {
|
||||
order: widgetOrder,
|
||||
hidden: hiddenWidgets,
|
||||
myAccountsShowExcluded,
|
||||
},
|
||||
quickActionOptions,
|
||||
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
|
||||
})}
|
||||
</ExpandableWidgetCard>
|
||||
|
||||
@@ -116,13 +116,14 @@ const getPercentChange = (current: number, previous: number): string => {
|
||||
}
|
||||
|
||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||
return Number.isFinite(change) && Math.abs(change) < 1000000
|
||||
? formatPercentage(change, {
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 1,
|
||||
signDisplay: "always",
|
||||
})
|
||||
: "—";
|
||||
if (!Number.isFinite(change)) return "—";
|
||||
if (change > 999) return "+999%";
|
||||
if (change < -999) return "-999%";
|
||||
return formatPercentage(change, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2,
|
||||
signDisplay: "always",
|
||||
});
|
||||
};
|
||||
|
||||
const getTrendBadgeClass = (trend: Trend, invertTrend: boolean): string => {
|
||||
@@ -159,7 +160,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<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 />
|
||||
{label}
|
||||
<MetricsCardInfoButton
|
||||
@@ -179,12 +180,12 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
|
||||
<MoneyValues
|
||||
className="text-2xl leading-none"
|
||||
className="text-2xl leading-none font-medium"
|
||||
amount={metric.current}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-xs ",
|
||||
"inline-flex items-center gap-1 text-xs font-medium",
|
||||
trendBadgeClass,
|
||||
)}
|
||||
>
|
||||
@@ -195,7 +196,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<MoneyValues
|
||||
className="inline text-xs text-muted-foreground"
|
||||
className="inline text-xs font-medium text-muted-foreground"
|
||||
amount={metric.previous}
|
||||
/>
|
||||
<span className="ml-1">no mês anterior</span>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
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 formattedDate = formatCurrentDate();
|
||||
const greeting = getGreeting();
|
||||
|
||||
return (
|
||||
<section className="py-4">
|
||||
<div className="tracking-tight">
|
||||
<h1 className="text-xl font-medium">
|
||||
<div>
|
||||
<h1 className="text-xl tracking-tight">
|
||||
{greeting}, {displayName}
|
||||
</h1>
|
||||
<h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2>
|
||||
<h2 className="mt-1 text-sm text-muted-foreground">{formattedDate}</h2>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -44,8 +44,9 @@ export function GoalProgressItem({
|
||||
{item.categoryName}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<MoneyValues amount={item.spentAmount} /> de{" "}
|
||||
<MoneyValues amount={item.budgetAmount} />
|
||||
<MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
|
||||
de{" "}
|
||||
<MoneyValues className="font-medium" amount={item.budgetAmount} />
|
||||
<span className={`ml-1.5 font-medium ${deltaColor}`}>
|
||||
{formatGoalProgressPercentage(percentageDelta, true)}
|
||||
</span>
|
||||
|
||||
268
src/features/dashboard/components/inbox-widget.tsx
Normal 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 `há ${minutes}min`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `há ${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `há ${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>
|
||||
);
|
||||
}
|
||||
@@ -132,12 +132,12 @@ export function InstallmentAnalysisPage({
|
||||
{/* Card de resumo principal */}
|
||||
<Card className="border-none bg-primary/15">
|
||||
<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:
|
||||
</p>
|
||||
<MoneyValues
|
||||
amount={grandTotal}
|
||||
className="text-3xl font-medium text-primary"
|
||||
className="text-3xl font-semibold text-primary"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
|
||||
@@ -167,7 +167,7 @@ export function InstallmentAnalysisPage({
|
||||
|
||||
{/* Seção de Lançamentos Parcelados */}
|
||||
{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) => (
|
||||
<InstallmentGroupCard
|
||||
key={group.seriesId}
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiBankCard2Line,
|
||||
RiCheckboxCircleFill,
|
||||
RiEyeLine,
|
||||
RiTimeLine,
|
||||
} from "@remixicon/react";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Progress } from "@/shared/components/ui/progress";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { cn } from "@/shared/utils";
|
||||
import type { InstallmentGroup } from "./types";
|
||||
|
||||
type InstallmentGroupCardProps = {
|
||||
@@ -29,18 +45,22 @@ export function InstallmentGroupCard({
|
||||
onToggleGroup,
|
||||
onToggleInstallment,
|
||||
}: InstallmentGroupCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||
|
||||
const unpaidInstallments = group.pendingInstallments.filter(
|
||||
(i) => !i.isSettled,
|
||||
);
|
||||
|
||||
const paidInstallments = group.pendingInstallments.filter((i) => i.isSettled);
|
||||
const unpaidCount = unpaidInstallments.length;
|
||||
|
||||
const isFullySelected =
|
||||
selectedInstallments.size === unpaidInstallments.length &&
|
||||
unpaidInstallments.length > 0;
|
||||
|
||||
const isPartiallySelected = selectedInstallments.size > 0 && !isFullySelected;
|
||||
|
||||
const hasSelection = selectedInstallments.size > 0;
|
||||
|
||||
const progress =
|
||||
group.totalInstallments > 0
|
||||
? (group.paidInstallments / group.totalInstallments) * 100
|
||||
@@ -50,186 +70,304 @@ export function InstallmentGroupCard({
|
||||
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
|
||||
.reduce((sum, i) => sum + Number(i.amount), 0);
|
||||
|
||||
// Calcular valor total de todas as parcelas (pagas + pendentes)
|
||||
const totalAmount = group.pendingInstallments.reduce(
|
||||
(sum, i) => sum + i.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
// Calcular valor pendente (apenas não pagas)
|
||||
const pendingAmount = unpaidInstallments.reduce(
|
||||
(sum, i) => sum + i.amount,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={cn(isFullySelected && "border-primary/50")}>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{/* Header do card */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isFullySelected}
|
||||
onCheckedChange={onToggleGroup}
|
||||
className="mt-1"
|
||||
aria-label={`Selecionar todas as parcelas de ${group.name}`}
|
||||
/>
|
||||
<>
|
||||
<Card
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-300",
|
||||
isFullySelected && "ring-2 ring-primary/30 border-primary/50",
|
||||
isPartiallySelected && "border-primary/30",
|
||||
)}
|
||||
>
|
||||
{/* 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">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{group.cartaoLogo && (
|
||||
<img
|
||||
{/* Info principal */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{group.cartaoLogo ? (
|
||||
<Image
|
||||
src={`/logos/${group.cartaoLogo}`}
|
||||
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>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
| {group.cartaoName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<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 className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base truncate">
|
||||
{group.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{group.cartaoName ?? "Compra parcelada"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3">
|
||||
<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">
|
||||
{/* Badge de status */}
|
||||
<Badge
|
||||
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>
|
||||
{group.paidInstallments} de {group.totalInstallments} pagas
|
||||
{group.paidInstallments} de {group.totalInstallments} parcelas
|
||||
pagas
|
||||
</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>
|
||||
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{/* Lista de parcelas expandida */}
|
||||
{isExpanded && (
|
||||
<div className="px-2 sm:px-8 mt-2 flex flex-col gap-2">
|
||||
{group.pendingInstallments.map((installment) => {
|
||||
const isSelected = selectedInstallments.has(installment.id);
|
||||
const isPaid = installment.isSettled;
|
||||
const dueDate = installment.dueDate
|
||||
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR })
|
||||
: format(installment.purchaseDate, "dd/MM/yyyy", {
|
||||
locale: ptBR,
|
||||
});
|
||||
{/* Valor selecionado */}
|
||||
{hasSelection && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/5 border border-primary/20 mb-4">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{selectedInstallments.size}{" "}
|
||||
{selectedInstallments.size === 1
|
||||
? "parcela selecionada"
|
||||
: "parcelas selecionadas"}
|
||||
</span>
|
||||
<MoneyValues
|
||||
amount={selectedAmount}
|
||||
className="text-base font-semibold text-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={installment.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md border p-2 transition-colors",
|
||||
isSelected && !isPaid && "border-primary/50 bg-primary/5",
|
||||
isPaid &&
|
||||
"border-success/40 bg-success/5 dark:border-success/20 dark:bg-success/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isPaid ? false : isSelected}
|
||||
disabled={isPaid}
|
||||
onCheckedChange={() =>
|
||||
!isPaid && onToggleInstallment(installment.id)
|
||||
}
|
||||
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
|
||||
/>
|
||||
{/* Botão para abrir detalhes */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-1.5"
|
||||
onClick={() => setIsDetailsOpen(true)}
|
||||
>
|
||||
<RiEyeLine className="size-4" />
|
||||
Ver detalhes ({group.pendingInstallments.length} parcelas)
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isPaid &&
|
||||
"text-success line-through decoration-success/50",
|
||||
)}
|
||||
>
|
||||
Parcela {installment.currentInstallment}/
|
||||
{group.totalInstallments}
|
||||
{isPaid && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
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>
|
||||
{/* Modal de detalhes */}
|
||||
<Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
{group.cartaoLogo ? (
|
||||
<img
|
||||
src={`/logos/${group.cartaoLogo}`}
|
||||
alt={group.cartaoName ?? "Cartão"}
|
||||
className="size-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footer com resumo da seleção */}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,10 @@ export function InstallmentExpenseListItem({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<MoneyValues amount={expense.amount} className="shrink-0" />
|
||||
<MoneyValues
|
||||
amount={expense.amount}
|
||||
className="shrink-0 font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -67,7 +70,7 @@ export function InstallmentExpenseListItem({
|
||||
{" · Restante "}
|
||||
<MoneyValues
|
||||
amount={remainingAmount}
|
||||
className="inline-block font-medium"
|
||||
className="inline-block font-semibold"
|
||||
/>{" "}
|
||||
({remainingInstallments})
|
||||
</p>
|
||||
|
||||
@@ -116,7 +116,10 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
<MoneyValues amount={share.amount} />
|
||||
<MoneyValues
|
||||
className="font-medium"
|
||||
amount={share.amount}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -144,7 +147,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
paymentTooltipLabel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help text-success">
|
||||
<span className="cursor-help text-success font-semibold">
|
||||
{paymentInfo.label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -153,7 +156,9 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-success">{paymentInfo.label}</span>
|
||||
<span className="text-success font-semibold">
|
||||
{paymentInfo.label}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
@@ -161,7 +166,10 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
|
||||
<MoneyValues
|
||||
className="font-medium"
|
||||
amount={Math.abs(invoice.totalAmount)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -113,7 +113,7 @@ export function InvoicePaymentDialog({
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Cartão
|
||||
</p>
|
||||
<p className="truncate text-base font-medium text-foreground">
|
||||
<p className="truncate text-base font-semibold text-foreground">
|
||||
{invoice.cardName}
|
||||
</p>
|
||||
</div>
|
||||
@@ -130,7 +130,7 @@ export function InvoicePaymentDialog({
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={Math.abs(invoice.totalAmount)}
|
||||
className="text-lg font-medium"
|
||||
className="text-lg font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export function MyAccountsWidget({
|
||||
<div className="flex items-start justify-between gap-3 py-1">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Saldo Total</p>
|
||||
<MoneyValues className="text-2xl" amount={totalBalance} />
|
||||
<MoneyValues className="text-2xl font-medium" amount={totalBalance} />
|
||||
</div>
|
||||
|
||||
{excludedAccountsCount > 0 ? (
|
||||
@@ -137,7 +137,7 @@ export function MyAccountsWidget({
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{displayedAccounts.map((account) => {
|
||||
{displayedAccounts.map((account, index) => {
|
||||
const logoSrc = resolveLogoSrc(account.logo);
|
||||
|
||||
return (
|
||||
@@ -154,6 +154,7 @@ export function MyAccountsWidget({
|
||||
fill
|
||||
sizes="38px"
|
||||
className="object-contain rounded-full"
|
||||
priority={index === 0}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -199,7 +200,10 @@ export function MyAccountsWidget({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-0.5 text-right">
|
||||
<MoneyValues amount={account.balance} />
|
||||
<MoneyValues
|
||||
className="font-medium"
|
||||
amount={account.balance}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -83,10 +83,13 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<MoneyValues amount={payer.totalExpenses} />
|
||||
<MoneyValues
|
||||
className="font-medium"
|
||||
amount={payer.totalExpenses}
|
||||
/>
|
||||
{percentageChange !== null && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${
|
||||
className={`flex items-center gap-0.5 text-xs font-medium ${
|
||||
percentageChange > 0
|
||||
? "text-destructive"
|
||||
: percentageChange < 0
|
||||
|
||||
@@ -8,11 +8,15 @@ import { PaymentOverviewWidgetView } from "./payment-overview/payment-overview-w
|
||||
type PaymentOverviewWidgetProps = {
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
paymentMethodsData: PaymentMethodsData;
|
||||
period: string;
|
||||
adminPayerSlug: string | null;
|
||||
};
|
||||
|
||||
export function PaymentOverviewWidget({
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
period,
|
||||
adminPayerSlug,
|
||||
}: PaymentOverviewWidgetProps) {
|
||||
const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
|
||||
|
||||
@@ -22,6 +26,8 @@ export function PaymentOverviewWidget({
|
||||
paymentConditionsData={paymentConditionsData}
|
||||
paymentMethodsData={paymentMethodsData}
|
||||
onTabChange={handleTabChange}
|
||||
period={period}
|
||||
adminPayerSlug={adminPayerSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RiExternalLinkLine } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
formatPaymentBreakdownPercentage,
|
||||
@@ -17,6 +19,7 @@ export type PaymentBreakdownListItemData = {
|
||||
amount: number;
|
||||
transactions: number;
|
||||
percentage: number;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type PaymentBreakdownListItemProps = {
|
||||
@@ -40,8 +43,21 @@ export function PaymentBreakdownListItem({
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
<MoneyValues amount={item.amount} />
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
<RiExternalLinkLine
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
)}
|
||||
<MoneyValues className="font-medium" amount={item.amount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { RiCheckLine, RiSlideshowLine } from "@remixicon/react";
|
||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||
import { getConditionIcon } from "@/shared/utils/icons";
|
||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||
import { slugify } from "@/shared/utils/string";
|
||||
import {
|
||||
PaymentBreakdownList,
|
||||
type PaymentBreakdownListItemData,
|
||||
@@ -8,6 +10,8 @@ import {
|
||||
|
||||
type PaymentConditionsWidgetProps = {
|
||||
data: PaymentConditionsData;
|
||||
period: string;
|
||||
adminPayerSlug: string | null;
|
||||
};
|
||||
|
||||
const resolveConditionIcon = (condition: string) =>
|
||||
@@ -15,16 +19,27 @@ const resolveConditionIcon = (condition: string) =>
|
||||
|
||||
export function PaymentConditionsWidget({
|
||||
data,
|
||||
period,
|
||||
adminPayerSlug,
|
||||
}: PaymentConditionsWidgetProps) {
|
||||
const items: PaymentBreakdownListItemData[] = data.conditions.map(
|
||||
(condition) => ({
|
||||
id: condition.condition,
|
||||
title: condition.condition,
|
||||
icon: resolveConditionIcon(condition.condition),
|
||||
amount: condition.amount,
|
||||
transactions: condition.transactions,
|
||||
percentage: condition.percentage,
|
||||
}),
|
||||
(condition) => {
|
||||
const params = new URLSearchParams({
|
||||
type: slugify("Despesa"),
|
||||
condition: slugify(condition.condition),
|
||||
periodo: formatPeriodForUrl(period),
|
||||
});
|
||||
if (adminPayerSlug) params.set("payer", adminPayerSlug);
|
||||
return {
|
||||
id: condition.condition,
|
||||
title: condition.condition,
|
||||
icon: resolveConditionIcon(condition.condition),
|
||||
amount: condition.amount,
|
||||
transactions: condition.transactions,
|
||||
percentage: condition.percentage,
|
||||
href: `/transactions?${params.toString()}`,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
|
||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||
import { slugify } from "@/shared/utils/string";
|
||||
import {
|
||||
PaymentBreakdownList,
|
||||
type PaymentBreakdownListItemData,
|
||||
@@ -8,6 +10,8 @@ import {
|
||||
|
||||
type PaymentMethodsWidgetProps = {
|
||||
data: PaymentMethodsData;
|
||||
period: string;
|
||||
adminPayerSlug: string | null;
|
||||
};
|
||||
|
||||
const resolvePaymentMethodIcon = (paymentMethod: string) =>
|
||||
@@ -15,15 +19,28 @@ const resolvePaymentMethodIcon = (paymentMethod: string) =>
|
||||
<RiBankCard2Line className="size-5" aria-hidden />
|
||||
);
|
||||
|
||||
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
|
||||
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => ({
|
||||
id: method.paymentMethod,
|
||||
title: method.paymentMethod,
|
||||
icon: resolvePaymentMethodIcon(method.paymentMethod),
|
||||
amount: method.amount,
|
||||
transactions: method.transactions,
|
||||
percentage: method.percentage,
|
||||
}));
|
||||
export function PaymentMethodsWidget({
|
||||
data,
|
||||
period,
|
||||
adminPayerSlug,
|
||||
}: PaymentMethodsWidgetProps) {
|
||||
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => {
|
||||
const params = new URLSearchParams({
|
||||
type: slugify("Despesa"),
|
||||
payment: slugify(method.paymentMethod),
|
||||
periodo: formatPeriodForUrl(period),
|
||||
});
|
||||
if (adminPayerSlug) params.set("payer", adminPayerSlug);
|
||||
return {
|
||||
id: method.paymentMethod,
|
||||
title: method.paymentMethod,
|
||||
icon: resolvePaymentMethodIcon(method.paymentMethod),
|
||||
amount: method.amount,
|
||||
transactions: method.transactions,
|
||||
percentage: method.percentage,
|
||||
href: `/transactions?${params.toString()}`,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<PaymentBreakdownList
|
||||
|
||||
@@ -16,6 +16,8 @@ type PaymentOverviewWidgetViewProps = {
|
||||
paymentConditionsData: PaymentConditionsData;
|
||||
paymentMethodsData: PaymentMethodsData;
|
||||
onTabChange: (value: string) => void;
|
||||
period: string;
|
||||
adminPayerSlug: string | null;
|
||||
};
|
||||
|
||||
export function PaymentOverviewWidgetView({
|
||||
@@ -23,6 +25,8 @@ export function PaymentOverviewWidgetView({
|
||||
paymentConditionsData,
|
||||
paymentMethodsData,
|
||||
onTabChange,
|
||||
period,
|
||||
adminPayerSlug,
|
||||
}: PaymentOverviewWidgetViewProps) {
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||
@@ -38,11 +42,19 @@ export function PaymentOverviewWidgetView({
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="conditions" className="mt-2">
|
||||
<PaymentConditionsWidget data={paymentConditionsData} />
|
||||
<PaymentConditionsWidget
|
||||
data={paymentConditionsData}
|
||||
period={period}
|
||||
adminPayerSlug={adminPayerSlug}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="methods" className="mt-2">
|
||||
<PaymentMethodsWidget data={paymentMethodsData} />
|
||||
<PaymentMethodsWidget
|
||||
data={paymentMethodsData}
|
||||
period={period}
|
||||
adminPayerSlug={adminPayerSlug}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -24,10 +24,7 @@ export function PaymentStatusCategorySection({
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
<MoneyValues
|
||||
amount={total}
|
||||
className="text-sm font-medium tabular-nums"
|
||||
/>
|
||||
<MoneyValues amount={total} className="font-medium" />
|
||||
</div>
|
||||
|
||||
<Progress value={confirmedPercentage} className="h-2" />
|
||||
@@ -35,13 +32,13 @@ export function PaymentStatusCategorySection({
|
||||
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusDot color="bg-primary" />
|
||||
<MoneyValues amount={confirmed} className="tabular-nums" />
|
||||
<MoneyValues amount={confirmed} className="font-medium" />
|
||||
<span className="text-xs text-muted-foreground">confirmados</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusDot color="bg-warning/40" />
|
||||
<MoneyValues amount={pending} className="tabular-nums" />
|
||||
<MoneyValues amount={pending} className="font-medium" />
|
||||
<span className="text-xs text-muted-foreground">pendentes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,7 +178,10 @@ export function PurchasesByCategoryWidget({
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-foreground">
|
||||
<MoneyValues amount={transaction.amount} />
|
||||
<MoneyValues
|
||||
className="font-medium"
|
||||
amount={transaction.amount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ export function RecurringExpensesWidget({
|
||||
{expense.name}
|
||||
</p>
|
||||
|
||||
<MoneyValues amount={expense.amount} />
|
||||
<MoneyValues className="font-medium" amount={expense.amount} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
|
||||
@@ -48,7 +48,10 @@ export function TopEstablishmentsWidget({
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-foreground">
|
||||
<MoneyValues amount={establishment.amount} />
|
||||
<MoneyValues
|
||||
className="font-medium"
|
||||
amount={establishment.amount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -113,7 +113,10 @@ export function TopExpensesWidget({
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-foreground">
|
||||
<MoneyValues amount={expense.amount} />
|
||||
<MoneyValues
|
||||
className="font-medium"
|
||||
amount={expense.amount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { cacheLife, cacheTag } from "next/cache";
|
||||
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
|
||||
import { fetchDashboardAccounts } from "./accounts-queries";
|
||||
import { fetchDashboardCategoryOverview } from "./category-overview-queries";
|
||||
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
|
||||
import { fetchDashboardInboxSnapshot } from "./inbox-snapshot-queries";
|
||||
import { fetchDashboardInvoices } from "./invoices-queries";
|
||||
import { fetchDashboardNotes } from "./notes-queries";
|
||||
import { fetchDashboardPayers } from "./payers-queries";
|
||||
@@ -16,6 +18,8 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||
categoryOverview,
|
||||
pagadoresSnapshot,
|
||||
notesData,
|
||||
allAttachments,
|
||||
inboxSnapshot,
|
||||
] = await Promise.all([
|
||||
fetchDashboardPeriodOverview(userId, period),
|
||||
fetchDashboardAccounts(userId),
|
||||
@@ -24,8 +28,27 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||
fetchDashboardCategoryOverview(userId, period),
|
||||
fetchDashboardPayers(userId, period),
|
||||
fetchDashboardNotes(userId),
|
||||
fetchAttachmentsForPeriod(userId, period),
|
||||
fetchDashboardInboxSnapshot(userId),
|
||||
]);
|
||||
|
||||
const attachmentsSnapshot = allAttachments.reduce(
|
||||
(acc, attachment, index) => {
|
||||
acc.totalBytes += attachment.fileSize;
|
||||
if (attachment.mimeType.startsWith("image/")) acc.imageCount++;
|
||||
if (attachment.mimeType === "application/pdf") acc.pdfCount++;
|
||||
if (index < 5) acc.recentAttachments.push(attachment);
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
totalCount: allAttachments.length,
|
||||
totalBytes: 0,
|
||||
imageCount: 0,
|
||||
pdfCount: 0,
|
||||
recentAttachments: [] as typeof allAttachments,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
metrics: periodOverview.metrics,
|
||||
accountsSnapshot,
|
||||
@@ -46,6 +69,8 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
||||
purchasesByCategoryData: currentPeriodOverview.purchasesByCategoryData,
|
||||
incomeByCategoryData: categoryOverview.incomeByCategoryData,
|
||||
expensesByCategoryData: categoryOverview.expensesByCategoryData,
|
||||
attachmentsSnapshot,
|
||||
inboxSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||