Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1161e97d9e | ||
|
|
55d7dedd9a | ||
|
|
ad2752b7b0 | ||
|
|
58db357cde | ||
|
|
99a9ff5512 | ||
|
|
5bcf4f69d3 | ||
|
|
95099c1a94 | ||
|
|
94912f7edc | ||
|
|
bf6adfa3f1 | ||
|
|
e4b9dd4254 | ||
|
|
f1907c8697 | ||
|
|
805bcb863d | ||
|
|
11b4f8940f | ||
|
|
fba9686fdb | ||
|
|
9b8ac9f71f | ||
|
|
fa41c78a39 | ||
|
|
5f7bfb98da | ||
|
|
9ecafdb15f | ||
|
|
e8cc673e52 | ||
|
|
3bd8117b65 | ||
|
|
a7268d8f05 | ||
|
|
1f9098879e | ||
|
|
7a3bff52ac | ||
|
|
dfb4126b12 | ||
|
|
ffead579fa | ||
|
|
aa85cf8b29 |
@@ -44,6 +44,12 @@ GOOGLE_CLIENT_SECRET=
|
|||||||
# Se não definido, todas as rotas ficam acessíveis.
|
# Se não definido, todas as rotas ficam acessíveis.
|
||||||
# PUBLIC_DOMAIN=openmonetis.com
|
# PUBLIC_DOMAIN=openmonetis.com
|
||||||
|
|
||||||
|
# === Analytics (Opcional) ===
|
||||||
|
# Umami: https://umami.is — self-hosted ou cloud
|
||||||
|
UMAMI_URL=
|
||||||
|
UMAMI_WEBSITE_ID=
|
||||||
|
UMAMI_DOMAINS=
|
||||||
|
|
||||||
# === AI Providers (Opcional) ===
|
# === AI Providers (Opcional) ===
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|||||||
4
.gitattributes
vendored
Normal file
@@ -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
|
||||||
45
CHANGELOG.md
@@ -7,6 +7,51 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.3.8] - 2026-04-12
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Docker: `docker-compose.yml` refatorado — removidos profiles, build e dependência de arquivo externo; compose agora é standalone (basta `curl` + `docker compose up -d`)
|
||||||
|
- Docker: `docker-entrypoint.sh` simplificado — extensão `pgcrypto` criada via Node.js antes das migrations; loop de retry reescrito; removido hack `@localhost → @db`
|
||||||
|
- Docker: scripts reduzidos de 10 para 5 — `docker:up`, `docker:db`, `docker:down`, `docker:logs`, `docker:update`
|
||||||
|
- Docs: README reestruturado em dois perfis claros — **Usar** (só Docker) e **Desenvolver** (hot-reload)
|
||||||
|
|
||||||
|
## [2.3.7] - 2026-04-11
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Dashboard: novos widgets configuráveis — Anexos (resumo de arquivos do período), Inbox (snapshot de pré-lançamentos pendentes) e Tendências de Categoria
|
||||||
|
- Lançamentos: filtro por status de pagamento (somente pagos / somente não pagos) e filtro por presença de anexo
|
||||||
|
- Lançamentos: indicador visual no status de liquidação para lançamentos de cartão de crédito com fatura paga — exibe ícone verde com tooltip explicativo
|
||||||
|
- Scripts: `scripts/install-deps.sh` — script de preparação para servidores Ubuntu 24.04 limpos (instala Docker, Node.js 22, pnpm via Homebrew)
|
||||||
|
- Docker: variáveis `PUBLIC_DOMAIN`, `UMAMI_URL`, `UMAMI_WEBSITE_ID` e `UMAMI_DOMAINS` passadas ao container da aplicação no `docker-compose.yml`
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- Fonte: substituída fonte local `America` por `Inter` (Google Fonts, self-hosted pelo Next.js) — elimina arquivos `.woff2` do repositório
|
||||||
|
- Tipografia: peso tipográfico padronizado de `font-medium` para `font-semibold` em títulos, rótulos e valores monetários em toda a interface
|
||||||
|
- Parcelas: redesenho do card de grupo de parcelas — expandindo para dialog de detalhes com parcelas pagas/pendentes separadas
|
||||||
|
- Inbox: redesenho do card de pré-lançamento — logo maior, hierarquia tipográfica melhorada
|
||||||
|
- Lançamentos: filtros de tipo, condição e forma de pagamento agora usam slugs em URL (ex: `receita` em vez do valor literal com acentos)
|
||||||
|
- Estabelecimento: popover de autocomplete agora respeita a largura do input ao abrir
|
||||||
|
- CSP: adicionado `frame-src` para permitir preview de anexos PDF via S3
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Docker: corrigido crash loop no container com mensagem `exec /app/docker-entrypoint.sh: no such file or directory` causado por CRLF no `docker-entrypoint.sh` em ambientes Windows/WSL2 — adicionado `sed -i 's/\r$//'` no Dockerfile e `.gitattributes` com `eol=lf` para scripts shell
|
||||||
|
- S3: corrigido `Error: Region is missing` ao usar o app sem S3 configurado — `S3_REGION` vazio (string vazia) não era tratado pelo operador `??`; substituído por `||` em todo o `s3-client.ts`
|
||||||
|
- i18n: corrigidas mensagens de erro que exibiam "Payer" em inglês em vez de "Pagador"
|
||||||
|
- Logos: corrigido modal seletor de logos de cartões e contas para renderizar miniaturas sem avisos de proporção
|
||||||
|
- Scripts: `install-deps.sh` — spinner travava o script por `wait` retornar código não-zero com `set -e` ativo; corrigido com `|| true`
|
||||||
|
- Scripts: `install-deps.sh` — prompt interativo do corepack suprimido com `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`
|
||||||
|
- Scripts: `install-deps.sh` — PATH do Homebrew não estava configurado na seção de resumo
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- Scripts: removidos arquivos órfãos `scripts/dev.ts` e `scripts/setup-env.sh` (substituídos pelo `setup.mjs`)
|
||||||
|
- Docker: `docker-compose.yml` agora funciona sem arquivo `.env` — `DATABASE_URL` tem valor padrão com credenciais de desenvolvimento
|
||||||
|
- Docker: `docker-entrypoint.sh` converte automaticamente `@localhost:` para `@db:` na `DATABASE_URL` ao iniciar o container, eliminando a necessidade de usar hosts diferentes no `.env` para desenvolvimento local e Docker
|
||||||
|
|
||||||
## [2.3.6] - 2026-04-09
|
## [2.3.6] - 2026-04-09
|
||||||
|
|
||||||
### 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 \
|
||||||
|
|||||||
238
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)
|
- [Como rodar o OpenMonetis](#-como-rodar-o-openmonetis)
|
||||||
- [Início Rápido (manual)](#-início-rápido)
|
- [Perfil 1 — Usar](#perfil-1--usar-self-hosting)
|
||||||
|
- [Perfil 2 — Desenvolver](#perfil-2--desenvolver)
|
||||||
- [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.
|
||||||
|
|
||||||
@@ -93,82 +103,87 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚡ Instalação via Script
|
## 🚀 Como rodar o OpenMonetis
|
||||||
|
|
||||||
A forma mais rápida de instalar. O script verifica dependências, configura o `.env` interativamente e sobe o banco automaticamente.
|
Escolha o perfil que corresponde ao seu objetivo:
|
||||||
|
|
||||||
**Pré-requisito:** Node.js 22+
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Mac / Linux / WSL
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs -o setup.mjs && node setup.mjs
|
|
||||||
|
|
||||||
# Windows (PowerShell)
|
|
||||||
curl -o setup.mjs https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs ; node setup.mjs
|
|
||||||
```
|
|
||||||
|
|
||||||
O script irá:
|
|
||||||
- Verificar Node, pnpm, Git e Docker
|
|
||||||
- Perguntar se quer banco local (Docker) ou remoto (Supabase, Neon, etc.)
|
|
||||||
- Gerar o `BETTER_AUTH_SECRET` automaticamente
|
|
||||||
- Configurar opcionais: Google OAuth, e-mail, IA, domínio público
|
|
||||||
- Clonar o repositório, instalar dependências e aplicar o schema
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Início Rápido (manual)
|
### Perfil 1 — Usar (self-hosting)
|
||||||
|
|
||||||
### Pré-requisitos
|
Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar Node.js** — apenas Docker.
|
||||||
|
|
||||||
- Node.js 22+ e pnpm
|
|
||||||
- Docker e Docker Compose
|
|
||||||
|
|
||||||
### Passo a Passo
|
|
||||||
|
|
||||||
1. **Clone e instale**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. Baixe o compose
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||||
|
|
||||||
|
# 2. Suba tudo
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Acesse em: `http://localhost:3000`
|
||||||
|
|
||||||
|
O banco sobe com credenciais padrão. Para personalizar (senha, Google OAuth, e-mail, IA...), crie um `.env` na mesma pasta **antes** de subir:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env mínimo recomendado para produção
|
||||||
|
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
|
||||||
|
BETTER_AUTH_URL=https://seu-dominio.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Veja todas as variáveis disponíveis em [Variáveis de Ambiente](#-variáveis-de-ambiente).
|
||||||
|
|
||||||
|
**Banco remoto (Supabase, Neon, Railway...):** defina `DATABASE_URL` no `.env` e suba só o app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d app
|
||||||
|
```
|
||||||
|
|
||||||
|
**Não tem Docker instalado?** Em servidores Ubuntu 24.04 limpos, use o script de instalação:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
|
||||||
|
sudo sh install-deps.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> Ao final, faça **logout e login** para as permissões do grupo `docker` terem efeito.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Perfil 2 — Desenvolver
|
||||||
|
|
||||||
|
Quer modificar o código com hot-reload. O banco roda no Docker, o app roda direto no seu servidor.
|
||||||
|
|
||||||
|
**Requisitos:** Docker + Node.js 22+ + pnpm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone o repositório
|
||||||
git clone https://github.com/felipegcoutinho/openmonetis.git
|
git clone https://github.com/felipegcoutinho/openmonetis.git
|
||||||
cd openmonetis
|
cd openmonetis
|
||||||
|
|
||||||
|
# 2. Instale as dependências
|
||||||
pnpm install
|
pnpm install
|
||||||
```
|
|
||||||
|
|
||||||
2. **Configure o `.env`**
|
# 3. Configure o ambiente
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
# Edite o .env com suas configurações
|
||||||
|
|
||||||
Edite o `.env` com suas credenciais. O principal é o `DATABASE_URL` e o `BETTER_AUTH_SECRET`:
|
# 4. Suba o banco
|
||||||
|
pnpm docker:db
|
||||||
|
|
||||||
```env
|
# 5. Habilite extensões do PostgreSQL (apenas no primeiro setup)
|
||||||
# Banco local (Docker): use host "localhost"
|
|
||||||
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
|
||||||
|
|
||||||
# Banco remoto (Supabase, Neon, etc): use a URL completa do provider
|
|
||||||
# DATABASE_URL=postgresql://user:password@host:5432/database?sslmode=require
|
|
||||||
|
|
||||||
BETTER_AUTH_SECRET=seu-secret-aqui # gere com: openssl rand -base64 32
|
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Suba o banco de dados** (pule se estiver usando banco remoto)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up db -d
|
|
||||||
pnpm db:extensions
|
pnpm db:extensions
|
||||||
```
|
|
||||||
|
|
||||||
4. **Execute as migrations e inicie**
|
# 6. Aplique o schema no banco (apenas no primeiro setup)
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm db:push
|
pnpm db:push
|
||||||
|
|
||||||
|
# 7. Inicie o app com hot-reload
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Acesse `http://localhost:3000`
|
Acesse em: `http://localhost:3000`
|
||||||
|
|
||||||
> **Docker completo** (app + banco em containers): use `pnpm docker:up` ao invés dos passos 3-4.
|
Toda vez que salvar um arquivo, o app atualiza automaticamente sem precisar reiniciar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -190,35 +205,50 @@ 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 # Sobe app (Docker Hub) + banco em background
|
||||||
pnpm docker:up:d # Subir em background
|
pnpm docker:db # Sobe apenas o banco em background (usar com pnpm dev)
|
||||||
pnpm docker:up:db # Subir apenas o banco
|
pnpm docker:down # Para e remove os containers
|
||||||
pnpm docker:down # Parar containers
|
pnpm docker:logs # Logs em tempo real (todos os containers)
|
||||||
pnpm docker:down:volumes # Parar e remover volumes (⚠️ apaga dados!)
|
pnpm docker:update # Atualiza para a imagem mais recente do Hub e reinicia
|
||||||
pnpm docker:logs # Logs em tempo real
|
|
||||||
pnpm docker:restart # Reiniciar
|
|
||||||
pnpm docker:rebuild # Rebuild completo
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐳 Docker
|
## 🐳 Docker
|
||||||
|
|
||||||
O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root.
|
O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root. Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
|
||||||
|
|
||||||
Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
|
### Self-hosting (recomendado)
|
||||||
|
|
||||||
|
Baixe apenas o `docker-compose.yml` e suba tudo — sem clonar o repositório, sem instalar dependências:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
As credenciais padrão do banco já estão configuradas. Para personalizar (senhas, opcionais), crie um `.env` na mesma pasta antes de subir — veja [Variáveis de Ambiente](#-variáveis-de-ambiente).
|
||||||
|
|
||||||
|
### Banco remoto (Supabase, Neon, Railway...)
|
||||||
|
|
||||||
|
Suba apenas o app e aponte para o banco externo via `DATABASE_URL` no `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d app
|
||||||
|
```
|
||||||
|
|
||||||
### Comandos úteis
|
### Comandos úteis
|
||||||
|
|
||||||
@@ -230,7 +260,7 @@ docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Bac
|
|||||||
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
|
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
|
||||||
```
|
```
|
||||||
|
|
||||||
### Customizando Portas
|
### Customizando portas
|
||||||
|
|
||||||
```env
|
```env
|
||||||
APP_PORT=3001 # Padrão: 3000
|
APP_PORT=3001 # Padrão: 3000
|
||||||
@@ -239,6 +269,68 @@ DB_PORT=5433 # Padrão: 5432
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 💾 Backup
|
||||||
|
|
||||||
|
O backup é uma rotina de infraestrutura — não é uma tela no app. Ele opera diretamente sobre o banco PostgreSQL e é executado via linha de comando.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm backup
|
||||||
|
```
|
||||||
|
|
||||||
|
### O que é salvo
|
||||||
|
|
||||||
|
Cada execução gera **3 arquivos** em `backup/`:
|
||||||
|
|
||||||
|
| Arquivo | Conteúdo | Uso |
|
||||||
|
|---|---|---|
|
||||||
|
| `openmonetis_YYYY-MM-DD_HH-MM.dump` | Dump custom do PostgreSQL (binário) | Restore completo via `pg_restore` |
|
||||||
|
| `openmonetis_YYYY-MM-DD_HH-MM.sql.gz` | Dump SQL completo compactado | Inspeção manual, portabilidade |
|
||||||
|
| `openmonetis_YYYY-MM-DD_HH-MM.data.sql.gz` | Apenas os dados da schema `public` | Migração parcial, seed de outro ambiente |
|
||||||
|
|
||||||
|
### Modos de conexão
|
||||||
|
|
||||||
|
Configure `DB_MODE` no topo de `scripts/backup.sh`:
|
||||||
|
|
||||||
|
| Modo | Quando usar | Fonte de dados |
|
||||||
|
|---|---|---|
|
||||||
|
| `remote` (padrão) | Banco em Supabase, Neon, Railway, etc. | `DATABASE_URL` do `.env` |
|
||||||
|
| `docker` | Banco no container local | Container `openmonetis_postgres` |
|
||||||
|
|
||||||
|
### Upload para Google Drive (opcional)
|
||||||
|
|
||||||
|
Se o [rclone](https://rclone.org/) estiver instalado e configurado com um remote chamado `gdrive`, os arquivos são enviados automaticamente para `gdrive:BACKUP OPENMONETIS`. Sem o rclone, o backup funciona normalmente e fica apenas local.
|
||||||
|
|
||||||
|
**Retenção:**
|
||||||
|
- Local: 7 dias
|
||||||
|
- Google Drive: 30 dias
|
||||||
|
|
||||||
|
### Automatizar com cron
|
||||||
|
|
||||||
|
Para rodar o backup automaticamente todo dia às 3h:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
```cron
|
||||||
|
0 3 * * * cd /caminho/para/openmonetis && pnpm backup >> /var/log/openmonetis-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# A partir do .dump (recomendado — mais rápido)
|
||||||
|
pg_restore --clean --no-owner --no-privileges \
|
||||||
|
-d "postgresql://user:senha@host:5432/openmonetis_db" \
|
||||||
|
backup/openmonetis_YYYY-MM-DD_HH-MM.dump
|
||||||
|
|
||||||
|
# A partir do .sql.gz (banco local via Docker)
|
||||||
|
gunzip -c backup/openmonetis_YYYY-MM-DD_HH-MM.sql.gz | \
|
||||||
|
docker compose exec -T db psql -U openmonetis -d openmonetis_db
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ☁️ Storage S3 Compatível
|
## ☁️ 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 +348,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.
|
||||||
|
|||||||
@@ -1,138 +1,51 @@
|
|||||||
# Docker Compose para Next.js + PostgreSQL
|
|
||||||
name: openmonetis
|
name: openmonetis
|
||||||
|
|
||||||
# MODOS DE USO:
|
|
||||||
# 1. Banco LOCAL (PostgreSQL em container):
|
|
||||||
# - Configure DATABASE_URL com host "db" no .env
|
|
||||||
# - Execute: docker compose --profile local up
|
|
||||||
#
|
|
||||||
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
|
|
||||||
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
|
||||||
# - Execute: docker compose up
|
|
||||||
#
|
|
||||||
# 3. Build local (desenvolvimento):
|
|
||||||
# - Execute: docker compose --profile local up --build
|
|
||||||
#
|
|
||||||
# 4. Para parar todos os serviços:
|
|
||||||
# - Execute: docker compose down
|
|
||||||
#
|
|
||||||
# 5. Para remover volumes (CUIDADO: apaga dados do banco local):
|
|
||||||
# - Execute: docker compose down -v
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ============================================
|
|
||||||
# Serviço: PostgreSQL (Banco de dados local)
|
|
||||||
# Ativado apenas com: --profile local
|
|
||||||
# ============================================
|
|
||||||
db:
|
db:
|
||||||
profiles: ["local"]
|
|
||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
container_name: openmonetis_postgres
|
container_name: openmonetis_postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
|
||||||
PGDATA: /var/lib/postgresql/data
|
|
||||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
# Cria extensão pgcrypto inline (necessária para gen_random_bytes no schema)
|
|
||||||
entrypoint: ["/bin/sh", "-c"]
|
|
||||||
command:
|
|
||||||
- |
|
|
||||||
echo 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' > /docker-entrypoint-initdb.d/init.sql
|
|
||||||
exec docker-entrypoint.sh postgres
|
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}"]
|
||||||
[
|
|
||||||
"CMD-SHELL",
|
|
||||||
"pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}",
|
|
||||||
]
|
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
# Para ativar logs de queries (debug), adicione ao command acima:
|
|
||||||
# exec docker-entrypoint.sh postgres -c log_statement=all
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Serviço: Aplicação Next.js
|
|
||||||
# ============================================
|
|
||||||
app:
|
app:
|
||||||
build: .
|
|
||||||
image: felipegcoutinho/openmonetis:latest
|
image: felipegcoutinho/openmonetis:latest
|
||||||
|
|
||||||
container_name: openmonetis_app
|
container_name: openmonetis_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db}
|
||||||
# Banco local: use host "db" | Banco remoto: URL completa do provider
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
|
||||||
|
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
|
||||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||||
|
|
||||||
# S3 (opcional)
|
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
|
||||||
S3_REGION: ${S3_REGION:-}
|
|
||||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
|
||||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
|
||||||
S3_BUCKET: ${S3_BUCKET:-}
|
|
||||||
|
|
||||||
# Email (opcional)
|
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
|
||||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
|
||||||
|
|
||||||
# OAuth (opcional)
|
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
|
||||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
|
||||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
|
||||||
|
|
||||||
# AI providers (opcional)
|
|
||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
|
|
||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
|
||||||
|
|
||||||
# required: false permite subir sem banco local (banco remoto via DATABASE_URL)
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"wget",
|
|
||||||
"--quiet",
|
|
||||||
"--tries=1",
|
|
||||||
"--spider",
|
|
||||||
"http://localhost:3000/api/health",
|
|
||||||
]
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Volumes
|
|
||||||
# ============================================
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Habilitando extensão pgcrypto..."
|
||||||
|
node -e "
|
||||||
|
const { Client } = require('/app/migrate/node_modules/pg');
|
||||||
|
const c = new Client({ connectionString: process.env.DATABASE_URL });
|
||||||
|
c.connect()
|
||||||
|
.then(() => c.query('CREATE EXTENSION IF NOT EXISTS pgcrypto'))
|
||||||
|
.then(() => c.end())
|
||||||
|
.catch((e) => { console.error('Aviso pgcrypto:', e.message); process.exit(0); });
|
||||||
|
"
|
||||||
|
|
||||||
echo "Rodando migrations..."
|
echo "Rodando migrations..."
|
||||||
RETRIES=5
|
MIGRATED=0
|
||||||
until NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
|
for i in 1 2 3 4 5; do
|
||||||
RETRIES=$((RETRIES - 1))
|
if NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push; then
|
||||||
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
|
MIGRATED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Tentativa $i/5 falhou. Aguardando 5s..."
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$RETRIES" -eq 0 ]; then
|
[ "$MIGRATED" -eq 0 ] && echo "Aviso: migrations não foram aplicadas."
|
||||||
echo "Aviso: migrations nao foram aplicadas"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
65
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.3.6",
|
"version": "2.3.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,36 +11,44 @@
|
|||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:deadcode": "knip --reporter compact",
|
"lint:deadcode": "knip --reporter compact",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"env:setup": "bash scripts/setup-env.sh",
|
"env:setup": "node setup.mjs",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"docker:up": "docker compose up --build",
|
|
||||||
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
||||||
"docker:up:db": "docker compose up -d db",
|
|
||||||
"docker:up:d": "docker compose up --build -d",
|
"// --- Docker ---": "---",
|
||||||
|
|
||||||
|
"docker:up": "docker compose up -d",
|
||||||
|
"//docker:up": "Sobe app (Docker Hub) + banco PostgreSQL em background",
|
||||||
|
|
||||||
|
"docker:db": "docker compose up -d db",
|
||||||
|
"//docker:db": "Sobe apenas o banco em background (para usar com pnpm dev)",
|
||||||
|
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
"docker:down:volumes": "docker compose down -v",
|
"//docker:down": "Para e remove os containers",
|
||||||
|
|
||||||
"docker:logs": "docker compose logs -f",
|
"docker:logs": "docker compose logs -f",
|
||||||
"docker:logs:app": "docker compose logs -f app",
|
"//docker:logs": "Acompanha logs de todos os containers em tempo real",
|
||||||
"docker:logs:db": "docker compose logs -f db",
|
|
||||||
"docker:restart": "docker compose restart",
|
"docker:update": "docker compose pull && docker compose up -d",
|
||||||
"docker:rebuild": "docker compose up --build --force-recreate",
|
"//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia",
|
||||||
|
|
||||||
"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 +69,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 +83,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 +98,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 |
@@ -1,17 +0,0 @@
|
|||||||
#!/usr/bin/env tsx
|
|
||||||
|
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import { config } from "dotenv";
|
|
||||||
|
|
||||||
// Carregar variáveis de ambiente
|
|
||||||
config();
|
|
||||||
|
|
||||||
const port = process.env.PORT || "3000";
|
|
||||||
|
|
||||||
console.log(`Starting Next.js development server on port ${port}...`);
|
|
||||||
|
|
||||||
// Executar next dev com a porta especificada
|
|
||||||
execSync(`npx next dev --turbopack --port ${port}`, {
|
|
||||||
stdio: "inherit",
|
|
||||||
env: { ...process.env, PORT: port },
|
|
||||||
});
|
|
||||||
245
scripts/install-deps.sh
Executable file
@@ -0,0 +1,245 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# install-deps.sh — Instala pré-requisitos do OpenMonetis
|
||||||
|
# Testado apenas em Ubuntu Server 24.04 LTS
|
||||||
|
# Uso: curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
|
||||||
|
# sudo sh install-deps.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
LOG_FILE="/tmp/openmonetis-install.log"
|
||||||
|
> "$LOG_FILE"
|
||||||
|
|
||||||
|
# Suprimir prompt interativo do corepack ao chamar pnpm/node versioning
|
||||||
|
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||||
|
|
||||||
|
# ── Cores ──────────────────────────────────────────────────────────────────────
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
ok() { printf "${GREEN}✔${RESET} %s\n" "$1"; }
|
||||||
|
warn() { printf "${YELLOW}!${RESET} %s\n" "$1"; }
|
||||||
|
info() { printf "${CYAN}→${RESET} %s\n" "$1"; }
|
||||||
|
fail() { printf "${RED}✗${RESET} %s\n" "$1"; exit 1; }
|
||||||
|
|
||||||
|
# ── Contador de etapas ─────────────────────────────────────────────────────────
|
||||||
|
_STEP=0
|
||||||
|
_TOTAL=5
|
||||||
|
|
||||||
|
section() {
|
||||||
|
_STEP=$((_STEP + 1))
|
||||||
|
printf "\n${BOLD}[%d/%d] %s${RESET}\n" "$_STEP" "$_TOTAL" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Spinner ────────────────────────────────────────────────────────────────────
|
||||||
|
_spin_pid=""
|
||||||
|
|
||||||
|
spinner_start() {
|
||||||
|
_spin_label="$1"
|
||||||
|
( i=0
|
||||||
|
while true; do
|
||||||
|
case $((i % 4)) in
|
||||||
|
0) d=" " ;; 1) d=". " ;; 2) d=".. " ;; *) d="..." ;;
|
||||||
|
esac
|
||||||
|
printf "\r${CYAN}→${RESET} %s%s" "$_spin_label" "$d"
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 0.4
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
_spin_pid=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner_stop() {
|
||||||
|
if [ -n "$_spin_pid" ]; then
|
||||||
|
kill "$_spin_pid" 2>/dev/null || true
|
||||||
|
wait "$_spin_pid" 2>/dev/null || true
|
||||||
|
_spin_pid=""
|
||||||
|
printf "\r\033[2K"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Executores silenciosos com spinner ─────────────────────────────────────────
|
||||||
|
|
||||||
|
# run_quiet "label" cmd [args...] — roda comando com spinner, falha mostra log
|
||||||
|
run_quiet() {
|
||||||
|
_rq_label="$1"; shift
|
||||||
|
spinner_start "$_rq_label"
|
||||||
|
if ! "$@" >> "$LOG_FILE" 2>&1; then
|
||||||
|
spinner_stop
|
||||||
|
printf "${RED}✗ Falha em: %s${RESET}\n" "$_rq_label"
|
||||||
|
printf " Log completo: %s\n\n" "$LOG_FILE"
|
||||||
|
tail -20 "$LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
spinner_stop
|
||||||
|
}
|
||||||
|
|
||||||
|
# run_as_user "label" "comando_shell" — roda comando como $CURRENT_USER com spinner
|
||||||
|
run_as_user() {
|
||||||
|
_ru_label="$1"; shift
|
||||||
|
spinner_start "$_ru_label"
|
||||||
|
if ! su - "$CURRENT_USER" -c "$*" >> "$LOG_FILE" 2>&1; then
|
||||||
|
spinner_stop
|
||||||
|
printf "${RED}✗ Falha em: %s${RESET}\n" "$_ru_label"
|
||||||
|
printf " Log completo: %s\n\n" "$LOG_FILE"
|
||||||
|
tail -20 "$LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
spinner_stop
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Cleanup no Ctrl+C ──────────────────────────────────────────────────────────
|
||||||
|
cleanup() {
|
||||||
|
spinner_stop
|
||||||
|
printf "\n${YELLOW}Instalação interrompida.${RESET} Log em: %s\n" "$LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
trap cleanup INT TERM
|
||||||
|
|
||||||
|
# ── Tempo total ────────────────────────────────────────────────────────────────
|
||||||
|
_START=$(date +%s)
|
||||||
|
elapsed() {
|
||||||
|
_secs=$(( $(date +%s) - _START ))
|
||||||
|
printf "%dm%ds" $((_secs / 60)) $((_secs % 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Root check ─────────────────────────────────────────────────────────────────
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
fail "Execute como root ou com sudo: sudo sh install-deps.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_USER="${SUDO_USER:-$(whoami)}"
|
||||||
|
|
||||||
|
printf "\n${BOLD}OpenMonetis — Instalação de Dependências${RESET}\n"
|
||||||
|
printf "Usuário: ${CYAN}%s${RESET} | Log: %s\n" "$CURRENT_USER" "$LOG_FILE"
|
||||||
|
|
||||||
|
# ── [1/5] Dependências base ────────────────────────────────────────────────────
|
||||||
|
section "Dependências base"
|
||||||
|
run_quiet "Atualizando lista de pacotes" apt-get update -qq
|
||||||
|
run_quiet "Instalando git, curl, ca-certificates" apt-get install -y -qq ca-certificates curl git
|
||||||
|
ok "git $(git --version | cut -d' ' -f3) · curl · ca-certificates"
|
||||||
|
|
||||||
|
# ── [2/5] Docker ───────────────────────────────────────────────────────────────
|
||||||
|
section "Docker"
|
||||||
|
|
||||||
|
if command -v docker > /dev/null 2>&1; then
|
||||||
|
ok "Docker já instalado: $(docker --version | cut -d',' -f1)"
|
||||||
|
else
|
||||||
|
info "Adicionando repositório oficial do Docker..."
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
run_quiet "Baixando chave GPG do Docker" \
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
|
||||||
|
. /etc/os-release
|
||||||
|
mkdir -p /etc/apt/sources.list.d
|
||||||
|
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu %s stable\n' \
|
||||||
|
"$(dpkg --print-architecture)" "$VERSION_CODENAME" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
|
||||||
|
run_quiet "Atualizando lista de pacotes" apt-get update -qq
|
||||||
|
run_quiet "Instalando Docker Engine (pode levar alguns minutos)" \
|
||||||
|
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
|
||||||
|
systemctl enable docker > /dev/null 2>&1 || true
|
||||||
|
systemctl start docker > /dev/null 2>&1 || true
|
||||||
|
ok "Docker $(docker --version | cut -d',' -f1 | cut -d' ' -f3) instalado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker compose version > /dev/null 2>&1; then
|
||||||
|
ok "Docker Compose $(docker compose version | cut -d' ' -f4)"
|
||||||
|
else
|
||||||
|
run_quiet "Instalando Docker Compose plugin" \
|
||||||
|
sh -c 'mkdir -p /usr/local/lib/docker/cli-plugins && curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" -o /usr/local/lib/docker/cli-plugins/docker-compose && chmod +x /usr/local/lib/docker/cli-plugins/docker-compose'
|
||||||
|
ok "Docker Compose $(docker compose version | cut -d' ' -f4) instalado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
|
||||||
|
if ! groups "$CURRENT_USER" | grep -q docker; then
|
||||||
|
usermod -aG docker "$CURRENT_USER"
|
||||||
|
warn "Usuário '$CURRENT_USER' adicionado ao grupo docker — faça logout/login para aplicar"
|
||||||
|
else
|
||||||
|
ok "Usuário '$CURRENT_USER' já está no grupo docker"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── [3/5] Homebrew ─────────────────────────────────────────────────────────────
|
||||||
|
section "Homebrew"
|
||||||
|
|
||||||
|
if command -v brew > /dev/null 2>&1; then
|
||||||
|
ok "Homebrew já instalado: $(brew --version | head -1)"
|
||||||
|
else
|
||||||
|
warn "Esta etapa pode levar de 5 a 10 minutos."
|
||||||
|
run_quiet "Instalando dependências de compilação" \
|
||||||
|
apt-get install -y -qq build-essential procps file
|
||||||
|
|
||||||
|
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
|
||||||
|
run_as_user "Instalando Homebrew" \
|
||||||
|
'NONINTERACTIVE=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||||
|
|
||||||
|
BREW_PROFILE="/home/$CURRENT_USER/.bashrc"
|
||||||
|
BREW_EVAL='eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"'
|
||||||
|
grep -qxF "$BREW_EVAL" "$BREW_PROFILE" 2>/dev/null || echo "$BREW_EVAL" >> "$BREW_PROFILE"
|
||||||
|
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
|
||||||
|
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
fail "Homebrew não pode ser instalado como root. Use sudo com um usuário normal."
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "Homebrew instalado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── [4/5] Node.js 22 ───────────────────────────────────────────────────────────
|
||||||
|
section "Node.js 22"
|
||||||
|
|
||||||
|
NODE_MAJOR=0
|
||||||
|
if command -v node > /dev/null 2>&1; then
|
||||||
|
NODE_MAJOR=$(node -e "process.stdout.write(String(parseInt(process.versions.node)))")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NODE_MAJOR" -ge 22 ] 2>/dev/null; then
|
||||||
|
ok "Node.js já instalado: $(node --version)"
|
||||||
|
else
|
||||||
|
warn "Node.js via Homebrew pode levar alguns minutos."
|
||||||
|
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
|
||||||
|
run_as_user "Instalando Node.js 22" \
|
||||||
|
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && brew install node@22 && brew link node@22 --force --overwrite'
|
||||||
|
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
|
||||||
|
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
fail "Node.js via Homebrew não pode ser instalado como root."
|
||||||
|
fi
|
||||||
|
ok "Node.js $(node --version) instalado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── [5/5] pnpm ─────────────────────────────────────────────────────────────────
|
||||||
|
section "pnpm"
|
||||||
|
|
||||||
|
if command -v pnpm > /dev/null 2>&1; then
|
||||||
|
ok "pnpm já instalado: $(pnpm --version)"
|
||||||
|
else
|
||||||
|
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
|
||||||
|
run_as_user "Instalando pnpm via corepack" \
|
||||||
|
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
|
||||||
|
else
|
||||||
|
run_quiet "Instalando pnpm via corepack" \
|
||||||
|
sh -c 'corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
|
||||||
|
fi
|
||||||
|
ok "pnpm instalado"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Resumo ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Garantir que node/pnpm do brew estejam no PATH para o resumo
|
||||||
|
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
|
||||||
|
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
|
||||||
|
|
||||||
|
printf "\n${BOLD}Concluído em $(elapsed)${RESET}\n"
|
||||||
|
|
||||||
|
ok "git: $(git --version | cut -d' ' -f3)"
|
||||||
|
ok "docker: $(docker --version | cut -d',' -f1 | cut -d' ' -f3)"
|
||||||
|
ok "docker compose: $(docker compose version | cut -d' ' -f4)"
|
||||||
|
ok "node: $(node --version)"
|
||||||
|
ok "pnpm: $(pnpm --version)"
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Script para configurar ambiente de forma segura
|
|
||||||
# Cria backup do .env atual antes de sobrescrever
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🔧 Configurando ambiente..."
|
|
||||||
|
|
||||||
# Se .env já existe, criar backup
|
|
||||||
if [ -f .env ]; then
|
|
||||||
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
|
|
||||||
echo "⚠️ Arquivo .env existente detectado!"
|
|
||||||
echo "📦 Criando backup em: $BACKUP_FILE"
|
|
||||||
cp .env "$BACKUP_FILE"
|
|
||||||
echo "✅ Backup criado com sucesso!"
|
|
||||||
echo ""
|
|
||||||
read -p "Deseja sobrescrever o .env atual com .env.example? (s/N) " -n 1 -r
|
|
||||||
echo
|
|
||||||
if [[ ! $REPLY =~ ^[Ss]$ ]]; then
|
|
||||||
echo "❌ Operação cancelada. Seu .env não foi modificado."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Copiar .env.example para .env
|
|
||||||
if [ -f .env.example ]; then
|
|
||||||
cp .env.example .env
|
|
||||||
echo "✅ Arquivo .env criado a partir de .env.example"
|
|
||||||
else
|
|
||||||
echo "❌ Erro: .env.example não encontrado!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Gerar BETTER_AUTH_SECRET automaticamente
|
|
||||||
if command -v openssl &> /dev/null; then
|
|
||||||
SECRET=$(openssl rand -base64 32)
|
|
||||||
sed -i.bak "s|BETTER_AUTH_SECRET=.*|BETTER_AUTH_SECRET=$SECRET|" .env && rm -f .env.bak
|
|
||||||
echo "✅ BETTER_AUTH_SECRET gerado automaticamente"
|
|
||||||
else
|
|
||||||
echo "⚠️ openssl não encontrado — configure BETTER_AUTH_SECRET manualmente:"
|
|
||||||
echo " openssl rand -base64 32"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "⚠️ IMPORTANTE: Edite o arquivo .env e configure:"
|
|
||||||
echo " - DATABASE_URL"
|
|
||||||
echo " - BETTER_AUTH_URL"
|
|
||||||
echo " - Demais variáveis opcionais (OAuth, e-mail, IA)"
|
|
||||||
@@ -11,7 +11,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
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" />
|
||||||
|
{process.env.UMAMI_URL && process.env.UMAMI_WEBSITE_ID && (
|
||||||
<script
|
<script
|
||||||
defer
|
defer
|
||||||
src="https://umami.felipecoutinho.com/script.js"
|
src={`${process.env.UMAMI_URL}/script.js`}
|
||||||
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
|
data-website-id={process.env.UMAMI_WEBSITE_ID}
|
||||||
data-domains="openmonetis.com"
|
{...(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, {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
signDisplay: "always",
|
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,168 +70,269 @@ 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",
|
||||||
|
isFullySelected && "ring-2 ring-primary/30 border-primary/50",
|
||||||
|
isPartiallySelected && "border-primary/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header Section */}
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{/* Checkbox de seleção do grupo */}
|
||||||
|
<div className="pt-1">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isFullySelected}
|
checked={
|
||||||
|
isFullySelected
|
||||||
|
? true
|
||||||
|
: isPartiallySelected
|
||||||
|
? "indeterminate"
|
||||||
|
: false
|
||||||
|
}
|
||||||
onCheckedChange={onToggleGroup}
|
onCheckedChange={onToggleGroup}
|
||||||
className="mt-1"
|
className="size-4"
|
||||||
aria-label={`Selecionar todas as parcelas de ${group.name}`}
|
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>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
{group.cartaoName ?? "Compra parcelada"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
{/* Badge de status */}
|
||||||
<div className="flex items-center gap-1">
|
<Badge
|
||||||
<span className="text-xs text-muted-foreground">Total:</span>
|
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
|
<MoneyValues
|
||||||
amount={totalAmount}
|
amount={totalAmount}
|
||||||
className="text-base font-medium"
|
className="text-lg font-semibold text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="space-y-1 text-right">
|
||||||
<span className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground font-medium">
|
||||||
Pendente:
|
Pendente
|
||||||
</span>
|
</p>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={pendingAmount}
|
amount={pendingAmount}
|
||||||
className="text-sm font-medium text-primary"
|
className={cn(
|
||||||
|
"text-lg font-semibold",
|
||||||
|
pendingAmount > 0 ? "text-amber-600" : "text-success-600",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Barra de progresso */}
|
||||||
<div className="mt-3">
|
<div className="space-y-2 mb-4">
|
||||||
<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">
|
<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 && (
|
</div>
|
||||||
<span className="text-primary font-medium">
|
)}
|
||||||
• Selecionado:{" "}
|
</div>
|
||||||
|
<Progress value={progress} className="h-2.5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Valor selecionado */}
|
||||||
|
{hasSelection && (
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/5 border border-primary/20 mb-4">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{selectedInstallments.size}{" "}
|
||||||
|
{selectedInstallments.size === 1
|
||||||
|
? "parcela selecionada"
|
||||||
|
: "parcelas selecionadas"}
|
||||||
|
</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={selectedAmount}
|
amount={selectedAmount}
|
||||||
className="text-xs font-medium text-primary inline"
|
className="text-base font-semibold text-primary"
|
||||||
/>
|
/>
|
||||||
</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Progress value={progress} className="h-2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Botão de expandir */}
|
{/* Botão para abrir detalhes */}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
variant="outline"
|
||||||
className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
size="sm"
|
||||||
|
className="w-full gap-1.5"
|
||||||
|
onClick={() => setIsDetailsOpen(true)}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
<RiEyeLine className="size-4" />
|
||||||
<>
|
Ver detalhes ({group.pendingInstallments.length} parcelas)
|
||||||
<RiArrowDownSLine className="size-4" />
|
</Button>
|
||||||
Ocultar parcelas ({group.pendingInstallments.length})
|
</CardContent>
|
||||||
</>
|
</Card>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiArrowRightSLine className="size-4" />
|
|
||||||
Ver parcelas ({group.pendingInstallments.length})
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lista de parcelas expandida */}
|
{/* Modal de detalhes */}
|
||||||
{isExpanded && (
|
<Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
|
||||||
<div className="px-2 sm:px-8 mt-2 flex flex-col gap-2">
|
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||||
{group.pendingInstallments.map((installment) => {
|
<DialogHeader>
|
||||||
const isSelected = selectedInstallments.has(installment.id);
|
<div className="flex items-center gap-3">
|
||||||
const isPaid = installment.isSettled;
|
{group.cartaoLogo ? (
|
||||||
|
<img
|
||||||
|
src={`/logos/${group.cartaoLogo}`}
|
||||||
|
alt={group.cartaoName ?? "Cartão"}
|
||||||
|
className="size-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogTitle className="text-base">{group.name}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Detalhes das parcelas do grupo {group.name}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 space-y-4 pr-1">
|
||||||
|
{/* Parcelas pagas */}
|
||||||
|
{paidInstallments.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Parcelas pagas
|
||||||
|
</p>
|
||||||
|
{paidInstallments.map((installment) => {
|
||||||
const dueDate = installment.dueDate
|
const dueDate = installment.dueDate
|
||||||
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR })
|
? format(installment.dueDate, "dd MMM yyyy", {
|
||||||
: format(installment.purchaseDate, "dd/MM/yyyy", {
|
locale: ptBR,
|
||||||
|
})
|
||||||
|
: format(installment.purchaseDate, "dd MMM yyyy", {
|
||||||
locale: ptBR,
|
locale: ptBR,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
key={installment.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-md border p-2 transition-colors",
|
"flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all duration-200",
|
||||||
isSelected && !isPaid && "border-primary/50 bg-primary/5",
|
isSelected
|
||||||
isPaid &&
|
? "bg-primary/5 border-primary/30 shadow-sm"
|
||||||
"border-success/40 bg-success/5 dark:border-success/20 dark:bg-success/5",
|
: "bg-card hover:bg-muted/50 hover:border-border",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isPaid ? false : isSelected}
|
checked={isSelected}
|
||||||
disabled={isPaid}
|
|
||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
!isPaid && onToggleInstallment(installment.id)
|
onToggleInstallment(installment.id)
|
||||||
}
|
}
|
||||||
|
className="size-5"
|
||||||
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
|
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="min-w-0">
|
<p className="text-sm font-medium">
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-xs font-medium",
|
|
||||||
isPaid &&
|
|
||||||
"text-success line-through decoration-success/50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Parcela {installment.currentInstallment}/
|
Parcela {installment.currentInstallment}/
|
||||||
{group.totalInstallments}
|
{group.totalInstallments}
|
||||||
{isPaid && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="ml-1 text-xs border-none text-success"
|
|
||||||
>
|
|
||||||
<RiCheckboxCircleFill /> Pago
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
className={cn(
|
<RiTimeLine className="size-3 text-amber-600" />
|
||||||
"text-xs mt-1",
|
|
||||||
isPaid ? "text-success" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Vencimento: {dueDate}
|
Vencimento: {dueDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,17 +340,34 @@ export function InstallmentGroupCard({
|
|||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={installment.amount}
|
amount={installment.amount}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 text-sm",
|
"text-sm font-semibold shrink-0",
|
||||||
isPaid && "text-success",
|
isSelected ? "text-primary" : "text-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
{/* Footer com resumo da seleção */}
|
||||||
|
{hasSelection && (
|
||||||
|
<div className="border-t pt-3 mt-1 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{selectedInstallments.size}{" "}
|
||||||
|
{selectedInstallments.size === 1
|
||||||
|
? "parcela selecionada"
|
||||||
|
: "parcelas selecionadas"}
|
||||||
|
</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={selectedAmount}
|
||||||
|
className="text-base font-bold text-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ export function InstallmentExpenseListItem({
|
|||||||
</span>
|
</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">
|
||||||
|
{item.href ? (
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<span className="truncate">{item.title}</span>
|
||||||
|
<RiExternalLinkLine
|
||||||
|
className="size-3 shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||||
<MoneyValues amount={item.amount} />
|
)}
|
||||||
|
<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) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
type: slugify("Despesa"),
|
||||||
|
condition: slugify(condition.condition),
|
||||||
|
periodo: formatPeriodForUrl(period),
|
||||||
|
});
|
||||||
|
if (adminPayerSlug) params.set("payer", adminPayerSlug);
|
||||||
|
return {
|
||||||
id: condition.condition,
|
id: condition.condition,
|
||||||
title: condition.condition,
|
title: condition.condition,
|
||||||
icon: resolveConditionIcon(condition.condition),
|
icon: resolveConditionIcon(condition.condition),
|
||||||
amount: condition.amount,
|
amount: condition.amount,
|
||||||
transactions: condition.transactions,
|
transactions: condition.transactions,
|
||||||
percentage: condition.percentage,
|
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,
|
||||||
|
period,
|
||||||
|
adminPayerSlug,
|
||||||
|
}: PaymentMethodsWidgetProps) {
|
||||||
|
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
type: slugify("Despesa"),
|
||||||
|
payment: slugify(method.paymentMethod),
|
||||||
|
periodo: formatPeriodForUrl(period),
|
||||||
|
});
|
||||||
|
if (adminPayerSlug) params.set("payer", adminPayerSlug);
|
||||||
|
return {
|
||||||
id: method.paymentMethod,
|
id: method.paymentMethod,
|
||||||
title: method.paymentMethod,
|
title: method.paymentMethod,
|
||||||
icon: resolvePaymentMethodIcon(method.paymentMethod),
|
icon: resolvePaymentMethodIcon(method.paymentMethod),
|
||||||
amount: method.amount,
|
amount: method.amount,
|
||||||
transactions: method.transactions,
|
transactions: method.transactions,
|
||||||
percentage: method.percentage,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||