Compare commits
19 Commits
98fe6a0f4f
...
v2.3.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6adfa3f1 | ||
|
|
e4b9dd4254 | ||
|
|
f1907c8697 | ||
|
|
805bcb863d | ||
|
|
11b4f8940f | ||
|
|
fba9686fdb | ||
|
|
9b8ac9f71f | ||
|
|
fa41c78a39 | ||
|
|
5f7bfb98da | ||
|
|
9ecafdb15f | ||
|
|
e8cc673e52 | ||
|
|
3bd8117b65 | ||
|
|
a7268d8f05 | ||
|
|
1f9098879e | ||
|
|
7a3bff52ac | ||
|
|
dfb4126b12 | ||
|
|
ffead579fa | ||
|
|
aa85cf8b29 | ||
|
|
9a7ae0fa3d |
@@ -44,6 +44,13 @@ GOOGLE_CLIENT_SECRET=
|
|||||||
# Se não definido, todas as rotas ficam acessíveis.
|
# Se não definido, todas as rotas ficam acessíveis.
|
||||||
# PUBLIC_DOMAIN=openmonetis.com
|
# PUBLIC_DOMAIN=openmonetis.com
|
||||||
|
|
||||||
|
# === Analytics (Opcional) ===
|
||||||
|
# Umami: https://umami.is — self-hosted ou cloud
|
||||||
|
UMAMI_URL=
|
||||||
|
UMAMI_WEBSITE_ID=
|
||||||
|
# Domínios rastreados (ex: openmonetis.com) — corresponde ao data-domains do script
|
||||||
|
UMAMI_DOMAINS=
|
||||||
|
|
||||||
# === AI Providers (Opcional) ===
|
# === AI Providers (Opcional) ===
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|||||||
4
.gitattributes
vendored
Normal file
@@ -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
|
||||||
32
CHANGELOG.md
@@ -7,6 +7,38 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.3.7] - 2026-04-11
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Dashboard: novos widgets configuráveis — Anexos (resumo de arquivos do período), Inbox (snapshot de pré-lançamentos pendentes) e Tendências de Categoria
|
||||||
|
- Lançamentos: filtro por status de pagamento (somente pagos / somente não pagos) e filtro por presença de anexo
|
||||||
|
- Lançamentos: indicador visual no status de liquidação para lançamentos de cartão de crédito com fatura paga — exibe ícone verde com tooltip explicativo
|
||||||
|
- Scripts: `scripts/install-deps.sh` — script de preparação para servidores Ubuntu 24.04 limpos (instala Docker, Node.js 22, pnpm via Homebrew)
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Fonte: substituída fonte local `America` por `Inter` (Google Fonts, self-hosted pelo Next.js) — elimina arquivos `.woff2` do repositório
|
||||||
|
- Tipografia: peso tipográfico padronizado de `font-medium` para `font-semibold` em títulos, rótulos e valores monetários em toda a interface
|
||||||
|
- Parcelas: redesenho do card de grupo de parcelas — expandindo para dialog de detalhes com parcelas pagas/pendentes separadas
|
||||||
|
- Inbox: redesenho do card de pré-lançamento — logo maior, hierarquia tipográfica melhorada
|
||||||
|
- Lançamentos: filtros de tipo, condição e forma de pagamento agora usam slugs em URL (ex: `receita` em vez do valor literal com acentos)
|
||||||
|
- Estabelecimento: popover de autocomplete agora respeita a largura do input ao abrir
|
||||||
|
- CSP: adicionado `frame-src` para permitir preview de anexos PDF via S3
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Docker: corrigido crash loop no container com mensagem `exec /app/docker-entrypoint.sh: no such file or directory` causado por CRLF no `docker-entrypoint.sh` em ambientes Windows/WSL2 — adicionado `sed -i 's/\r$//'` no Dockerfile e `.gitattributes` com `eol=lf` para scripts shell
|
||||||
|
- S3: corrigido `Error: Region is missing` ao usar o app sem S3 configurado — `S3_REGION` vazio (string vazia) não era tratado pelo operador `??`; substituído por `||` em todo o `s3-client.ts`
|
||||||
|
- i18n: corrigidas mensagens de erro que exibiam "Payer" em inglês em vez de "Pagador"
|
||||||
|
- Logos: corrigido modal seletor de logos de cartões e contas para renderizar miniaturas sem avisos de proporção
|
||||||
|
|
||||||
|
## [2.3.6] - 2026-04-09
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Docker: adicionado `NODE_PATH=/app/migrate/node_modules` no entrypoint para que o `drizzle-kit` consiga resolver `drizzle-orm` ao executar as migrations no container
|
||||||
|
|
||||||
## [2.3.5] - 2026-04-07
|
## [2.3.5] - 2026-04-07
|
||||||
|
|
||||||
### Corrigido
|
### Corrigido
|
||||||
|
|||||||
37
CLAUDE.md
@@ -16,7 +16,7 @@
|
|||||||
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
||||||
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
||||||
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
||||||
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json` e `readme.md`.
|
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json` e `readme.md` (Badges do README.md).
|
||||||
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
||||||
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
||||||
9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.
|
9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.
|
||||||
@@ -85,6 +85,7 @@ src/
|
|||||||
│ │ ├── insights/
|
│ │ ├── insights/
|
||||||
│ │ ├── calendar/
|
│ │ ├── calendar/
|
||||||
│ │ ├── inbox/
|
│ │ ├── inbox/
|
||||||
|
│ │ ├── attachments/
|
||||||
│ │ ├── changelog/
|
│ │ ├── changelog/
|
||||||
│ │ ├── reports/
|
│ │ ├── reports/
|
||||||
│ │ │ ├── category-trends/
|
│ │ │ ├── category-trends/
|
||||||
@@ -111,6 +112,7 @@ src/
|
|||||||
│ ├── insights/
|
│ ├── insights/
|
||||||
│ ├── calendar/
|
│ ├── calendar/
|
||||||
│ ├── inbox/
|
│ ├── inbox/
|
||||||
|
│ ├── attachments/
|
||||||
│ ├── reports/
|
│ ├── reports/
|
||||||
│ └── settings/
|
│ └── settings/
|
||||||
├── shared/
|
├── shared/
|
||||||
@@ -307,18 +309,29 @@ export async function fetchData(userId: string, period: string) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Response Style
|
## Security Rules
|
||||||
|
|
||||||
Quando o time pedir avaliacao de plano ou feature:
|
Regras aplicadas automaticamente ao gerar codigo.
|
||||||
|
|
||||||
1. Responder em portugues simples.
|
### Secrets
|
||||||
2. Listar 3-5 problemas principais.
|
Nunca colocar API keys, credenciais de banco ou tokens em codigo frontend. Evitar variaveis prefixadas com `NEXT_PUBLIC_` para dados sensiveis — estas sao bundladas no cliente. Usar variaveis server-side apenas. `.env` deve estar no `.gitignore` antes do primeiro commit. `.env.example` deve ter apenas placeholders.
|
||||||
3. Fechar com decisao pratica:
|
|
||||||
- aprova agora
|
|
||||||
- nao aprova agora
|
|
||||||
- o que ajustar antes de comecar codigo
|
|
||||||
|
|
||||||
Exemplo:
|
### Autenticacao & Autorizacao
|
||||||
|
Toda rota protegida em `src/app/api/` requer `getUser()` ou `getOptionalUserSession()` antes de qualquer logica, retornando 401 para nao autenticados. Rotas com IDs de recursos devem verificar ownership: `eq(table.userId, userId)`. Rotas admin devem checar role e retornar 403 para nao-admins. Session cookies em Better Auth ja tem `httpOnly`, `secure` e `sameSite` configurados — nao alterar.
|
||||||
|
|
||||||
- "Nao aprovaria para comecar codigo imediatamente."
|
### Input & Output
|
||||||
- "Primeiro ajustaria o doc com estes 5 pontos."
|
Usar Drizzle ORM (parametrizado por padrao) — nunca concatenar input de usuario em SQL. Validar todo input com Zod antes de usar. Upload de arquivos: usar whitelist de MIME types (`ALLOWED_MIME_TYPES`), presigned URLs para S3, token de upload assinado com verificacao pos-upload. Nunca usar `dangerouslySetInnerHTML` com conteudo de usuario.
|
||||||
|
|
||||||
|
### Headers & CSP
|
||||||
|
CSP definida em `src/proxy.ts` via middleware — alterar la, nao em `next.config.ts`. Headers de seguranca (HSTS, X-Frame-Options, etc.) definidos em `next.config.ts`. Nao remover nem enfraquecer essas configuracoes.
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
Login: 5 tentativas/min. Signup: 3 tentativas/min. API tokens: 100 req/min (inbox), 20 req/min (batch). Configurado em `src/shared/lib/auth/config.ts` e nas rotas de inbox. Nao remover.
|
||||||
|
|
||||||
|
### Tratamento de Erros
|
||||||
|
Erros nao devem expor stack traces, paths ou nomes de bibliotecas ao cliente. Usar mensagens genericas: `"Algo deu errado"`. Logar detalhes apenas no servidor com `console.error()`.
|
||||||
|
|
||||||
|
### Dependencias
|
||||||
|
Verificar pacotes novos sugeridos pela IA em npmjs.com antes de instalar. Red flags: menos de 1.000 downloads/semana, publicado nos ultimos 30 dias, nome muito parecido com pacote popular. Rodar `pnpm audit` periodicamente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
|
|||||||
|
|
||||||
# Copiar entrypoint de migrations
|
# Copiar entrypoint de migrations
|
||||||
COPY docker-entrypoint.sh ./
|
COPY docker-entrypoint.sh ./
|
||||||
RUN chmod +x /app/docker-entrypoint.sh && chown nextjs:nodejs /app/docker-entrypoint.sh
|
RUN sed -i 's/\r$//' /app/docker-entrypoint.sh && \
|
||||||
|
chmod +x /app/docker-entrypoint.sh && \
|
||||||
|
chown nextjs:nodejs /app/docker-entrypoint.sh
|
||||||
|
|
||||||
# Definir variáveis de ambiente de produção
|
# Definir variáveis de ambiente de produção
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
169
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.
|
> **⚠️ 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://nextjs.org/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
@@ -23,15 +23,21 @@
|
|||||||
<img src="./public/images/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" />
|
<img src="./public/images/dashboard-preview-light.webp" alt="Dashboard Preview" width="800" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./public/images/preview-lancamentos-light.webp" alt="Lançamentos" width="800" />
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📖 Índice
|
## 📖 Índice
|
||||||
|
|
||||||
- [Sobre o Projeto](#-sobre-o-projeto)
|
- [Sobre o Projeto](#-sobre-o-projeto)
|
||||||
- [Instalação via Script](#-instalação-via-script)
|
- [Instalação via Script](#-instalação-via-script)
|
||||||
|
- [Preparar o servidor (Ubuntu 24.04)](#-preparar-o-servidor-ubuntu-2404)
|
||||||
- [Início Rápido (manual)](#-início-rápido)
|
- [Início Rápido (manual)](#-início-rápido)
|
||||||
- [Scripts Disponíveis](#-scripts-disponíveis)
|
- [Scripts Disponíveis](#-scripts-disponíveis)
|
||||||
- [Docker](#-docker)
|
- [Docker](#-docker)
|
||||||
|
- [Backup](#-backup)
|
||||||
- [Storage S3 Compatível](#-storage-s3-compatível)
|
- [Storage S3 Compatível](#-storage-s3-compatível)
|
||||||
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
|
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
|
||||||
- [Arquitetura](#-arquitetura)
|
- [Arquitetura](#-arquitetura)
|
||||||
@@ -53,7 +59,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
|||||||
|
|
||||||
**1. Não há versão hospedada online** — Este projeto é self-hosted. Você precisa rodar no seu próprio computador ou servidor.
|
**1. Não há versão hospedada online** — Este projeto é self-hosted. Você precisa rodar no seu próprio computador ou servidor.
|
||||||
|
|
||||||
**2. Não há Open Finance** — Não há conexão automática com bancos. Você pode registrar transações manualmente ou importar extratos nos formatos OFX e XLS/XLSX.
|
**2. Não há Open Finance** — Não há conexão automática com bancos. Você pode registrar transações manualmente, usar o app companion para capturar notificações bancárias ou importar extratos nos formatos OFX e XLS/XLSX.
|
||||||
|
|
||||||
**3. Requer disciplina** — O OpenMonetis funciona melhor para quem tem disciplina de registrar os gastos regularmente, quer controle total sobre seus dados e gosta de entender exatamente onde o dinheiro está indo.
|
**3. Requer disciplina** — O OpenMonetis funciona melhor para quem tem disciplina de registrar os gastos regularmente, quer controle total sobre seus dados e gosta de entender exatamente onde o dinheiro está indo.
|
||||||
|
|
||||||
@@ -77,7 +83,11 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
|||||||
|
|
||||||
📅 **Calendário financeiro** — Visualize todos os lançamentos em um calendário mensal.
|
📅 **Calendário financeiro** — Visualize todos os lançamentos em um calendário mensal.
|
||||||
|
|
||||||
📲 **OpenMonetis Companion** — App Android que captura notificações bancárias (Nubank, Itaú, Bradesco, Inter, C6 e outros) e envia como pré-lançamentos para revisão. [Repositório](https://github.com/felipegcoutinho/openmonetis-companion).
|
📲 **OpenMonetis Companion** — App Android que captura notificações bancárias (Nubank, Itaú, Bradesco, Inter, C6 e outros) e envia automaticamente como pré-lançamentos para revisão — sem digitar nada. [Repositório](https://github.com/felipegcoutinho/openmonetis-companion).
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" />
|
||||||
|
</p>
|
||||||
|
|
||||||
⚙️ **Personalização** — Tema dark/light e modo privacidade.
|
⚙️ **Personalização** — Tema dark/light e modo privacidade.
|
||||||
|
|
||||||
@@ -97,6 +107,31 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
|||||||
|
|
||||||
A forma mais rápida de instalar. O script verifica dependências, configura o `.env` interativamente e sobe o banco automaticamente.
|
A forma mais rápida de instalar. O script verifica dependências, configura o `.env` interativamente e sobe o banco automaticamente.
|
||||||
|
|
||||||
|
### 🖥️ Preparar o servidor (Ubuntu 24.04)
|
||||||
|
|
||||||
|
Se você está num **servidor Ubuntu limpo** (VPS, Oracle Cloud, DigitalOcean...) sem Node.js, Docker ou pnpm instalados, use o script de preparação antes de continuar.
|
||||||
|
|
||||||
|
> ⚠️ **Testado apenas em Ubuntu Server 24.04 LTS.** Em outras distribuições ou versões é necessário testar ou ajustar o script.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
|
||||||
|
sudo sh install-deps.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
O script instala (e pula o que já estiver presente):
|
||||||
|
|
||||||
|
| Ferramenta | Como instala |
|
||||||
|
|---|---|
|
||||||
|
| `git`, `curl`, `ca-certificates` | apt |
|
||||||
|
| Docker Engine + Docker Compose | Repositório oficial do Docker |
|
||||||
|
| Homebrew | Script oficial (como usuário não-root) |
|
||||||
|
| Node.js 22 | Via Homebrew |
|
||||||
|
| pnpm | Via corepack |
|
||||||
|
|
||||||
|
Após a conclusão, adiciona o usuário atual ao grupo `docker` — faça logout/login para ativar. Em seguida, prossiga com o `setup.mjs` abaixo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**Pré-requisito:** Node.js 22+
|
**Pré-requisito:** Node.js 22+
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -168,7 +203,7 @@ O script irá:
|
|||||||
|
|
||||||
5. Acesse `http://localhost:3000`
|
5. Acesse `http://localhost:3000`
|
||||||
|
|
||||||
> **Docker completo** (app + banco em containers): use `pnpm docker:up` ao invés dos passos 3-4.
|
> **Docker completo** (app + banco em containers): use `pnpm docker:up:local` ao invés dos passos 3-4.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -190,26 +225,30 @@ pnpm lint:fix # Biome auto-fix
|
|||||||
pnpm db:generate # Gerar migrations
|
pnpm db:generate # Gerar migrations
|
||||||
pnpm db:migrate # Executar migrations
|
pnpm db:migrate # Executar migrations
|
||||||
pnpm db:push # Push schema direto (dev)
|
pnpm db:push # Push schema direto (dev)
|
||||||
|
pnpm db:extensions # Habilitar extensões PostgreSQL (rodar uma vez)
|
||||||
pnpm db:studio # Drizzle Studio (UI visual)
|
pnpm db:studio # Drizzle Studio (UI visual)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Utilitários
|
### Utilitários
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm backup # Backup do banco (requer scripts/backup.sh configurado)
|
pnpm backup # Backup completo do banco (ver seção Backup)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm docker:up # Subir app + banco
|
pnpm docker:up:local # Sobe app + banco PostgreSQL juntos (imagem do Hub)
|
||||||
pnpm docker:up:d # Subir em background
|
pnpm docker:up # Sobe apenas o app com build local
|
||||||
pnpm docker:up:db # Subir apenas o banco
|
pnpm docker:up:d # Sobe apenas o app com build local em background
|
||||||
pnpm docker:down # Parar containers
|
pnpm docker:up:db # Sobe apenas o banco em background
|
||||||
pnpm docker:down:volumes # Parar e remover volumes (⚠️ apaga dados!)
|
pnpm docker:down # Para e remove os containers
|
||||||
pnpm docker:logs # Logs em tempo real
|
pnpm docker:down:volumes # Para containers e remove volumes (⚠️ apaga dados!)
|
||||||
pnpm docker:restart # Reiniciar
|
pnpm docker:logs # Logs em tempo real (todos os containers)
|
||||||
pnpm docker:rebuild # Rebuild completo
|
pnpm docker:logs:app # Logs do container da aplicação
|
||||||
|
pnpm docker:logs:db # Logs do container do banco
|
||||||
|
pnpm docker:restart # Reinicia todos os containers
|
||||||
|
pnpm docker:rebuild # Rebuild completo forçando recriação
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -220,6 +259,46 @@ O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem fi
|
|||||||
|
|
||||||
Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
|
Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
|
||||||
|
|
||||||
|
### Modos de uso
|
||||||
|
|
||||||
|
**Modo 1 — App + banco local (recomendado para self-hosting)**
|
||||||
|
|
||||||
|
Puxa a imagem pronta do Docker Hub e sobe app + banco juntos. Não precisa de Node.js instalado.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Baixar o docker-compose.yml
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||||
|
|
||||||
|
# 2. Criar o .env (copie o .env.example como referência)
|
||||||
|
# DATABASE_URL=postgresql://openmonetis:SUA_SENHA@db:5432/openmonetis_db
|
||||||
|
# POSTGRES_PASSWORD=SUA_SENHA
|
||||||
|
# BETTER_AUTH_SECRET=string-longa-aleatoria
|
||||||
|
# BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# 3. Subir
|
||||||
|
docker compose --profile local up
|
||||||
|
# ou, se tiver o projeto clonado:
|
||||||
|
pnpm docker:up:local
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modo 2 — App com banco remoto**
|
||||||
|
|
||||||
|
Use quando o banco está em um provider externo (Supabase, Neon, Railway...).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# DATABASE_URL deve apontar para o banco remoto no .env
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modo 3 — Build local (desenvolvimento)**
|
||||||
|
|
||||||
|
Builda a imagem localmente a partir do código-fonte.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm docker:up # app apenas (banco separado)
|
||||||
|
pnpm docker:up:db # sobe o banco em background
|
||||||
|
```
|
||||||
|
|
||||||
### Comandos úteis
|
### Comandos úteis
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -239,6 +318,68 @@ DB_PORT=5433 # Padrão: 5432
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 💾 Backup
|
||||||
|
|
||||||
|
O backup é uma rotina de infraestrutura — não é uma tela no app. Ele opera diretamente sobre o banco PostgreSQL e é executado via linha de comando.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm backup
|
||||||
|
```
|
||||||
|
|
||||||
|
### O que é salvo
|
||||||
|
|
||||||
|
Cada execução gera **3 arquivos** em `backup/`:
|
||||||
|
|
||||||
|
| Arquivo | Conteúdo | Uso |
|
||||||
|
|---|---|---|
|
||||||
|
| `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom do PostgreSQL (binário) | Restore completo via `pg_restore` |
|
||||||
|
| `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL completo compactado | Inspeção manual, portabilidade |
|
||||||
|
| `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados da schema `public` | Migração parcial, seed de outro ambiente |
|
||||||
|
|
||||||
|
### Modos de conexão
|
||||||
|
|
||||||
|
Configure `DB_MODE` no topo de `scripts/backup.sh`:
|
||||||
|
|
||||||
|
| Modo | Quando usar | Fonte de dados |
|
||||||
|
|---|---|---|
|
||||||
|
| `remote` (padrão) | Banco em Supabase, Neon, Railway, etc. | `DATABASE_URL` do `.env` |
|
||||||
|
| `docker` | Banco no container local | Container `openmonetis_postgres` |
|
||||||
|
|
||||||
|
### Upload para Google Drive (opcional)
|
||||||
|
|
||||||
|
Se o [rclone](https://rclone.org/) estiver instalado e configurado com um remote chamado `gdrive`, os arquivos são enviados automaticamente para `gdrive:BACKUP OPENMONETIS`. Sem o rclone, o backup funciona normalmente e fica apenas local.
|
||||||
|
|
||||||
|
**Retenção:**
|
||||||
|
- Local: 7 dias
|
||||||
|
- Google Drive: 30 dias
|
||||||
|
|
||||||
|
### Automatizar com cron
|
||||||
|
|
||||||
|
Para rodar o backup automaticamente todo dia às 3h:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
```cron
|
||||||
|
0 3 * * * cd /caminho/para/openmonetis && pnpm backup >> /var/log/openmonetis-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# A partir do .dump (recomendado — mais rápido)
|
||||||
|
pg_restore --clean --no-owner --no-privileges \
|
||||||
|
-d "postgresql://user:senha@host:5432/openmonetis_db" \
|
||||||
|
backup/openmonetis_YYYY-MM-DD_HH-MM.dump
|
||||||
|
|
||||||
|
# A partir do .sql.gz (banco local via Docker)
|
||||||
|
gunzip -c backup/openmonetis_YYYY-MM-DD_HH-MM.sql.gz | \
|
||||||
|
docker compose exec -T db psql -U openmonetis -d openmonetis_db
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ☁️ Storage S3 Compatível
|
## ☁️ Storage S3 Compatível
|
||||||
|
|
||||||
O suporte a anexos de lançamentos usa upload direto com URL pré-assinada. Essa configuração é opcional, mas passa a ser necessária se você quiser habilitar anexos no app.
|
O suporte a anexos de lançamentos usa upload direto com URL pré-assinada. Essa configuração é opcional, mas passa a ser necessária se você quiser habilitar anexos no app.
|
||||||
@@ -256,7 +397,7 @@ S3_BUCKET=
|
|||||||
### Compatibilidade
|
### Compatibilidade
|
||||||
|
|
||||||
- O código atual espera um provider com API compatível com S3 e suporte a `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` e URLs pré-assinadas.
|
- O código atual espera um provider com API compatível com S3 e suporte a `PutObject`, `GetObject`, `HeadObject`, `DeleteObject` e URLs pré-assinadas.
|
||||||
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](/home/ubuntu/github/openmonetis/src/shared/lib/storage/s3-client.ts).
|
- A implementação usa `endpoint` customizado e `forcePathStyle: true` em [`src/shared/lib/storage/s3-client.ts`](./src/shared/lib/storage/s3-client.ts).
|
||||||
- Em geral isso cobre MinIO, Cloudflare R2, Backblaze B2 S3-Compatible, DigitalOcean Spaces e AWS S3. Mas foi testado apenas no Supabase Storage.
|
- Em geral isso cobre MinIO, Cloudflare R2, Backblaze B2 S3-Compatible, DigitalOcean Spaces e AWS S3. Mas foi testado apenas no Supabase Storage.
|
||||||
- Se o seu provider exigir `virtual-hosted-style` em vez de `path-style`, você vai precisar ajustar essa configuração antes de usar anexos.
|
- Se o seu provider exigir `virtual-hosted-style` em vez de `path-style`, você vai precisar ajustar essa configuração antes de usar anexos.
|
||||||
- Se as variáveis de S3 não forem configuradas, mantenha os anexos desabilitados no seu fluxo de uso.
|
- Se as variáveis de S3 não forem configuradas, mantenha os anexos desabilitados no seu fluxo de uso.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
echo "Rodando migrations..."
|
echo "Rodando migrations..."
|
||||||
RETRIES=5
|
RETRIES=5
|
||||||
until /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
|
until NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
|
||||||
RETRIES=$((RETRIES - 1))
|
RETRIES=$((RETRIES - 1))
|
||||||
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
|
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ const nextConfig: NextConfig = {
|
|||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
|
remotePatterns: [
|
||||||
|
new URL("https://lh3.googleusercontent.com/**"),
|
||||||
|
{ protocol: "https", hostname: "**" },
|
||||||
|
{ protocol: "http", hostname: "**" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
devIndicators: {
|
devIndicators: {
|
||||||
position: "bottom-right",
|
position: "bottom-right",
|
||||||
|
|||||||
69
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.3.5",
|
"version": "2.3.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,30 +17,56 @@
|
|||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"docker:up": "docker compose up --build",
|
|
||||||
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
||||||
"docker:up:db": "docker compose up -d db",
|
|
||||||
|
"// --- Docker ---": "---",
|
||||||
|
|
||||||
|
"docker:up:local": "docker compose --profile local up",
|
||||||
|
"//docker:up:local": "Sobe app + banco PostgreSQL local juntos (imagem do Docker Hub)",
|
||||||
|
|
||||||
|
"docker:up": "docker compose up --build",
|
||||||
|
"//docker:up": "Sobe apenas o app com build local (banco deve estar rodando separado)",
|
||||||
|
|
||||||
"docker:up:d": "docker compose up --build -d",
|
"docker:up:d": "docker compose up --build -d",
|
||||||
|
"//docker:up:d": "Sobe apenas o app com build local em background (detached)",
|
||||||
|
|
||||||
|
"docker:up:db": "docker compose up -d db",
|
||||||
|
"//docker:up:db": "Sobe apenas o banco PostgreSQL em background",
|
||||||
|
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
|
"//docker:down": "Para e remove os containers",
|
||||||
|
|
||||||
"docker:down:volumes": "docker compose down -v",
|
"docker:down:volumes": "docker compose down -v",
|
||||||
|
"//docker:down:volumes": "Para containers e remove volumes (APAGA os dados!)",
|
||||||
|
|
||||||
"docker:logs": "docker compose logs -f",
|
"docker:logs": "docker compose logs -f",
|
||||||
|
"//docker:logs": "Acompanha logs de todos os containers em tempo real",
|
||||||
|
|
||||||
"docker:logs:app": "docker compose logs -f app",
|
"docker:logs:app": "docker compose logs -f app",
|
||||||
|
"//docker:logs:app": "Acompanha logs do container da aplicação",
|
||||||
|
|
||||||
"docker:logs:db": "docker compose logs -f db",
|
"docker:logs:db": "docker compose logs -f db",
|
||||||
|
"//docker:logs:db": "Acompanha logs do container do banco",
|
||||||
|
|
||||||
"docker:restart": "docker compose restart",
|
"docker:restart": "docker compose restart",
|
||||||
|
"//docker:restart": "Reinicia todos os containers",
|
||||||
|
|
||||||
"docker:rebuild": "docker compose up --build --force-recreate",
|
"docker:rebuild": "docker compose up --build --force-recreate",
|
||||||
|
"//docker:rebuild": "Rebuild completo forçando recriação dos containers",
|
||||||
|
|
||||||
"backup": "bash scripts/backup.sh"
|
"backup": "bash scripts/backup.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.65",
|
"@ai-sdk/anthropic": "^3.0.68",
|
||||||
"@ai-sdk/google": "^3.0.55",
|
"@ai-sdk/google": "^3.0.61",
|
||||||
"@ai-sdk/openai": "^3.0.49",
|
"@ai-sdk/openai": "^3.0.52",
|
||||||
"@aws-sdk/client-s3": "^3.1022.0",
|
"@aws-sdk/client-s3": "^3.1027.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1022.0",
|
"@aws-sdk/s3-request-presigner": "^3.1027.0",
|
||||||
"@better-auth/passkey": "^1.5.6",
|
"@better-auth/passkey": "^1.6.2",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@openrouter/ai-sdk-provider": "^2.3.3",
|
"@openrouter/ai-sdk-provider": "^2.5.1",
|
||||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||||
"@radix-ui/react-avatar": "1.1.11",
|
"@radix-ui/react-avatar": "1.1.11",
|
||||||
"@radix-ui/react-checkbox": "1.3.3",
|
"@radix-ui/react-checkbox": "1.3.3",
|
||||||
@@ -61,11 +87,11 @@
|
|||||||
"@radix-ui/react-toggle-group": "1.1.11",
|
"@radix-ui/react-toggle-group": "1.1.11",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "^5.96.2",
|
"@tanstack/react-query": "^5.97.0",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@tanstack/react-virtual": "^3.13.23",
|
||||||
"ai": "^6.0.143",
|
"ai": "^6.0.154",
|
||||||
"better-auth": "1.5.6",
|
"better-auth": "1.6.2",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -75,14 +101,14 @@
|
|||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"next": "16.2.2",
|
"next": "16.2.3",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"pdfjs-dist": "^5.6.205",
|
"pdfjs-dist": "^5.6.205",
|
||||||
"pg": "8.20.0",
|
"pg": "8.20.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.5",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.5",
|
||||||
"recharts": "3.8.1",
|
"recharts": "3.8.1",
|
||||||
"resend": "^6.10.0",
|
"resend": "^6.10.0",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
@@ -90,17 +116,22 @@
|
|||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"defu": "6.1.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.10",
|
"@biomejs/biome": "2.4.10",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.2.2",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "25.5.0",
|
"@types/node": "25.5.2",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.1",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"knip": "^6.3.0",
|
"knip": "^6.3.1",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.2.2",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "6.0.2"
|
"typescript": "6.0.2"
|
||||||
|
|||||||
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({
|
export const inter = Inter({
|
||||||
src: [
|
subsets: ["latin"],
|
||||||
{
|
display: "swap",
|
||||||
path: "./america-regular.woff2",
|
variable: "--font-inter",
|
||||||
weight: "400",
|
fallback: ["ui-sans-serif", "system-ui"],
|
||||||
style: "normal",
|
preload: true,
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "./america-medium.woff2",
|
|
||||||
weight: "500",
|
|
||||||
style: "normal",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
display: "fallback",
|
|
||||||
variable: "--font-america",
|
|
||||||
});
|
});
|
||||||
|
|||||||
|
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 |
241
scripts/install-deps.sh
Executable file
@@ -0,0 +1,241 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# install-deps.sh — Instala pré-requisitos do OpenMonetis
|
||||||
|
# Testado apenas em Ubuntu Server 24.04 LTS
|
||||||
|
# Uso: curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
|
||||||
|
# sudo sh install-deps.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
LOG_FILE="/tmp/openmonetis-install.log"
|
||||||
|
> "$LOG_FILE"
|
||||||
|
|
||||||
|
# ── Cores ──────────────────────────────────────────────────────────────────────
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
ok() { printf "${GREEN}✔${RESET} %s\n" "$1"; }
|
||||||
|
warn() { printf "${YELLOW}!${RESET} %s\n" "$1"; }
|
||||||
|
info() { printf "${CYAN}→${RESET} %s\n" "$1"; }
|
||||||
|
fail() { printf "${RED}✗${RESET} %s\n" "$1"; exit 1; }
|
||||||
|
|
||||||
|
# ── Contador de etapas ─────────────────────────────────────────────────────────
|
||||||
|
_STEP=0
|
||||||
|
_TOTAL=5
|
||||||
|
|
||||||
|
section() {
|
||||||
|
_STEP=$((_STEP + 1))
|
||||||
|
printf "\n${BOLD}[%d/%d] %s${RESET}\n" "$_STEP" "$_TOTAL" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Spinner ────────────────────────────────────────────────────────────────────
|
||||||
|
_spin_pid=""
|
||||||
|
|
||||||
|
spinner_start() {
|
||||||
|
_spin_label="$1"
|
||||||
|
( i=0
|
||||||
|
while true; do
|
||||||
|
case $((i % 4)) in
|
||||||
|
0) d=" " ;; 1) d=". " ;; 2) d=".. " ;; *) d="..." ;;
|
||||||
|
esac
|
||||||
|
printf "\r${CYAN}→${RESET} %s%s" "$_spin_label" "$d"
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 0.4
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
_spin_pid=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner_stop() {
|
||||||
|
if [ -n "$_spin_pid" ]; then
|
||||||
|
kill "$_spin_pid" 2>/dev/null
|
||||||
|
wait "$_spin_pid" 2>/dev/null
|
||||||
|
_spin_pid=""
|
||||||
|
printf "\r\033[2K"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Executores silenciosos com spinner ─────────────────────────────────────────
|
||||||
|
|
||||||
|
# run_quiet "label" cmd [args...] — roda comando com spinner, falha mostra log
|
||||||
|
run_quiet() {
|
||||||
|
_rq_label="$1"; shift
|
||||||
|
spinner_start "$_rq_label"
|
||||||
|
if ! "$@" >> "$LOG_FILE" 2>&1; then
|
||||||
|
spinner_stop
|
||||||
|
printf "${RED}✗ Falha em: %s${RESET}\n" "$_rq_label"
|
||||||
|
printf " Log completo: %s\n\n" "$LOG_FILE"
|
||||||
|
tail -20 "$LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
spinner_stop
|
||||||
|
}
|
||||||
|
|
||||||
|
# run_as_user "label" "comando_shell" — roda comando como $CURRENT_USER com spinner
|
||||||
|
run_as_user() {
|
||||||
|
_ru_label="$1"; shift
|
||||||
|
spinner_start "$_ru_label"
|
||||||
|
if ! su - "$CURRENT_USER" -c "$*" >> "$LOG_FILE" 2>&1; then
|
||||||
|
spinner_stop
|
||||||
|
printf "${RED}✗ Falha em: %s${RESET}\n" "$_ru_label"
|
||||||
|
printf " Log completo: %s\n\n" "$LOG_FILE"
|
||||||
|
tail -20 "$LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
spinner_stop
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Cleanup no Ctrl+C ──────────────────────────────────────────────────────────
|
||||||
|
cleanup() {
|
||||||
|
spinner_stop
|
||||||
|
printf "\n${YELLOW}Instalação interrompida.${RESET} Log em: %s\n" "$LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
trap cleanup INT TERM
|
||||||
|
|
||||||
|
# ── Tempo total ────────────────────────────────────────────────────────────────
|
||||||
|
_START=$(date +%s)
|
||||||
|
elapsed() {
|
||||||
|
_secs=$(( $(date +%s) - _START ))
|
||||||
|
printf "%dm%ds" $((_secs / 60)) $((_secs % 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Root check ─────────────────────────────────────────────────────────────────
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
fail "Execute como root ou com sudo: sudo sh install-deps.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_USER="${SUDO_USER:-$(whoami)}"
|
||||||
|
|
||||||
|
printf "\n${BOLD}OpenMonetis — Instalação de Dependências${RESET}\n"
|
||||||
|
printf "Usuário: ${CYAN}%s${RESET} | Log: %s\n" "$CURRENT_USER" "$LOG_FILE"
|
||||||
|
|
||||||
|
# ── [1/5] Dependências base ────────────────────────────────────────────────────
|
||||||
|
section "Dependências base"
|
||||||
|
run_quiet "Atualizando lista de pacotes" apt-get update -qq
|
||||||
|
run_quiet "Instalando git, curl, ca-certificates" apt-get install -y -qq ca-certificates curl git
|
||||||
|
ok "git $(git --version | cut -d' ' -f3) · curl · ca-certificates"
|
||||||
|
|
||||||
|
# ── [2/5] Docker ───────────────────────────────────────────────────────────────
|
||||||
|
section "Docker"
|
||||||
|
|
||||||
|
if command -v docker > /dev/null 2>&1; then
|
||||||
|
ok "Docker já instalado: $(docker --version | cut -d',' -f1)"
|
||||||
|
else
|
||||||
|
info "Adicionando repositório oficial do Docker..."
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
run_quiet "Baixando chave GPG do Docker" \
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
|
||||||
|
. /etc/os-release
|
||||||
|
mkdir -p /etc/apt/sources.list.d
|
||||||
|
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu %s stable\n' \
|
||||||
|
"$(dpkg --print-architecture)" "$VERSION_CODENAME" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
|
||||||
|
run_quiet "Atualizando lista de pacotes" apt-get update -qq
|
||||||
|
run_quiet "Instalando Docker Engine (pode levar alguns minutos)" \
|
||||||
|
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
|
||||||
|
systemctl enable docker > /dev/null 2>&1 || true
|
||||||
|
systemctl start docker > /dev/null 2>&1 || true
|
||||||
|
ok "Docker $(docker --version | cut -d',' -f1 | cut -d' ' -f3) instalado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker compose version > /dev/null 2>&1; then
|
||||||
|
ok "Docker Compose $(docker compose version | cut -d' ' -f4)"
|
||||||
|
else
|
||||||
|
run_quiet "Instalando Docker Compose plugin" \
|
||||||
|
sh -c 'mkdir -p /usr/local/lib/docker/cli-plugins && curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" -o /usr/local/lib/docker/cli-plugins/docker-compose && chmod +x /usr/local/lib/docker/cli-plugins/docker-compose'
|
||||||
|
ok "Docker Compose $(docker compose version | cut -d' ' -f4) instalado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
|
||||||
|
if ! groups "$CURRENT_USER" | grep -q docker; then
|
||||||
|
usermod -aG docker "$CURRENT_USER"
|
||||||
|
warn "Usuário '$CURRENT_USER' adicionado ao grupo docker — faça logout/login para aplicar"
|
||||||
|
else
|
||||||
|
ok "Usuário '$CURRENT_USER' já está no grupo docker"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── [3/5] Homebrew ─────────────────────────────────────────────────────────────
|
||||||
|
section "Homebrew"
|
||||||
|
|
||||||
|
if command -v brew > /dev/null 2>&1; then
|
||||||
|
ok "Homebrew já instalado: $(brew --version | head -1)"
|
||||||
|
else
|
||||||
|
warn "Esta etapa pode levar de 5 a 10 minutos."
|
||||||
|
run_quiet "Instalando dependências de compilação" \
|
||||||
|
apt-get install -y -qq build-essential procps file
|
||||||
|
|
||||||
|
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
|
||||||
|
run_as_user "Instalando Homebrew" \
|
||||||
|
'NONINTERACTIVE=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||||
|
|
||||||
|
BREW_PROFILE="/home/$CURRENT_USER/.bashrc"
|
||||||
|
BREW_EVAL='eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"'
|
||||||
|
grep -qxF "$BREW_EVAL" "$BREW_PROFILE" 2>/dev/null || echo "$BREW_EVAL" >> "$BREW_PROFILE"
|
||||||
|
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
|
||||||
|
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
fail "Homebrew não pode ser instalado como root. Use sudo com um usuário normal."
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "Homebrew instalado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── [4/5] Node.js 22 ───────────────────────────────────────────────────────────
|
||||||
|
section "Node.js 22"
|
||||||
|
|
||||||
|
NODE_MAJOR=0
|
||||||
|
if command -v node > /dev/null 2>&1; then
|
||||||
|
NODE_MAJOR=$(node -e "process.stdout.write(String(parseInt(process.versions.node)))")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NODE_MAJOR" -ge 22 ] 2>/dev/null; then
|
||||||
|
ok "Node.js já instalado: $(node --version)"
|
||||||
|
else
|
||||||
|
warn "Node.js via Homebrew pode levar alguns minutos."
|
||||||
|
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
|
||||||
|
run_as_user "Instalando Node.js 22" \
|
||||||
|
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && brew install node@22 && brew link node@22 --force --overwrite'
|
||||||
|
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
|
||||||
|
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
fail "Node.js via Homebrew não pode ser instalado como root."
|
||||||
|
fi
|
||||||
|
ok "Node.js $(node --version) instalado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── [5/5] pnpm ─────────────────────────────────────────────────────────────────
|
||||||
|
section "pnpm"
|
||||||
|
|
||||||
|
if command -v pnpm > /dev/null 2>&1; then
|
||||||
|
ok "pnpm já instalado: $(pnpm --version)"
|
||||||
|
else
|
||||||
|
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
|
||||||
|
run_as_user "Instalando pnpm via corepack" \
|
||||||
|
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && corepack enable && corepack prepare pnpm@latest --activate'
|
||||||
|
else
|
||||||
|
run_quiet "Instalando pnpm via corepack" \
|
||||||
|
sh -c 'corepack enable && corepack prepare pnpm@latest --activate'
|
||||||
|
fi
|
||||||
|
ok "pnpm instalado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Resumo ─────────────────────────────────────────────────────────────────────
|
||||||
|
printf "\n${BOLD}Concluído em $(elapsed)${RESET}\n"
|
||||||
|
|
||||||
|
ok "git: $(git --version | cut -d' ' -f3)"
|
||||||
|
ok "docker: $(docker --version | cut -d',' -f1 | cut -d' ' -f3)"
|
||||||
|
ok "docker compose: $(docker compose version | cut -d' ' -f4)"
|
||||||
|
ok "node: $(node --version)"
|
||||||
|
ok "pnpm: $(pnpm --version)"
|
||||||
|
|
||||||
|
printf "\n${CYAN}Próximo passo:${RESET}\n"
|
||||||
|
printf " node setup.mjs\n\n"
|
||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiBankLine />}
|
icon={<RiBankLine />}
|
||||||
title="Contas"
|
title="Contas"
|
||||||
|
|||||||
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;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiBarChart2Line />}
|
icon={<RiBarChart2Line />}
|
||||||
title="Orçamentos"
|
title="Orçamentos"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiCalendarEventLine />}
|
icon={<RiCalendarEventLine />}
|
||||||
title="Calendário"
|
title="Calendário"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiBankCard2Line />}
|
icon={<RiBankCard2Line />}
|
||||||
title="Cartões"
|
title="Cartões"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiPriceTag3Line />}
|
icon={<RiPriceTag3Line />}
|
||||||
title="Categorias"
|
title="Categorias"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiHistoryLine />}
|
icon={<RiHistoryLine />}
|
||||||
title="Changelog"
|
title="Changelog"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiAtLine />}
|
icon={<RiAtLine />}
|
||||||
title="Pré-Lançamentos"
|
title="Pré-Lançamentos"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiSparklingLine />}
|
icon={<RiSparklingLine />}
|
||||||
title="Insights"
|
title="Insights"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiTodoLine />}
|
icon={<RiTodoLine />}
|
||||||
title="Anotações"
|
title="Anotações"
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
|||||||
categoryFilter: null,
|
categoryFilter: null,
|
||||||
accountCardFilter: null,
|
accountCardFilter: null,
|
||||||
searchFilter: null,
|
searchFilter: null,
|
||||||
|
settledFilter: null,
|
||||||
|
attachmentFilter: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEmptySlugMaps = (): SlugMaps => ({
|
const createEmptySlugMaps = (): SlugMaps => ({
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiGroupLine />}
|
icon={<RiGroupLine />}
|
||||||
title="Pagadores"
|
title="Pagadores"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiBankCard2Line />}
|
icon={<RiBankCard2Line />}
|
||||||
title="Uso de Cartões"
|
title="Uso de Cartões"
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default async function RelatorioCartoesPage({
|
|||||||
<div className="flex size-14 items-center justify-center rounded-full bg-muted mb-4">
|
<div className="flex size-14 items-center justify-center rounded-full bg-muted mb-4">
|
||||||
<RiBankCard2Line className="size-7 text-muted-foreground" />
|
<RiBankCard2Line className="size-7 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base font-medium">Nenhum cartão selecionado</p>
|
<p className="text-base font-semibold">Nenhum cartão selecionado</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Selecione um cartão para ver os detalhes de uso.
|
Selecione um cartão para ver os detalhes de uso.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiFileChartLine />}
|
icon={<RiFileChartLine />}
|
||||||
title="Tendências"
|
title="Tendências"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiStore2Line />}
|
icon={<RiStore2Line />}
|
||||||
title="Top Estabelecimentos"
|
title="Top Estabelecimentos"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiSecurePaymentLine />}
|
icon={<RiSecurePaymentLine />}
|
||||||
title="Análise de Parcelas"
|
title="Análise de Parcelas"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiSettings2Line />}
|
icon={<RiSettings2Line />}
|
||||||
title="Ajustes"
|
title="Ajustes"
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-medium mb-1">Preferências</h2>
|
<h2 className="text-xl font-semibold mb-1">Preferências</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Personalize sua experiência no OpenMonetis ajustando as
|
Personalize sua experiência no OpenMonetis ajustando as
|
||||||
configurações de acordo com suas necessidades.
|
configurações de acordo com suas necessidades.
|
||||||
@@ -92,7 +92,9 @@ export default async function Page() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h2 className="text-xl font-medium">OpenMonetis Companion</h2>
|
<h2 className="text-xl font-semibold">
|
||||||
|
OpenMonetis Companion
|
||||||
|
</h2>
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
|
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
|
||||||
<RiAndroidLine className="h-3 w-3" />
|
<RiAndroidLine className="h-3 w-3" />
|
||||||
Android
|
Android
|
||||||
@@ -114,7 +116,7 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-medium mb-1">Alterar nome</h2>
|
<h2 className="text-xl font-semibold mb-1">Alterar nome</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
|
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
|
||||||
ser exibido em diferentes seções do app e em comunicações.
|
ser exibido em diferentes seções do app e em comunicações.
|
||||||
@@ -130,7 +132,7 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-medium mb-1">Alterar senha</h2>
|
<h2 className="text-xl font-semibold mb-1">Alterar senha</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Defina uma nova senha para sua conta. Guarde-a em local
|
Defina uma nova senha para sua conta. Guarde-a em local
|
||||||
seguro.
|
seguro.
|
||||||
@@ -146,7 +148,7 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-medium mb-1">Passkeys</h2>
|
<h2 className="text-xl font-semibold mb-1">Passkeys</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Passkeys permitem login sem senha, usando biometria (Face ID,
|
Passkeys permitem login sem senha, usando biometria (Face ID,
|
||||||
Touch ID, Windows Hello) ou chaves de segurança.
|
Touch ID, Windows Hello) ou chaves de segurança.
|
||||||
@@ -162,7 +164,7 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-medium mb-1">Alterar e-mail</h2>
|
<h2 className="text-xl font-semibold mb-1">Alterar e-mail</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Atualize o e-mail associado à sua conta. Você precisará
|
Atualize o e-mail associado à sua conta. Você precisará
|
||||||
confirmar os links enviados para o novo e também para o e-mail
|
confirmar os links enviados para o novo e também para o e-mail
|
||||||
@@ -182,9 +184,7 @@ export default async function Page() {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-medium mb-1 text-destructive">
|
<h2 className="text-xl font-semibold mb-1">Ações perigosas</h2>
|
||||||
Ações perigosas
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Você pode zerar os dados do OpenMonetis e manter seu acesso,
|
Você pode zerar os dados do OpenMonetis e manter seu acesso,
|
||||||
ou excluir sua conta inteira de forma irreversível.
|
ou excluir sua conta inteira de forma irreversível.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-4">
|
<section className="space-y-6">
|
||||||
<PageDescription
|
<PageDescription
|
||||||
icon={<RiArrowLeftRightLine />}
|
icon={<RiArrowLeftRightLine />}
|
||||||
title="Lançamentos"
|
title="Lançamentos"
|
||||||
|
|||||||
@@ -120,13 +120,13 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-8xl mx-auto px-4 relative">
|
<div className="max-w-8xl mx-auto px-4 relative">
|
||||||
<div className="mx-auto flex max-w-3xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14">
|
<div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
<RiGithubFill className="size-4 mr-1" />
|
<RiGithubFill className="size-4 mr-1" />
|
||||||
Projeto Open Source
|
Projeto Open Source
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-medium tracking-tight">
|
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold">
|
||||||
Suas finanças,
|
Suas finanças,
|
||||||
<span className="text-primary"> do seu jeito</span>
|
<span className="text-primary"> do seu jeito</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -207,7 +207,7 @@ export default async function Page() {
|
|||||||
className="flex flex-col items-center text-center gap-1.5"
|
className="flex flex-col items-center text-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Icon className="size-5" style={{ color: colorVar }} />
|
<Icon className="size-5" style={{ color: colorVar }} />
|
||||||
<span className="text-2xl md:text-3xl font-medium">
|
<span className="text-2xl md:text-3xl font-semibold">
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs md:text-sm text-muted-foreground">
|
<span className="text-xs md:text-sm text-muted-foreground">
|
||||||
@@ -229,7 +229,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Conheça as telas
|
Conheça as telas
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
|
||||||
Veja o que você pode fazer
|
Veja o que você pode fazer
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -254,7 +254,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
O que tem aqui
|
O que tem aqui
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
|
||||||
Funcionalidades que importam
|
Funcionalidades que importam
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -282,7 +282,7 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
|
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
@@ -298,7 +298,7 @@ export default async function Page() {
|
|||||||
|
|
||||||
<AnimateOnScroll>
|
<AnimateOnScroll>
|
||||||
<div className="mt-8 md:mt-12">
|
<div className="mt-8 md:mt-12">
|
||||||
<h3 className="text-sm font-medium text-center mb-4 md:mb-6 text-muted-foreground">
|
<h3 className="text-sm font-semibold text-center mb-4 md:mb-6 text-muted-foreground">
|
||||||
Também inclui
|
Também inclui
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@@ -319,7 +319,7 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h4 className="font-medium text-sm mb-0.5">
|
<h4 className="font-semibold text-sm mb-0.5">
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
@@ -346,7 +346,7 @@ export default async function Page() {
|
|||||||
<RiSmartphoneLine className="size-3.5 mr-1" />
|
<RiSmartphoneLine className="size-3.5 mr-1" />
|
||||||
Mobile
|
Mobile
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
|
||||||
Use o OpenMonetis no celular sem perder o fluxo
|
Use o OpenMonetis no celular sem perder o fluxo
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -529,7 +529,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Stack técnica
|
Stack técnica
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
|
||||||
O que roda por baixo
|
O que roda por baixo
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||||
@@ -556,7 +556,7 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
|
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
|
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
|
||||||
@@ -582,7 +582,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Como usar
|
Como usar
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
|
||||||
Rode no seu computador
|
Rode no seu computador
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
||||||
@@ -617,7 +617,7 @@ export default async function Page() {
|
|||||||
<Badge variant="outline" className="mb-4">
|
<Badge variant="outline" className="mb-4">
|
||||||
Para quem é?
|
Para quem é?
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
|
||||||
Feito para quem gosta de controle
|
Feito para quem gosta de controle
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
||||||
@@ -644,7 +644,7 @@ export default async function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium mb-1">{item.title}</h3>
|
<h3 className="font-semibold mb-1">{item.title}</h3>
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -664,7 +664,7 @@ export default async function Page() {
|
|||||||
<div className="max-w-8xl mx-auto px-4">
|
<div className="max-w-8xl mx-auto px-4">
|
||||||
<AnimateOnScroll>
|
<AnimateOnScroll>
|
||||||
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
|
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
|
||||||
Pronto para testar?
|
Pronto para testar?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
|
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
|
||||||
@@ -715,7 +715,7 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium mb-3 md:mb-4">Projeto</h3>
|
<h3 className="font-semibold mb-3 md:mb-4">Projeto</h3>
|
||||||
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
@@ -749,7 +749,7 @@ export default async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium mb-3 md:mb-4">Companion</h3>
|
<h3 className="font-semibold mb-3 md:mb-4">Companion</h3>
|
||||||
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -177,7 +177,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--default-font-family: var(--font-america);
|
--default-font-family: var(--font-inter);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { QueryProvider } from "@/shared/components/providers/query-provider";
|
|||||||
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
||||||
import { Toaster } from "@/shared/components/ui/sonner";
|
import { Toaster } from "@/shared/components/ui/sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { america } from "@/public/fonts/font_index";
|
import { inter } from "@/public/fonts/font_index";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -24,19 +24,23 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
data-scroll-behavior="smooth"
|
data-scroll-behavior="smooth"
|
||||||
lang="pt-BR"
|
lang="pt-BR"
|
||||||
className={`${america.variable} ${america.className} `}
|
className={`${inter.variable}`}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
|
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
|
||||||
<script
|
{process.env.UMAMI_URL && process.env.UMAMI_WEBSITE_ID && (
|
||||||
defer
|
<script
|
||||||
src="https://umami.felipecoutinho.com/script.js"
|
defer
|
||||||
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
|
src={`${process.env.UMAMI_URL}/script.js`}
|
||||||
data-domains="openmonetis.com"
|
data-website-id={process.env.UMAMI_WEBSITE_ID}
|
||||||
/>
|
{...(process.env.UMAMI_DOMAINS
|
||||||
|
? { "data-domains": process.env.UMAMI_DOMAINS }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased" suppressHydrationWarning>
|
<body className="subpixel-antialiased" suppressHydrationWarning>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light">
|
<ThemeProvider attribute="class" defaultTheme="light">
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<Suspense>{children}</Suspense>
|
<Suspense>{children}</Suspense>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export async function createAccountAction(
|
|||||||
|
|
||||||
if (hasInitialBalance && !adminPayerId) {
|
if (hasInitialBalance && !adminPayerId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Payer com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
|
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ export async function transferBetweenAccountsAction(
|
|||||||
|
|
||||||
if (!adminPayerId) {
|
if (!adminPayerId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Payer administrador não encontrado. Por favor, crie um pagador admin.",
|
"Pagador administrador não encontrado. Por favor, crie um pagador admin.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ export function AccountCard({
|
|||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<h2 className="text-lg font-medium text-foreground">{accountName}</h2>
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
{accountName}
|
||||||
|
</h2>
|
||||||
|
|
||||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function AccountStatementCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-sm font-medium text-foreground">
|
<h2 className="truncate text-sm font-semibold text-foreground">
|
||||||
{accountName}
|
{accountName}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -81,12 +81,12 @@ export function AccountStatementCard({
|
|||||||
|
|
||||||
{/* Linha 2 — saldo final (hero) */}
|
{/* Linha 2 — saldo final (hero) */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm font-medium text-muted-foreground ">
|
<p className="text-sm text-muted-foreground ">
|
||||||
Saldo ao final do período
|
Saldo ao final do período
|
||||||
</p>
|
</p>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={currentBalance}
|
amount={currentBalance}
|
||||||
className="text-3xl leading-none font-medium tracking-tight sm:text-[2rem]"
|
className="text-3xl leading-none tracking-tighter sm:text-[2rem]"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -69,9 +69,7 @@ function PdfCanvas({ url }: PdfCanvasProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
|
||||||
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
|
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
|
||||||
<span className="text-xs font-medium text-muted-foreground/60">
|
<span className="text-xs text-muted-foreground/60">PDF Protegido</span>
|
||||||
PDF Protegido
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -153,7 +151,7 @@ export function AttachmentGridItem({
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<p className="truncate text-sm font-medium leading-tight text-foreground">
|
<p className="truncate text-sm font-semibold leading-tight text-foreground">
|
||||||
{attachment.fileName}
|
{attachment.fileName}
|
||||||
</p>
|
</p>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -180,25 +178,21 @@ export function AttachmentGridItem({
|
|||||||
{attachment.transactionName}
|
{attachment.transactionName}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span
|
<span className={cn("shrink-0 text-sm font-medium tracking-tighter")}>
|
||||||
className={cn(
|
|
||||||
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatCurrency(amount)}
|
{formatCurrency(amount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer: Tamanho + Botão Detalhes */}
|
{/* Footer: Tamanho + Botão Detalhes */}
|
||||||
<div className="mt-auto flex items-center justify-between border-t pt-3">
|
<div className="mt-auto flex items-center justify-between border-t pt-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground/70">
|
<span className="text-xs text-muted-foreground/70">
|
||||||
{formatBytes(attachment.fileSize)}
|
{formatBytes(attachment.fileSize)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDetails}
|
onClick={onDetails}
|
||||||
disabled={isLoadingDetails}
|
disabled={isLoadingDetails}
|
||||||
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
|
className="text-xs text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoadingDetails ? "Carregando..." : "Detalhes"}
|
{isLoadingDetails ? "Carregando..." : "Detalhes"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function AttachmentPreview({
|
|||||||
>
|
>
|
||||||
<RiArrowLeftSLine className="size-4" />
|
<RiArrowLeftSLine className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="select-none text-xs text-muted-foreground tabular-nums">
|
<span className="select-none text-xs text-muted-foreground">
|
||||||
{currentIndex + 1} / {attachments.length}
|
{currentIndex + 1} / {attachments.length}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import { TransactionDetailsDialog } from "@/features/transactions/components/dia
|
|||||||
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||||
import type { TransactionItem } from "@/features/transactions/components/types";
|
import type { TransactionItem } from "@/features/transactions/components/types";
|
||||||
import { EmptyState } from "@/shared/components/empty-state";
|
import { EmptyState } from "@/shared/components/empty-state";
|
||||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
|
||||||
import PageDescription from "@/shared/components/page-description";
|
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
@@ -143,14 +141,6 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-6">
|
<div className="w-full space-y-6">
|
||||||
<PageDescription
|
|
||||||
icon={<RiAttachmentLine className="size-5" />}
|
|
||||||
title="Anexos"
|
|
||||||
subtitle="Comprovantes e documentos dos seus lançamentos no mês."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MonthNavigation />
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{attachments.length === 0 ? (
|
{attachments.length === 0 ? (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface AuthHeaderProps {
|
|||||||
export function AuthHeader({ title, description }: AuthHeaderProps) {
|
export function AuthHeader({ title, description }: AuthHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-2.5")}>
|
<div className={cn("flex flex-col gap-2.5")}>
|
||||||
<h1 className="text-2xl font-medium tracking-tight text-card-foreground">
|
<h1 className="text-2xl font-semibold tracking-tight text-card-foreground">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{description ? (
|
{description ? (
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function BudgetCard({
|
|||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-base font-medium leading-tight">
|
<h3 className="text-base font-semibold leading-tight">
|
||||||
{formatCategoryName(budget)}
|
{formatCategoryName(budget)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export function CalendarGrid({
|
|||||||
}: CalendarGridProps) {
|
}: CalendarGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
|
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
|
||||||
<div className="grid grid-cols-7 text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{WEEK_DAYS_SHORT.map((dayName) => (
|
{WEEK_DAYS_SHORT.map((dayName) => (
|
||||||
<span key={dayName} className="px-3 py-2 text-center text-primary">
|
<span key={dayName} className="px-3 py-2 text-center">
|
||||||
{dayName}
|
{dayName}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
<span className="text-sm font-medium leading-tight">
|
<span className="text-sm font-medium leading-tight">
|
||||||
Vencimento Invoice - {event.card.name}
|
Vencimento Fatura - {event.card.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function CardItem({
|
|||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<h3 className="truncate text-sm font-medium text-foreground sm:text-base">
|
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
{note ? (
|
{note ? (
|
||||||
@@ -206,29 +206,29 @@ export function CardItem({
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-semibold text-foreground">
|
||||||
<MoneyValues amount={metrics[0].value} />
|
<MoneyValues amount={metrics[0].value} />
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{metrics[0].label}
|
{metrics[0].label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||||
<span className="size-2 rounded-full bg-primary" />
|
<span className="size-2 rounded-full bg-primary" />
|
||||||
<MoneyValues amount={metrics[1].value} />
|
<MoneyValues amount={metrics[1].value} />
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{metrics[1].label}
|
{metrics[1].label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-semibold text-foreground">
|
||||||
<MoneyValues amount={metrics[2].value} />
|
<MoneyValues amount={metrics[2].value} />
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{metrics[2].label}
|
{metrics[2].label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
|
|||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<Link
|
<Link
|
||||||
href={`/categories/${category.id}`}
|
href={`/categories/${category.id}`}
|
||||||
className="inline-flex items-center gap-1 underline-offset-2 hover:text-primary hover:underline"
|
className="inline-flex items-center gap-1 underline-offset-2 hover:text-primary hover:underline font-semibold"
|
||||||
>
|
>
|
||||||
{category.name}
|
{category.name}
|
||||||
<RiExternalLinkLine
|
<RiExternalLinkLine
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function CategoryDetailHeader({
|
|||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-xl font-medium leading-tight">
|
<h1 className="text-xl font-semibold leading-tight">
|
||||||
{category.name}
|
{category.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
@@ -99,7 +99,7 @@ export function CategoryDetailHeader({
|
|||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Total em {currentPeriodLabel}
|
Total em {currentPeriodLabel}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-2xl font-medium">
|
<p className="mt-1 text-2xl font-semibold">
|
||||||
{currencyFormatter.format(currentTotal)}
|
{currencyFormatter.format(currentTotal)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +107,7 @@ export function CategoryDetailHeader({
|
|||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Total em {previousPeriodLabel}
|
Total em {previousPeriodLabel}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg font-medium text-muted-foreground">
|
<p className="mt-1 text-lg font-semibold text-muted-foreground">
|
||||||
{currencyFormatter.format(previousTotal)}
|
{currencyFormatter.format(previousTotal)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +117,7 @@ export function CategoryDetailHeader({
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 flex items-center gap-1 text-xl font-medium",
|
"mt-1 flex items-center gap-1 text-lg font-semibold",
|
||||||
variationColor,
|
variationColor,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function CategoryPickerDialog({
|
|||||||
<div className="flex max-h-96 flex-col gap-4 overflow-y-auto pr-1">
|
<div className="flex max-h-96 flex-col gap-4 overflow-y-auto pr-1">
|
||||||
{filteredGroups.map((group) => (
|
{filteredGroups.map((group) => (
|
||||||
<div key={group.label}>
|
<div key={group.label}>
|
||||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
<p className="mb-2 text-xs text-muted-foreground">
|
||||||
{group.label}
|
{group.label}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-8 gap-1.5">
|
<div className="grid grid-cols-8 gap-1.5">
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import {
|
|||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||||
INITIAL_BALANCE_NOTE,
|
INITIAL_BALANCE_NOTE,
|
||||||
} from "@/shared/lib/accounts/constants";
|
} from "@/shared/lib/accounts/constants";
|
||||||
import type { CategoryType } from "@/shared/lib/categories/constants";
|
import {
|
||||||
|
type CategoryType,
|
||||||
|
INVOICE_PAYMENT_CATEGORY_NAME,
|
||||||
|
} from "@/shared/lib/categories/constants";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
import { calculatePercentageChange } from "@/shared/utils/math";
|
import { calculatePercentageChange } from "@/shared/utils/math";
|
||||||
@@ -45,6 +48,7 @@ export async function fetchCategoryDetails(
|
|||||||
const previousPeriod = getPreviousPeriod(period);
|
const previousPeriod = getPreviousPeriod(period);
|
||||||
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
|
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
|
||||||
const adminPayerId = await getAdminPayerId(userId);
|
const adminPayerId = await getAdminPayerId(userId);
|
||||||
|
const isInvoiceCategory = category.name === INVOICE_PAYMENT_CATEGORY_NAME;
|
||||||
|
|
||||||
const sanitizedNote = or(
|
const sanitizedNote = or(
|
||||||
isNull(transactions.note),
|
isNull(transactions.note),
|
||||||
@@ -59,7 +63,7 @@ export async function fetchCategoryDetails(
|
|||||||
eq(transactions.transactionType, transactionType),
|
eq(transactions.transactionType, transactionType),
|
||||||
eq(transactions.period, period),
|
eq(transactions.period, period),
|
||||||
eq(transactions.payerId, adminPayerId),
|
eq(transactions.payerId, adminPayerId),
|
||||||
sanitizedNote,
|
...(isInvoiceCategory ? [] : [sanitizedNote]),
|
||||||
),
|
),
|
||||||
with: {
|
with: {
|
||||||
payer: true,
|
payer: true,
|
||||||
@@ -108,7 +112,7 @@ export async function fetchCategoryDetails(
|
|||||||
eq(transactions.categoryId, categoryId),
|
eq(transactions.categoryId, categoryId),
|
||||||
eq(transactions.transactionType, transactionType),
|
eq(transactions.transactionType, transactionType),
|
||||||
eq(transactions.payerId, adminPayerId),
|
eq(transactions.payerId, adminPayerId),
|
||||||
sanitizedNote,
|
...(isInvoiceCategory ? [] : [sanitizedNote]),
|
||||||
eq(transactions.period, previousPeriod),
|
eq(transactions.period, previousPeriod),
|
||||||
or(
|
or(
|
||||||
isNull(transactions.note),
|
isNull(transactions.note),
|
||||||
|
|||||||
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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-help rounded-full py-0.5",
|
"cursor-help rounded-full py-0.5",
|
||||||
bill.isSettled && "text-success",
|
bill.isSettled && "text-success font-semibold",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
@@ -60,7 +60,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full py-0.5",
|
"rounded-full py-0.5",
|
||||||
bill.isSettled && "text-success",
|
bill.isSettled && "text-success font-semibold",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
@@ -72,7 +72,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end">
|
<div className="flex shrink-0 flex-col items-end">
|
||||||
<MoneyValues amount={bill.amount} />
|
<MoneyValues className="font-medium" amount={bill.amount} />
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function BillPaymentDialog({
|
|||||||
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Boleto
|
Boleto
|
||||||
</p>
|
</p>
|
||||||
<p className="text-base font-medium text-foreground">
|
<p className="text-base font-semibold text-foreground">
|
||||||
{bill.name}
|
{bill.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +113,7 @@ export function BillPaymentDialog({
|
|||||||
</div>
|
</div>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={bill.amount}
|
amount={bill.amount}
|
||||||
className="text-lg font-medium"
|
className="text-lg font-semibold"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -281,12 +281,12 @@ export function CategoryBreakdownWidgetView({
|
|||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
className="text-foreground"
|
className="text-foreground font-medium"
|
||||||
amount={category.currentAmount}
|
amount={category.currentAmount}
|
||||||
/>
|
/>
|
||||||
{category.percentageChange !== null ? (
|
{category.percentageChange !== null ? (
|
||||||
<span
|
<span
|
||||||
className={`flex items-center gap-0.5 text-xs ${changeClassName}`}
|
className={`flex items-center gap-0.5 text-xs font-medium ${changeClassName}`}
|
||||||
>
|
>
|
||||||
{hasIncrease ? (
|
{hasIncrease ? (
|
||||||
<RiArrowUpSFill className="size-3" />
|
<RiArrowUpSFill className="size-3" />
|
||||||
|
|||||||
@@ -197,7 +197,9 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
|||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="text-foreground">{category.name}</span>
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -398,7 +400,7 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
|||||||
{config?.label}
|
{config?.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium tabular-nums">
|
<span className="text-xs font-medium">
|
||||||
{formatCurrency(value)}
|
{formatCurrency(value)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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,
|
type WidgetPreferences,
|
||||||
} from "@/features/dashboard/widgets/actions";
|
} from "@/features/dashboard/widgets/actions";
|
||||||
import {
|
import {
|
||||||
|
type DashboardWidgetQuickActionOptions,
|
||||||
type WidgetConfig,
|
type WidgetConfig,
|
||||||
widgetsConfig,
|
widgetsConfig,
|
||||||
} from "@/features/dashboard/widgets/widgets-config";
|
} from "@/features/dashboard/widgets/widgets-config";
|
||||||
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
import { NoteDialog } from "@/features/notes/components/note-dialog";
|
||||||
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||||
import type { SelectOption } from "@/features/transactions/components/types";
|
|
||||||
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
@@ -47,15 +47,7 @@ type DashboardGridEditableProps = {
|
|||||||
data: DashboardData;
|
data: DashboardData;
|
||||||
period: string;
|
period: string;
|
||||||
initialPreferences: WidgetPreferences | null;
|
initialPreferences: WidgetPreferences | null;
|
||||||
quickActionOptions: {
|
quickActionOptions: DashboardWidgetQuickActionOptions;
|
||||||
payerOptions: SelectOption[];
|
|
||||||
splitPayerOptions: SelectOption[];
|
|
||||||
defaultPayerId: string | null;
|
|
||||||
accountOptions: SelectOption[];
|
|
||||||
cardOptions: SelectOption[];
|
|
||||||
categoryOptions: SelectOption[];
|
|
||||||
estabelecimentos: string[];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
|
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
|
||||||
@@ -368,11 +360,16 @@ export function DashboardGridEditable({
|
|||||||
{widget.component({
|
{widget.component({
|
||||||
data,
|
data,
|
||||||
period,
|
period,
|
||||||
|
adminPayerSlug:
|
||||||
|
quickActionOptions.payerOptions.find(
|
||||||
|
(p) => p.value === quickActionOptions.defaultPayerId,
|
||||||
|
)?.slug ?? null,
|
||||||
widgetPreferences: {
|
widgetPreferences: {
|
||||||
order: widgetOrder,
|
order: widgetOrder,
|
||||||
hidden: hiddenWidgets,
|
hidden: hiddenWidgets,
|
||||||
myAccountsShowExcluded,
|
myAccountsShowExcluded,
|
||||||
},
|
},
|
||||||
|
quickActionOptions,
|
||||||
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
|
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
|
||||||
})}
|
})}
|
||||||
</ExpandableWidgetCard>
|
</ExpandableWidgetCard>
|
||||||
|
|||||||
@@ -116,13 +116,14 @@ const getPercentChange = (current: number, previous: number): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||||
return Number.isFinite(change) && Math.abs(change) < 1000000
|
if (!Number.isFinite(change)) return "—";
|
||||||
? formatPercentage(change, {
|
if (change > 999) return "+999%";
|
||||||
maximumFractionDigits: 1,
|
if (change < -999) return "-999%";
|
||||||
minimumFractionDigits: 1,
|
return formatPercentage(change, {
|
||||||
signDisplay: "always",
|
maximumFractionDigits: 2,
|
||||||
})
|
minimumFractionDigits: 2,
|
||||||
: "—";
|
signDisplay: "always",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTrendBadgeClass = (trend: Trend, invertTrend: boolean): string => {
|
const getTrendBadgeClass = (trend: Trend, invertTrend: boolean): string => {
|
||||||
@@ -159,7 +160,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="flex items-center gap-1.5 tracking-tight">
|
<CardTitle className="flex items-center gap-1.5 ">
|
||||||
<Icon className={cn("size-4", iconClass)} aria-hidden />
|
<Icon className={cn("size-4", iconClass)} aria-hidden />
|
||||||
{label}
|
{label}
|
||||||
<MetricsCardInfoButton
|
<MetricsCardInfoButton
|
||||||
@@ -179,12 +180,12 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
|||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
|
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
className="text-2xl leading-none"
|
className="text-2xl leading-none font-medium"
|
||||||
amount={metric.current}
|
amount={metric.current}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 text-xs ",
|
"inline-flex items-center gap-1 text-xs font-medium",
|
||||||
trendBadgeClass,
|
trendBadgeClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -195,7 +196,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
|||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
className="inline text-xs text-muted-foreground"
|
className="inline text-xs font-medium text-muted-foreground"
|
||||||
amount={metric.previous}
|
amount={metric.previous}
|
||||||
/>
|
/>
|
||||||
<span className="ml-1">no mês anterior</span>
|
<span className="ml-1">no mês anterior</span>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { formatCurrentDate, getGreeting } from "./welcome-widget";
|
import { formatCurrentDate, getGreeting } from "./welcome-widget";
|
||||||
|
|
||||||
export function DashboardWelcome({ name }: { name?: string | null }) {
|
type DashboardWelcomeProps = {
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardWelcome({ name }: DashboardWelcomeProps) {
|
||||||
const displayName = name && name.trim().length > 0 ? name : "Administrador";
|
const displayName = name && name.trim().length > 0 ? name : "Administrador";
|
||||||
const formattedDate = formatCurrentDate();
|
const formattedDate = formatCurrentDate();
|
||||||
const greeting = getGreeting();
|
const greeting = getGreeting();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-4">
|
<section className="py-4">
|
||||||
<div className="tracking-tight">
|
<div>
|
||||||
<h1 className="text-xl font-medium">
|
<h1 className="text-xl tracking-tight">
|
||||||
{greeting}, {displayName}
|
{greeting}, {displayName}
|
||||||
</h1>
|
</h1>
|
||||||
<h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2>
|
<h2 className="mt-1 text-sm text-muted-foreground">{formattedDate}</h2>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ export function GoalProgressItem({
|
|||||||
{item.categoryName}
|
{item.categoryName}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
<MoneyValues amount={item.spentAmount} /> de{" "}
|
<MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
|
||||||
<MoneyValues amount={item.budgetAmount} />
|
de{" "}
|
||||||
|
<MoneyValues className="font-medium" amount={item.budgetAmount} />
|
||||||
<span className={`ml-1.5 font-medium ${deltaColor}`}>
|
<span className={`ml-1.5 font-medium ${deltaColor}`}>
|
||||||
{formatGoalProgressPercentage(percentageDelta, true)}
|
{formatGoalProgressPercentage(percentageDelta, true)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
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 de resumo principal */}
|
||||||
<Card className="border-none bg-primary/15">
|
<Card className="border-none bg-primary/15">
|
||||||
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
|
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Se você pagar tudo que está selecionado:
|
Se você pagar tudo que está selecionado:
|
||||||
</p>
|
</p>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={grandTotal}
|
amount={grandTotal}
|
||||||
className="text-3xl font-medium text-primary"
|
className="text-3xl font-semibold text-primary"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
|
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
|
||||||
@@ -167,7 +167,7 @@ export function InstallmentAnalysisPage({
|
|||||||
|
|
||||||
{/* Seção de Lançamentos Parcelados */}
|
{/* Seção de Lançamentos Parcelados */}
|
||||||
{data.installmentGroups.length > 0 && (
|
{data.installmentGroups.length > 0 && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{data.installmentGroups.map((group) => (
|
{data.installmentGroups.map((group) => (
|
||||||
<InstallmentGroupCard
|
<InstallmentGroupCard
|
||||||
key={group.seriesId}
|
key={group.seriesId}
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RiArrowDownSLine,
|
RiBankCard2Line,
|
||||||
RiArrowRightSLine,
|
|
||||||
RiCheckboxCircleFill,
|
RiCheckboxCircleFill,
|
||||||
|
RiEyeLine,
|
||||||
|
RiTimeLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
import { Progress } from "@/shared/components/ui/progress";
|
import { Progress } from "@/shared/components/ui/progress";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils";
|
||||||
import type { InstallmentGroup } from "./types";
|
import type { InstallmentGroup } from "./types";
|
||||||
|
|
||||||
type InstallmentGroupCardProps = {
|
type InstallmentGroupCardProps = {
|
||||||
@@ -29,18 +45,22 @@ export function InstallmentGroupCard({
|
|||||||
onToggleGroup,
|
onToggleGroup,
|
||||||
onToggleInstallment,
|
onToggleInstallment,
|
||||||
}: InstallmentGroupCardProps) {
|
}: InstallmentGroupCardProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||||
|
|
||||||
const unpaidInstallments = group.pendingInstallments.filter(
|
const unpaidInstallments = group.pendingInstallments.filter(
|
||||||
(i) => !i.isSettled,
|
(i) => !i.isSettled,
|
||||||
);
|
);
|
||||||
|
const paidInstallments = group.pendingInstallments.filter((i) => i.isSettled);
|
||||||
const unpaidCount = unpaidInstallments.length;
|
const unpaidCount = unpaidInstallments.length;
|
||||||
|
|
||||||
const isFullySelected =
|
const isFullySelected =
|
||||||
selectedInstallments.size === unpaidInstallments.length &&
|
selectedInstallments.size === unpaidInstallments.length &&
|
||||||
unpaidInstallments.length > 0;
|
unpaidInstallments.length > 0;
|
||||||
|
|
||||||
|
const isPartiallySelected = selectedInstallments.size > 0 && !isFullySelected;
|
||||||
|
|
||||||
|
const hasSelection = selectedInstallments.size > 0;
|
||||||
|
|
||||||
const progress =
|
const progress =
|
||||||
group.totalInstallments > 0
|
group.totalInstallments > 0
|
||||||
? (group.paidInstallments / group.totalInstallments) * 100
|
? (group.paidInstallments / group.totalInstallments) * 100
|
||||||
@@ -50,186 +70,304 @@ export function InstallmentGroupCard({
|
|||||||
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
|
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
|
||||||
.reduce((sum, i) => sum + Number(i.amount), 0);
|
.reduce((sum, i) => sum + Number(i.amount), 0);
|
||||||
|
|
||||||
// Calcular valor total de todas as parcelas (pagas + pendentes)
|
|
||||||
const totalAmount = group.pendingInstallments.reduce(
|
const totalAmount = group.pendingInstallments.reduce(
|
||||||
(sum, i) => sum + i.amount,
|
(sum, i) => sum + i.amount,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calcular valor pendente (apenas não pagas)
|
|
||||||
const pendingAmount = unpaidInstallments.reduce(
|
const pendingAmount = unpaidInstallments.reduce(
|
||||||
(sum, i) => sum + i.amount,
|
(sum, i) => sum + i.amount,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn(isFullySelected && "border-primary/50")}>
|
<>
|
||||||
<CardContent className="flex flex-col gap-2">
|
<Card
|
||||||
{/* Header do card */}
|
className={cn(
|
||||||
<div className="flex items-start gap-3">
|
"overflow-hidden transition-all duration-300",
|
||||||
<Checkbox
|
isFullySelected && "ring-2 ring-primary/30 border-primary/50",
|
||||||
checked={isFullySelected}
|
isPartiallySelected && "border-primary/30",
|
||||||
onCheckedChange={onToggleGroup}
|
)}
|
||||||
className="mt-1"
|
>
|
||||||
aria-label={`Selecionar todas as parcelas de ${group.name}`}
|
{/* Header Section */}
|
||||||
/>
|
<CardHeader className="pb-0">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{/* Checkbox de seleção do grupo */}
|
||||||
|
<div className="pt-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
isFullySelected
|
||||||
|
? true
|
||||||
|
: isPartiallySelected
|
||||||
|
? "indeterminate"
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={onToggleGroup}
|
||||||
|
className="size-4"
|
||||||
|
aria-label={`Selecionar todas as parcelas de ${group.name}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
{/* Info principal */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex gap-1 items-center flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{group.cartaoLogo && (
|
{group.cartaoLogo ? (
|
||||||
<img
|
<Image
|
||||||
src={`/logos/${group.cartaoLogo}`}
|
src={`/logos/${group.cartaoLogo}`}
|
||||||
alt={group.cartaoName ?? "Cartão"}
|
alt={group.cartaoName ?? "Cartão"}
|
||||||
className="h-6 w-auto object-contain rounded"
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="size-10 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="size-10 flex items-center justify-center">
|
||||||
|
<RiBankCard2Line className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="font-medium truncate">{group.name}</span>
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-xs text-muted-foreground">
|
<CardTitle className="text-base truncate">
|
||||||
| {group.cartaoName}
|
{group.name}
|
||||||
</span>
|
</CardTitle>
|
||||||
</div>
|
<CardDescription className="text-xs">
|
||||||
|
{group.cartaoName ?? "Compra parcelada"}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
</CardDescription>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-xs text-muted-foreground">Total:</span>
|
|
||||||
<MoneyValues
|
|
||||||
amount={totalAmount}
|
|
||||||
className="text-base font-medium"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Pendente:
|
|
||||||
</span>
|
|
||||||
<MoneyValues
|
|
||||||
amount={pendingAmount}
|
|
||||||
className="text-sm font-medium text-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Badge de status */}
|
||||||
<div className="mt-3">
|
<Badge
|
||||||
<div className="mb-2 flex flex-wrap items-center px-1 justify-between gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
variant={progress === 100 ? "default" : "outline"}
|
||||||
|
className={cn("shrink-0", progress === 100 && "bg-success")}
|
||||||
|
>
|
||||||
|
{progress === 100 ? "Quitado" : `${Math.round(progress)}% pago`}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{/* Grid de valores */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-muted/50 mb-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground font-medium">
|
||||||
|
Valor total
|
||||||
|
</p>
|
||||||
|
<MoneyValues
|
||||||
|
amount={totalAmount}
|
||||||
|
className="text-lg font-semibold text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-right">
|
||||||
|
<p className="text-xs text-muted-foreground font-medium">
|
||||||
|
Pendente
|
||||||
|
</p>
|
||||||
|
<MoneyValues
|
||||||
|
amount={pendingAmount}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold",
|
||||||
|
pendingAmount > 0 ? "text-amber-600" : "text-success-600",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barra de progresso */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<RiCheckboxCircleFill className="size-3.5 text-success" />
|
||||||
<span>
|
<span>
|
||||||
{group.paidInstallments} de {group.totalInstallments} pagas
|
{group.paidInstallments} de {group.totalInstallments} parcelas
|
||||||
|
pagas
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
</div>
|
||||||
|
{unpaidCount > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<RiTimeLine className="size-3.5 text-amber-600" />
|
||||||
<span>
|
<span>
|
||||||
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
||||||
</span>
|
</span>
|
||||||
{selectedInstallments.size > 0 && (
|
|
||||||
<span className="text-primary font-medium">
|
|
||||||
• Selecionado:{" "}
|
|
||||||
<MoneyValues
|
|
||||||
amount={selectedAmount}
|
|
||||||
className="text-xs font-medium text-primary inline"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Progress value={progress} className="h-2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Botão de expandir */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<>
|
|
||||||
<RiArrowDownSLine className="size-4" />
|
|
||||||
Ocultar parcelas ({group.pendingInstallments.length})
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiArrowRightSLine className="size-4" />
|
|
||||||
Ver parcelas ({group.pendingInstallments.length})
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
|
<Progress value={progress} className="h-2.5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lista de parcelas expandida */}
|
{/* Valor selecionado */}
|
||||||
{isExpanded && (
|
{hasSelection && (
|
||||||
<div className="px-2 sm:px-8 mt-2 flex flex-col gap-2">
|
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/5 border border-primary/20 mb-4">
|
||||||
{group.pendingInstallments.map((installment) => {
|
<span className="text-sm font-medium text-foreground">
|
||||||
const isSelected = selectedInstallments.has(installment.id);
|
{selectedInstallments.size}{" "}
|
||||||
const isPaid = installment.isSettled;
|
{selectedInstallments.size === 1
|
||||||
const dueDate = installment.dueDate
|
? "parcela selecionada"
|
||||||
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR })
|
: "parcelas selecionadas"}
|
||||||
: format(installment.purchaseDate, "dd/MM/yyyy", {
|
</span>
|
||||||
locale: ptBR,
|
<MoneyValues
|
||||||
});
|
amount={selectedAmount}
|
||||||
|
className="text-base font-semibold text-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{/* Botão para abrir detalhes */}
|
||||||
<div
|
<Button
|
||||||
key={installment.id}
|
type="button"
|
||||||
className={cn(
|
variant="outline"
|
||||||
"flex items-center gap-3 rounded-md border p-2 transition-colors",
|
size="sm"
|
||||||
isSelected && !isPaid && "border-primary/50 bg-primary/5",
|
className="w-full gap-1.5"
|
||||||
isPaid &&
|
onClick={() => setIsDetailsOpen(true)}
|
||||||
"border-success/40 bg-success/5 dark:border-success/20 dark:bg-success/5",
|
>
|
||||||
)}
|
<RiEyeLine className="size-4" />
|
||||||
>
|
Ver detalhes ({group.pendingInstallments.length} parcelas)
|
||||||
<Checkbox
|
</Button>
|
||||||
checked={isPaid ? false : isSelected}
|
</CardContent>
|
||||||
disabled={isPaid}
|
</Card>
|
||||||
onCheckedChange={() =>
|
|
||||||
!isPaid && onToggleInstallment(installment.id)
|
|
||||||
}
|
|
||||||
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
{/* Modal de detalhes */}
|
||||||
<div className="min-w-0">
|
<Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
|
||||||
<p
|
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||||
className={cn(
|
<DialogHeader>
|
||||||
"text-xs font-medium",
|
<div className="flex items-center gap-3">
|
||||||
isPaid &&
|
{group.cartaoLogo ? (
|
||||||
"text-success line-through decoration-success/50",
|
<img
|
||||||
)}
|
src={`/logos/${group.cartaoLogo}`}
|
||||||
>
|
alt={group.cartaoName ?? "Cartão"}
|
||||||
Parcela {installment.currentInstallment}/
|
className="size-8 rounded-full object-cover"
|
||||||
{group.totalInstallments}
|
/>
|
||||||
{isPaid && (
|
) : (
|
||||||
<Badge
|
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
||||||
variant="outline"
|
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
||||||
className="ml-1 text-xs border-none text-success"
|
|
||||||
>
|
|
||||||
<RiCheckboxCircleFill /> Pago
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-xs mt-1",
|
|
||||||
isPaid ? "text-success" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Vencimento: {dueDate}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MoneyValues
|
|
||||||
amount={installment.amount}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 text-sm",
|
|
||||||
isPaid && "text-success",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
<DialogTitle className="text-base">{group.name}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Detalhes das parcelas do grupo {group.name}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 space-y-4 pr-1">
|
||||||
|
{/* Parcelas pagas */}
|
||||||
|
{paidInstallments.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Parcelas pagas
|
||||||
|
</p>
|
||||||
|
{paidInstallments.map((installment) => {
|
||||||
|
const dueDate = installment.dueDate
|
||||||
|
? format(installment.dueDate, "dd MMM yyyy", {
|
||||||
|
locale: ptBR,
|
||||||
|
})
|
||||||
|
: format(installment.purchaseDate, "dd MMM yyyy", {
|
||||||
|
locale: ptBR,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={installment.id}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg bg-success/5 dark:bg-success/10 border border-success/20 dark:border-success/10"
|
||||||
|
>
|
||||||
|
<div className="size-8 rounded-full flex items-center justify-center shrink-0">
|
||||||
|
<RiCheckboxCircleFill className="size-6 text-success" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-success">
|
||||||
|
Parcela {installment.currentInstallment}/
|
||||||
|
{group.totalInstallments}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-success/80">
|
||||||
|
Vencimento: {dueDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MoneyValues
|
||||||
|
amount={installment.amount}
|
||||||
|
className="text-sm font-semibold text-success shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Parcelas pendentes */}
|
||||||
|
{unpaidInstallments.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Parcelas pendentes
|
||||||
|
</p>
|
||||||
|
{unpaidInstallments.map((installment) => {
|
||||||
|
const isSelected = selectedInstallments.has(installment.id);
|
||||||
|
const dueDate = installment.dueDate
|
||||||
|
? format(installment.dueDate, "dd MMM yyyy", {
|
||||||
|
locale: ptBR,
|
||||||
|
})
|
||||||
|
: format(installment.purchaseDate, "dd MMM yyyy", {
|
||||||
|
locale: ptBR,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={installment.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all duration-200",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/5 border-primary/30 shadow-sm"
|
||||||
|
: "bg-card hover:bg-muted/50 hover:border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
onToggleInstallment(installment.id)
|
||||||
|
}
|
||||||
|
className="size-5"
|
||||||
|
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Parcela {installment.currentInstallment}/
|
||||||
|
{group.totalInstallments}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<RiTimeLine className="size-3 text-amber-600" />
|
||||||
|
Vencimento: {dueDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MoneyValues
|
||||||
|
amount={installment.amount}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-semibold shrink-0",
|
||||||
|
isSelected ? "text-primary" : "text-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</CardContent>
|
{/* Footer com resumo da seleção */}
|
||||||
</Card>
|
{hasSelection && (
|
||||||
|
<div className="border-t pt-3 mt-1 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{selectedInstallments.size}{" "}
|
||||||
|
{selectedInstallments.size === 1
|
||||||
|
? "parcela selecionada"
|
||||||
|
: "parcelas selecionadas"}
|
||||||
|
</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={selectedAmount}
|
||||||
|
className="text-base font-bold text-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ export function InstallmentExpenseListItem({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<MoneyValues amount={expense.amount} className="shrink-0" />
|
<MoneyValues
|
||||||
|
amount={expense.amount}
|
||||||
|
className="shrink-0 font-medium"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -67,7 +70,7 @@ export function InstallmentExpenseListItem({
|
|||||||
{" · Restante "}
|
{" · Restante "}
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={remainingAmount}
|
amount={remainingAmount}
|
||||||
className="inline-block font-medium"
|
className="inline-block font-semibold"
|
||||||
/>{" "}
|
/>{" "}
|
||||||
({remainingInstallments})
|
({remainingInstallments})
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground">
|
<div className="text-sm font-medium text-foreground">
|
||||||
<MoneyValues amount={share.amount} />
|
<MoneyValues
|
||||||
|
className="font-medium"
|
||||||
|
amount={share.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -144,7 +147,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
|||||||
paymentTooltipLabel ? (
|
paymentTooltipLabel ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="cursor-help text-success">
|
<span className="cursor-help text-success font-semibold">
|
||||||
{paymentInfo.label}
|
{paymentInfo.label}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -153,7 +156,9 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-success">{paymentInfo.label}</span>
|
<span className="text-success font-semibold">
|
||||||
|
{paymentInfo.label}
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +166,10 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function InvoicePaymentDialog({
|
|||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Cartão
|
Cartão
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-base font-medium text-foreground">
|
<p className="truncate text-base font-semibold text-foreground">
|
||||||
{invoice.cardName}
|
{invoice.cardName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +130,7 @@ export function InvoicePaymentDialog({
|
|||||||
</div>
|
</div>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={Math.abs(invoice.totalAmount)}
|
amount={Math.abs(invoice.totalAmount)}
|
||||||
className="text-lg font-medium"
|
className="text-lg font-semibold"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function MyAccountsWidget({
|
|||||||
<div className="flex items-start justify-between gap-3 py-1">
|
<div className="flex items-start justify-between gap-3 py-1">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm text-muted-foreground">Saldo Total</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{excludedAccountsCount > 0 ? (
|
{excludedAccountsCount > 0 ? (
|
||||||
@@ -137,7 +137,7 @@ export function MyAccountsWidget({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col">
|
<ul className="flex flex-col">
|
||||||
{displayedAccounts.map((account) => {
|
{displayedAccounts.map((account, index) => {
|
||||||
const logoSrc = resolveLogoSrc(account.logo);
|
const logoSrc = resolveLogoSrc(account.logo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -154,6 +154,7 @@ export function MyAccountsWidget({
|
|||||||
fill
|
fill
|
||||||
sizes="38px"
|
sizes="38px"
|
||||||
className="object-contain rounded-full"
|
className="object-contain rounded-full"
|
||||||
|
priority={index === 0}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +200,10 @@ export function MyAccountsWidget({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-0.5 text-right">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,10 +83,13 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end">
|
<div className="flex shrink-0 flex-col items-end">
|
||||||
<MoneyValues amount={payer.totalExpenses} />
|
<MoneyValues
|
||||||
|
className="font-medium"
|
||||||
|
amount={payer.totalExpenses}
|
||||||
|
/>
|
||||||
{percentageChange !== null && (
|
{percentageChange !== null && (
|
||||||
<span
|
<span
|
||||||
className={`flex items-center gap-0.5 text-xs ${
|
className={`flex items-center gap-0.5 text-xs font-medium ${
|
||||||
percentageChange > 0
|
percentageChange > 0
|
||||||
? "text-destructive"
|
? "text-destructive"
|
||||||
: percentageChange < 0
|
: percentageChange < 0
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import { PaymentOverviewWidgetView } from "./payment-overview/payment-overview-w
|
|||||||
type PaymentOverviewWidgetProps = {
|
type PaymentOverviewWidgetProps = {
|
||||||
paymentConditionsData: PaymentConditionsData;
|
paymentConditionsData: PaymentConditionsData;
|
||||||
paymentMethodsData: PaymentMethodsData;
|
paymentMethodsData: PaymentMethodsData;
|
||||||
|
period: string;
|
||||||
|
adminPayerSlug: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PaymentOverviewWidget({
|
export function PaymentOverviewWidget({
|
||||||
paymentConditionsData,
|
paymentConditionsData,
|
||||||
paymentMethodsData,
|
paymentMethodsData,
|
||||||
|
period,
|
||||||
|
adminPayerSlug,
|
||||||
}: PaymentOverviewWidgetProps) {
|
}: PaymentOverviewWidgetProps) {
|
||||||
const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
|
const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
|
||||||
|
|
||||||
@@ -22,6 +26,8 @@ export function PaymentOverviewWidget({
|
|||||||
paymentConditionsData={paymentConditionsData}
|
paymentConditionsData={paymentConditionsData}
|
||||||
paymentMethodsData={paymentMethodsData}
|
paymentMethodsData={paymentMethodsData}
|
||||||
onTabChange={handleTabChange}
|
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 type { ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
formatPaymentBreakdownPercentage,
|
formatPaymentBreakdownPercentage,
|
||||||
@@ -17,6 +19,7 @@ export type PaymentBreakdownListItemData = {
|
|||||||
amount: number;
|
amount: number;
|
||||||
transactions: number;
|
transactions: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
|
href?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PaymentBreakdownListItemProps = {
|
type PaymentBreakdownListItemProps = {
|
||||||
@@ -40,8 +43,21 @@ export function PaymentBreakdownListItem({
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
{item.href ? (
|
||||||
<MoneyValues amount={item.amount} />
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { RiCheckLine, RiSlideshowLine } from "@remixicon/react";
|
import { RiCheckLine, RiSlideshowLine } from "@remixicon/react";
|
||||||
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
|
||||||
import { getConditionIcon } from "@/shared/utils/icons";
|
import { getConditionIcon } from "@/shared/utils/icons";
|
||||||
|
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||||
|
import { slugify } from "@/shared/utils/string";
|
||||||
import {
|
import {
|
||||||
PaymentBreakdownList,
|
PaymentBreakdownList,
|
||||||
type PaymentBreakdownListItemData,
|
type PaymentBreakdownListItemData,
|
||||||
@@ -8,6 +10,8 @@ import {
|
|||||||
|
|
||||||
type PaymentConditionsWidgetProps = {
|
type PaymentConditionsWidgetProps = {
|
||||||
data: PaymentConditionsData;
|
data: PaymentConditionsData;
|
||||||
|
period: string;
|
||||||
|
adminPayerSlug: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveConditionIcon = (condition: string) =>
|
const resolveConditionIcon = (condition: string) =>
|
||||||
@@ -15,16 +19,27 @@ const resolveConditionIcon = (condition: string) =>
|
|||||||
|
|
||||||
export function PaymentConditionsWidget({
|
export function PaymentConditionsWidget({
|
||||||
data,
|
data,
|
||||||
|
period,
|
||||||
|
adminPayerSlug,
|
||||||
}: PaymentConditionsWidgetProps) {
|
}: PaymentConditionsWidgetProps) {
|
||||||
const items: PaymentBreakdownListItemData[] = data.conditions.map(
|
const items: PaymentBreakdownListItemData[] = data.conditions.map(
|
||||||
(condition) => ({
|
(condition) => {
|
||||||
id: condition.condition,
|
const params = new URLSearchParams({
|
||||||
title: condition.condition,
|
type: slugify("Despesa"),
|
||||||
icon: resolveConditionIcon(condition.condition),
|
condition: slugify(condition.condition),
|
||||||
amount: condition.amount,
|
periodo: formatPeriodForUrl(period),
|
||||||
transactions: condition.transactions,
|
});
|
||||||
percentage: condition.percentage,
|
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 (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
|
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
|
||||||
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
|
||||||
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||||
|
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||||
|
import { slugify } from "@/shared/utils/string";
|
||||||
import {
|
import {
|
||||||
PaymentBreakdownList,
|
PaymentBreakdownList,
|
||||||
type PaymentBreakdownListItemData,
|
type PaymentBreakdownListItemData,
|
||||||
@@ -8,6 +10,8 @@ import {
|
|||||||
|
|
||||||
type PaymentMethodsWidgetProps = {
|
type PaymentMethodsWidgetProps = {
|
||||||
data: PaymentMethodsData;
|
data: PaymentMethodsData;
|
||||||
|
period: string;
|
||||||
|
adminPayerSlug: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolvePaymentMethodIcon = (paymentMethod: string) =>
|
const resolvePaymentMethodIcon = (paymentMethod: string) =>
|
||||||
@@ -15,15 +19,28 @@ const resolvePaymentMethodIcon = (paymentMethod: string) =>
|
|||||||
<RiBankCard2Line className="size-5" aria-hidden />
|
<RiBankCard2Line className="size-5" aria-hidden />
|
||||||
);
|
);
|
||||||
|
|
||||||
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
|
export function PaymentMethodsWidget({
|
||||||
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => ({
|
data,
|
||||||
id: method.paymentMethod,
|
period,
|
||||||
title: method.paymentMethod,
|
adminPayerSlug,
|
||||||
icon: resolvePaymentMethodIcon(method.paymentMethod),
|
}: PaymentMethodsWidgetProps) {
|
||||||
amount: method.amount,
|
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => {
|
||||||
transactions: method.transactions,
|
const params = new URLSearchParams({
|
||||||
percentage: method.percentage,
|
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 (
|
return (
|
||||||
<PaymentBreakdownList
|
<PaymentBreakdownList
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type PaymentOverviewWidgetViewProps = {
|
|||||||
paymentConditionsData: PaymentConditionsData;
|
paymentConditionsData: PaymentConditionsData;
|
||||||
paymentMethodsData: PaymentMethodsData;
|
paymentMethodsData: PaymentMethodsData;
|
||||||
onTabChange: (value: string) => void;
|
onTabChange: (value: string) => void;
|
||||||
|
period: string;
|
||||||
|
adminPayerSlug: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PaymentOverviewWidgetView({
|
export function PaymentOverviewWidgetView({
|
||||||
@@ -23,6 +25,8 @@ export function PaymentOverviewWidgetView({
|
|||||||
paymentConditionsData,
|
paymentConditionsData,
|
||||||
paymentMethodsData,
|
paymentMethodsData,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
|
period,
|
||||||
|
adminPayerSlug,
|
||||||
}: PaymentOverviewWidgetViewProps) {
|
}: PaymentOverviewWidgetViewProps) {
|
||||||
return (
|
return (
|
||||||
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
|
||||||
@@ -38,11 +42,19 @@ export function PaymentOverviewWidgetView({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="conditions" className="mt-2">
|
<TabsContent value="conditions" className="mt-2">
|
||||||
<PaymentConditionsWidget data={paymentConditionsData} />
|
<PaymentConditionsWidget
|
||||||
|
data={paymentConditionsData}
|
||||||
|
period={period}
|
||||||
|
adminPayerSlug={adminPayerSlug}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="methods" className="mt-2">
|
<TabsContent value="methods" className="mt-2">
|
||||||
<PaymentMethodsWidget data={paymentMethodsData} />
|
<PaymentMethodsWidget
|
||||||
|
data={paymentMethodsData}
|
||||||
|
period={period}
|
||||||
|
adminPayerSlug={adminPayerSlug}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ export function PaymentStatusCategorySection({
|
|||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||||
<MoneyValues
|
<MoneyValues amount={total} className="font-medium" />
|
||||||
amount={total}
|
|
||||||
className="text-sm font-medium tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Progress value={confirmedPercentage} className="h-2" />
|
<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 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">
|
<div className="flex items-center gap-1.5">
|
||||||
<StatusDot color="bg-primary" />
|
<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>
|
<span className="text-xs text-muted-foreground">confirmados</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<StatusDot color="bg-warning/40" />
|
<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>
|
<span className="text-xs text-muted-foreground">pendentes</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -178,7 +178,10 @@ export function PurchasesByCategoryWidget({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 text-foreground">
|
<div className="shrink-0 text-foreground">
|
||||||
<MoneyValues amount={transaction.amount} />
|
<MoneyValues
|
||||||
|
className="font-medium"
|
||||||
|
amount={transaction.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function RecurringExpensesWidget({
|
|||||||
{expense.name}
|
{expense.name}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<MoneyValues amount={expense.amount} />
|
<MoneyValues className="font-medium" amount={expense.amount} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export function TopEstablishmentsWidget({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 text-foreground">
|
<div className="shrink-0 text-foreground">
|
||||||
<MoneyValues amount={establishment.amount} />
|
<MoneyValues
|
||||||
|
className="font-medium"
|
||||||
|
amount={establishment.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -113,7 +113,10 @@ export function TopExpensesWidget({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 text-foreground">
|
<div className="shrink-0 text-foreground">
|
||||||
<MoneyValues amount={expense.amount} />
|
<MoneyValues
|
||||||
|
className="font-medium"
|
||||||
|
amount={expense.amount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { cacheLife, cacheTag } from "next/cache";
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
|
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
|
||||||
import { fetchDashboardAccounts } from "./accounts-queries";
|
import { fetchDashboardAccounts } from "./accounts-queries";
|
||||||
import { fetchDashboardCategoryOverview } from "./category-overview-queries";
|
import { fetchDashboardCategoryOverview } from "./category-overview-queries";
|
||||||
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
|
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
|
||||||
|
import { fetchDashboardInboxSnapshot } from "./inbox-snapshot-queries";
|
||||||
import { fetchDashboardInvoices } from "./invoices-queries";
|
import { fetchDashboardInvoices } from "./invoices-queries";
|
||||||
import { fetchDashboardNotes } from "./notes-queries";
|
import { fetchDashboardNotes } from "./notes-queries";
|
||||||
import { fetchDashboardPayers } from "./payers-queries";
|
import { fetchDashboardPayers } from "./payers-queries";
|
||||||
@@ -16,6 +18,8 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
|||||||
categoryOverview,
|
categoryOverview,
|
||||||
pagadoresSnapshot,
|
pagadoresSnapshot,
|
||||||
notesData,
|
notesData,
|
||||||
|
allAttachments,
|
||||||
|
inboxSnapshot,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchDashboardPeriodOverview(userId, period),
|
fetchDashboardPeriodOverview(userId, period),
|
||||||
fetchDashboardAccounts(userId),
|
fetchDashboardAccounts(userId),
|
||||||
@@ -24,8 +28,27 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
|||||||
fetchDashboardCategoryOverview(userId, period),
|
fetchDashboardCategoryOverview(userId, period),
|
||||||
fetchDashboardPayers(userId, period),
|
fetchDashboardPayers(userId, period),
|
||||||
fetchDashboardNotes(userId),
|
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 {
|
return {
|
||||||
metrics: periodOverview.metrics,
|
metrics: periodOverview.metrics,
|
||||||
accountsSnapshot,
|
accountsSnapshot,
|
||||||
@@ -46,6 +69,8 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
|
|||||||
purchasesByCategoryData: currentPeriodOverview.purchasesByCategoryData,
|
purchasesByCategoryData: currentPeriodOverview.purchasesByCategoryData,
|
||||||
incomeByCategoryData: categoryOverview.incomeByCategoryData,
|
incomeByCategoryData: categoryOverview.incomeByCategoryData,
|
||||||
expensesByCategoryData: categoryOverview.expensesByCategoryData,
|
expensesByCategoryData: categoryOverview.expensesByCategoryData,
|
||||||
|
attachmentsSnapshot,
|
||||||
|
inboxSnapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
74
src/features/dashboard/inbox-snapshot-queries.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { and, count, desc, eq } from "drizzle-orm";
|
||||||
|
import { cacheLife, cacheTag } from "next/cache";
|
||||||
|
import { cards, financialAccounts, inboxItems } from "@/db/schema";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
|
||||||
|
export type DashboardInboxItem = {
|
||||||
|
id: string;
|
||||||
|
sourceAppName: string | null;
|
||||||
|
parsedName: string | null;
|
||||||
|
parsedAmount: string | null;
|
||||||
|
originalText: string;
|
||||||
|
notificationTimestamp: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardInboxSnapshot = {
|
||||||
|
pendingCount: number;
|
||||||
|
recentItems: DashboardInboxItem[];
|
||||||
|
logoMap: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchDashboardInboxSnapshot(
|
||||||
|
userId: string,
|
||||||
|
): Promise<DashboardInboxSnapshot> {
|
||||||
|
"use cache";
|
||||||
|
cacheTag(`dashboard-${userId}`);
|
||||||
|
cacheLife({ revalidate: 3 });
|
||||||
|
|
||||||
|
const [countRows, items, userCards, userAccounts] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(inboxItems)
|
||||||
|
.where(
|
||||||
|
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: inboxItems.id,
|
||||||
|
sourceAppName: inboxItems.sourceAppName,
|
||||||
|
parsedName: inboxItems.parsedName,
|
||||||
|
parsedAmount: inboxItems.parsedAmount,
|
||||||
|
originalText: inboxItems.originalText,
|
||||||
|
notificationTimestamp: inboxItems.notificationTimestamp,
|
||||||
|
createdAt: inboxItems.createdAt,
|
||||||
|
})
|
||||||
|
.from(inboxItems)
|
||||||
|
.where(
|
||||||
|
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
|
||||||
|
)
|
||||||
|
.orderBy(desc(inboxItems.notificationTimestamp))
|
||||||
|
.limit(10),
|
||||||
|
db
|
||||||
|
.select({ name: cards.name, logo: cards.logo })
|
||||||
|
.from(cards)
|
||||||
|
.where(eq(cards.userId, userId)),
|
||||||
|
db
|
||||||
|
.select({ name: financialAccounts.name, logo: financialAccounts.logo })
|
||||||
|
.from(financialAccounts)
|
||||||
|
.where(eq(financialAccounts.userId, userId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const logoMap: Record<string, string> = {};
|
||||||
|
for (const item of [...userCards, ...userAccounts]) {
|
||||||
|
if (item.logo) {
|
||||||
|
logoMap[item.name.toLowerCase()] = item.logo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingCount: Number(countRows[0]?.total ?? 0),
|
||||||
|
recentItems: items,
|
||||||
|
logoMap,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
RiArrowRightLine,
|
RiArrowRightLine,
|
||||||
RiArrowUpDoubleLine,
|
RiArrowUpDoubleLine,
|
||||||
|
RiAtLine,
|
||||||
|
RiAttachmentLine,
|
||||||
RiBarChartBoxLine,
|
RiBarChartBoxLine,
|
||||||
RiBarcodeLine,
|
RiBarcodeLine,
|
||||||
RiBillLine,
|
RiBillLine,
|
||||||
@@ -16,9 +18,12 @@ import {
|
|||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { AttachmentsWidget } from "@/features/dashboard/components/attachments-widget";
|
||||||
import { BillWidget } from "@/features/dashboard/components/bill-widget";
|
import { BillWidget } from "@/features/dashboard/components/bill-widget";
|
||||||
|
import { CategoryTrendsWidget } from "@/features/dashboard/components/category-trends-widget";
|
||||||
import { ExpensesByCategoryWidgetWithChart } from "@/features/dashboard/components/expenses-by-category-widget-with-chart";
|
import { ExpensesByCategoryWidgetWithChart } from "@/features/dashboard/components/expenses-by-category-widget-with-chart";
|
||||||
import { GoalsProgressWidget } from "@/features/dashboard/components/goals-progress-widget";
|
import { GoalsProgressWidget } from "@/features/dashboard/components/goals-progress-widget";
|
||||||
|
import { InboxWidget } from "@/features/dashboard/components/inbox-widget";
|
||||||
import { IncomeByCategoryWidgetWithChart } from "@/features/dashboard/components/income-by-category-widget-with-chart";
|
import { IncomeByCategoryWidgetWithChart } from "@/features/dashboard/components/income-by-category-widget-with-chart";
|
||||||
import { IncomeExpenseBalanceWidget } from "@/features/dashboard/components/income-expense-balance-widget";
|
import { IncomeExpenseBalanceWidget } from "@/features/dashboard/components/income-expense-balance-widget";
|
||||||
import { InstallmentExpensesWidget } from "@/features/dashboard/components/installment-expenses-widget";
|
import { InstallmentExpensesWidget } from "@/features/dashboard/components/installment-expenses-widget";
|
||||||
@@ -32,8 +37,19 @@ import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purch
|
|||||||
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
|
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
|
||||||
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget";
|
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget";
|
||||||
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
|
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
|
||||||
|
import type { SelectOption } from "@/features/transactions/components/types";
|
||||||
import type { DashboardData } from "../fetch-dashboard-data";
|
import type { DashboardData } from "../fetch-dashboard-data";
|
||||||
|
|
||||||
|
export type DashboardWidgetQuickActionOptions = {
|
||||||
|
payerOptions: SelectOption[];
|
||||||
|
splitPayerOptions: SelectOption[];
|
||||||
|
defaultPayerId: string | null;
|
||||||
|
accountOptions: SelectOption[];
|
||||||
|
cardOptions: SelectOption[];
|
||||||
|
categoryOptions: SelectOption[];
|
||||||
|
estabelecimentos: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type WidgetConfig = {
|
export type WidgetConfig = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -42,7 +58,9 @@ export type WidgetConfig = {
|
|||||||
component: (props: {
|
component: (props: {
|
||||||
data: DashboardData;
|
data: DashboardData;
|
||||||
period: string;
|
period: string;
|
||||||
|
adminPayerSlug: string | null;
|
||||||
widgetPreferences: WidgetPreferences;
|
widgetPreferences: WidgetPreferences;
|
||||||
|
quickActionOptions: DashboardWidgetQuickActionOptions;
|
||||||
onMyAccountsShowExcludedChange?: (value: boolean) => void;
|
onMyAccountsShowExcludedChange?: (value: boolean) => void;
|
||||||
}) => ReactNode;
|
}) => ReactNode;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
@@ -88,21 +106,149 @@ export const widgetsConfig: WidgetConfig[] = [
|
|||||||
{
|
{
|
||||||
id: "payment-status",
|
id: "payment-status",
|
||||||
title: "Status de Pagamento",
|
title: "Status de Pagamento",
|
||||||
subtitle: "Valores Confirmados E Pendentes",
|
subtitle: "Valores confirmados e pendentes",
|
||||||
icon: <RiWallet3Line className="size-4" />,
|
icon: <RiWallet3Line className="size-4" />,
|
||||||
component: ({ data }) => (
|
component: ({ data }) => (
|
||||||
<PaymentStatusWidget data={data.paymentStatusData} />
|
<PaymentStatusWidget data={data.paymentStatusData} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "inbox",
|
||||||
|
title: "Pré-lançamentos",
|
||||||
|
subtitle: "Notificações pendentes de revisão",
|
||||||
|
icon: <RiAtLine className="size-4" />,
|
||||||
|
component: ({ data, quickActionOptions }) => (
|
||||||
|
<InboxWidget
|
||||||
|
snapshot={data.inboxSnapshot}
|
||||||
|
quickActionOptions={quickActionOptions}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
action: (
|
||||||
|
<Link
|
||||||
|
href="/inbox"
|
||||||
|
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Revisar
|
||||||
|
<RiArrowRightLine className="size-4" />
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "income-expense-balance",
|
id: "income-expense-balance",
|
||||||
title: "Receita, Despesa e Balanço",
|
title: "Receita, Despesa e Balanço",
|
||||||
subtitle: "Últimos 6 Meses",
|
subtitle: "Últimos 6 meses",
|
||||||
icon: <RiLineChartLine className="size-4" />,
|
icon: <RiLineChartLine className="size-4" />,
|
||||||
component: ({ data }) => (
|
component: ({ data }) => (
|
||||||
<IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} />
|
<IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "goals-progress",
|
||||||
|
title: "Progresso de Orçamentos",
|
||||||
|
subtitle: "Orçamentos por categoria no período",
|
||||||
|
icon: <RiExchangeLine className="size-4" />,
|
||||||
|
component: ({ data }) => (
|
||||||
|
<GoalsProgressWidget data={data.goalsProgressData} />
|
||||||
|
),
|
||||||
|
action: (
|
||||||
|
<Link
|
||||||
|
href="/budgets"
|
||||||
|
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Ver todos
|
||||||
|
<RiArrowRightLine className="size-4" />
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "category-trends",
|
||||||
|
title: "Tendências de Categorias",
|
||||||
|
subtitle: "Top 10 maiores variações vs. mês anterior",
|
||||||
|
icon: <RiLineChartLine className="size-4" />,
|
||||||
|
component: ({ data }) => (
|
||||||
|
<CategoryTrendsWidget
|
||||||
|
categories={data.expensesByCategoryData.categories}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "spending-overview",
|
||||||
|
title: "Panorama de Gastos",
|
||||||
|
subtitle: "Principais despesas e frequência por local",
|
||||||
|
icon: <RiArrowUpDoubleLine className="size-4" />,
|
||||||
|
component: ({ data }) => (
|
||||||
|
<SpendingOverviewWidget
|
||||||
|
topExpensesAll={data.topExpensesAll}
|
||||||
|
topExpensesCardOnly={data.topExpensesCardOnly}
|
||||||
|
topEstablishmentsData={data.topEstablishmentsData}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "payment-overview",
|
||||||
|
title: "Comportamento de Pagamento",
|
||||||
|
subtitle: "Despesas por condição e forma de pagamento",
|
||||||
|
icon: <RiWallet3Line className="size-4" />,
|
||||||
|
component: ({ data, period, adminPayerSlug }) => (
|
||||||
|
<PaymentOverviewWidget
|
||||||
|
paymentConditionsData={data.paymentConditionsData}
|
||||||
|
paymentMethodsData={data.paymentMethodsData}
|
||||||
|
period={period}
|
||||||
|
adminPayerSlug={adminPayerSlug}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "expenses-by-category",
|
||||||
|
title: "Categorias por Despesas",
|
||||||
|
subtitle: "Distribuição de despesas por categoria",
|
||||||
|
icon: <RiPieChartLine className="size-4" />,
|
||||||
|
component: ({ data, period }) => (
|
||||||
|
<ExpensesByCategoryWidgetWithChart
|
||||||
|
data={data.expensesByCategoryData}
|
||||||
|
period={period}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "income-by-category",
|
||||||
|
title: "Categorias por Receitas",
|
||||||
|
subtitle: "Distribuição de receitas por categoria",
|
||||||
|
icon: <RiPieChartLine className="size-4" />,
|
||||||
|
component: ({ data, period }) => (
|
||||||
|
<IncomeByCategoryWidgetWithChart
|
||||||
|
data={data.incomeByCategoryData}
|
||||||
|
period={period}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "purchases-by-category",
|
||||||
|
title: "Lançamentos por Categorias",
|
||||||
|
subtitle: "Distribuição de lançamentos por categoria",
|
||||||
|
icon: <RiStore3Line className="size-4" />,
|
||||||
|
component: ({ data }) => (
|
||||||
|
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "recurring-expenses",
|
||||||
|
title: "Lançamentos Recorrentes",
|
||||||
|
subtitle: "Despesas recorrentes do período",
|
||||||
|
icon: <RiRefreshLine className="size-4" />,
|
||||||
|
component: ({ data }) => (
|
||||||
|
<RecurringExpensesWidget data={data.recurringExpensesData} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "installment-expenses",
|
||||||
|
title: "Lançamentos Parcelados",
|
||||||
|
subtitle: "Acompanhe as parcelas abertas",
|
||||||
|
icon: <RiNumbersLine className="size-4" />,
|
||||||
|
component: ({ data }) => (
|
||||||
|
<InstallmentExpensesWidget data={data.installmentExpensesData} />
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "pagadores",
|
id: "pagadores",
|
||||||
title: "Pagadores",
|
title: "Pagadores",
|
||||||
@@ -138,16 +284,16 @@ export const widgetsConfig: WidgetConfig[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "goals-progress",
|
id: "attachments",
|
||||||
title: "Progresso de Orçamentos",
|
title: "Anexos",
|
||||||
subtitle: "Orçamentos por categoria no período",
|
subtitle: "Comprovantes do período",
|
||||||
icon: <RiExchangeLine className="size-4" />,
|
icon: <RiAttachmentLine className="size-4" />,
|
||||||
component: ({ data }) => (
|
component: ({ data }) => (
|
||||||
<GoalsProgressWidget data={data.goalsProgressData} />
|
<AttachmentsWidget snapshot={data.attachmentsSnapshot} />
|
||||||
),
|
),
|
||||||
action: (
|
action: (
|
||||||
<Link
|
<Link
|
||||||
href="/budgets"
|
href="/attachments"
|
||||||
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Ver todos
|
Ver todos
|
||||||
@@ -155,80 +301,4 @@ export const widgetsConfig: WidgetConfig[] = [
|
|||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "payment-overview",
|
|
||||||
title: "Comportamento de Pagamento",
|
|
||||||
subtitle: "Despesas por condição e forma de pagamento",
|
|
||||||
icon: <RiWallet3Line className="size-4" />,
|
|
||||||
component: ({ data }) => (
|
|
||||||
<PaymentOverviewWidget
|
|
||||||
paymentConditionsData={data.paymentConditionsData}
|
|
||||||
paymentMethodsData={data.paymentMethodsData}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "recurring-expenses",
|
|
||||||
title: "Lançamentos Recorrentes",
|
|
||||||
subtitle: "Despesas recorrentes do período",
|
|
||||||
icon: <RiRefreshLine className="size-4" />,
|
|
||||||
component: ({ data }) => (
|
|
||||||
<RecurringExpensesWidget data={data.recurringExpensesData} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "installment-expenses",
|
|
||||||
title: "Lançamentos Parcelados",
|
|
||||||
subtitle: "Acompanhe as parcelas abertas",
|
|
||||||
icon: <RiNumbersLine className="size-4" />,
|
|
||||||
component: ({ data }) => (
|
|
||||||
<InstallmentExpensesWidget data={data.installmentExpensesData} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "spending-overview",
|
|
||||||
title: "Panorama de Gastos",
|
|
||||||
subtitle: "Principais despesas e frequência por local",
|
|
||||||
icon: <RiArrowUpDoubleLine className="size-4" />,
|
|
||||||
component: ({ data }) => (
|
|
||||||
<SpendingOverviewWidget
|
|
||||||
topExpensesAll={data.topExpensesAll}
|
|
||||||
topExpensesCardOnly={data.topExpensesCardOnly}
|
|
||||||
topEstablishmentsData={data.topEstablishmentsData}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "purchases-by-category",
|
|
||||||
title: "Lançamentos por Categorias",
|
|
||||||
subtitle: "Distribuição de lançamentos por categoria",
|
|
||||||
icon: <RiStore3Line className="size-4" />,
|
|
||||||
component: ({ data }) => (
|
|
||||||
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "income-by-category",
|
|
||||||
title: "Categorias por Receitas",
|
|
||||||
subtitle: "Distribuição de receitas por categoria",
|
|
||||||
icon: <RiPieChartLine className="size-4" />,
|
|
||||||
component: ({ data, period }) => (
|
|
||||||
<IncomeByCategoryWidgetWithChart
|
|
||||||
data={data.incomeByCategoryData}
|
|
||||||
period={period}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "expenses-by-category",
|
|
||||||
title: "Categorias por Despesas",
|
|
||||||
subtitle: "Distribuição de despesas por categoria",
|
|
||||||
icon: <RiPieChartLine className="size-4" />,
|
|
||||||
component: ({ data, period }) => (
|
|
||||||
<ExpensesByCategoryWidgetWithChart
|
|
||||||
data={data.expensesByCategoryData}
|
|
||||||
period={period}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -104,11 +104,11 @@ export const InboxCard = memo(function InboxCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`flex h-54 flex-col gap-0 py-0 transition-colors ${selected ? "ring-2 ring-primary" : ""}`}
|
className={`flex h-54 flex-col gap-0 py-0 transition-colors ${selected ? "ring-2 ring-primary/30" : ""}`}
|
||||||
>
|
>
|
||||||
<CardHeader className="pt-4">
|
<CardHeader className="pt-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex min-w-0 items-center gap-1.5 text-sm">
|
<CardTitle className="flex min-w-0 items-center gap-3 text-sm">
|
||||||
{onSelectToggle && (
|
{onSelectToggle && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={!!selected}
|
checked={!!selected}
|
||||||
@@ -117,29 +117,34 @@ export const InboxCard = memo(function InboxCard({
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="relative size-8 shrink-0 overflow-hidden rounded-full">
|
<div className="relative shrink-0 overflow-hidden ">
|
||||||
<Image
|
<Image
|
||||||
src={displayLogo}
|
src={displayLogo}
|
||||||
alt=""
|
alt={item.sourceAppName || item.sourceApp}
|
||||||
fill
|
width={40}
|
||||||
sizes="32px"
|
height={40}
|
||||||
className="object-cover"
|
className="size-10 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate">
|
<div className="flex min-w-0 flex-col">
|
||||||
{item.sourceAppName || item.sourceApp}
|
<span className="truncate font-semibold text-base">
|
||||||
</span>
|
{item.sourceAppName || item.sourceApp}
|
||||||
<Tooltip>
|
</span>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<span className="shrink-0 cursor-default text-xs font-normal text-muted-foreground underline decoration-dotted underline-offset-2">
|
<TooltipTrigger asChild>
|
||||||
{timeAgo}
|
<span className="cursor-default text-xs text-muted-foreground underline decoration-dotted underline-offset-2">
|
||||||
</span>
|
{timeAgo}
|
||||||
</TooltipTrigger>
|
</span>
|
||||||
<TooltipContent>{fullDate}</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent>{fullDate}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{amount !== null && (
|
{amount !== null && (
|
||||||
<MoneyValues amount={amount} className="shrink-0 text-sm" />
|
<MoneyValues
|
||||||
|
amount={amount}
|
||||||
|
className="shrink-0 text-base font-semibold"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||