Compare commits
16 Commits
58db357cde
...
v2.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e80d5995b | ||
|
|
68daae7926 | ||
|
|
9413c470a8 | ||
|
|
ad1b0aa979 | ||
|
|
4d9a1c0a35 | ||
|
|
5635705c56 | ||
|
|
4c97ed569d | ||
|
|
22a88de993 | ||
|
|
9456aa98bc | ||
|
|
21c6a8d9d0 | ||
|
|
c29ffa9a12 | ||
|
|
8875de843b | ||
|
|
679ea752bb | ||
|
|
1161e97d9e | ||
|
|
55d7dedd9a | ||
|
|
ad2752b7b0 |
13
.env.example
@@ -3,10 +3,10 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# === Database ===
|
# === Database ===
|
||||||
# PostgreSQL local (Docker): use host "db"
|
# Desenvolvimento local (pnpm dev): use host "localhost" (padrão abaixo)
|
||||||
# PostgreSQL local (sem Docker): use host "localhost"
|
# Docker Compose completo: o compose.yml define DATABASE_URL automaticamente com host "db"
|
||||||
# PostgreSQL remoto: use URL completa do provider
|
# PostgreSQL remoto: use URL completa do provider
|
||||||
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db
|
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
||||||
|
|
||||||
# Credenciais do PostgreSQL (apenas para Docker local) - Alterar
|
# Credenciais do PostgreSQL (apenas para Docker local) - Alterar
|
||||||
POSTGRES_USER=openmonetis
|
POSTGRES_USER=openmonetis
|
||||||
@@ -55,3 +55,10 @@ 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=
|
||||||
|
|
||||||
|
# === Logo.dev (Opcional) ===
|
||||||
|
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev
|
||||||
|
# NEXT_PUBLIC_LOGO_DEV_TOKEN — token público (aparece no frontend, ok por design)
|
||||||
|
# LOGO_DEV_SECRET_KEY — chave secreta (apenas server-side, nunca expor)
|
||||||
|
NEXT_PUBLIC_LOGO_DEV_TOKEN=
|
||||||
|
LOGO_DEV_SECRET_KEY=
|
||||||
2
.github/workflows/docker-publish.yml
vendored
@@ -85,6 +85,8 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_LOGO_DEV_TOKEN=${{ secrets.NEXT_PUBLIC_LOGO_DEV_TOKEN }}
|
||||||
|
|
||||||
- name: Image digest
|
- name: Image digest
|
||||||
run: echo ${{ steps.meta.outputs.digest }}
|
run: echo ${{ steps.meta.outputs.digest }}
|
||||||
|
|||||||
48
CHANGELOG.md
@@ -7,6 +7,54 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.4.1] - 2026-04-16
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- UI/Auth: layout animado nas páginas de login e signup com efeito blob (3 círculos coloridos em movimento) e card com glassmorphism; layout compartilhado extraído para `app/(auth)/layout.tsx` eliminando duplicação (PR #42)
|
||||||
|
- DB: 17 índices em foreign keys — evita sequential scans em deletes nas tabelas pai. Impacto maior nas FKs de `lancamentos` (conta_id, categoria_id, antecipacao_id), onde deletes em `categorias` antes provocavam full scan na tabela de lançamentos
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
|
||||||
|
- UI/Navbar: labels capitalizados (Lançamentos, Categorias, Contas) em vez de caixa baixa — melhora legibilidade (PR #42)
|
||||||
|
|
||||||
|
### Removido
|
||||||
|
|
||||||
|
- DB: 7 índices sem uso — `tokens_api_user_id_idx`, `cartoes_user_id_status_idx`, `contas_user_id_status_idx`, `pagadores_user_id_status_idx`, `pagadores_user_id_role_idx`, `dashboard_notification_states_user_id_archived_idx`, `antecipacoes_parcelas_series_id_idx` (0 scans em 187 dias de estatísticas)
|
||||||
|
- UI/Settings: tab de Integrações órfã removida (não tinha `TabsContent` correspondente)
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Docker: container do PostgreSQL falhava ao iniciar em instalações existentes após atualização da imagem `postgres:18-alpine` — entrypoint passou a recusar dados no caminho legado `/var/lib/postgresql/data`. Adicionada variável `PGDATA` no `docker-compose.yml` para fixar o caminho e preservar dados de quem já tinha o volume populado (resolve #41)
|
||||||
|
|
||||||
|
## [2.4.0] - 2026-04-13
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
|
||||||
|
- Estabelecimentos: integração com Logo.dev — logos automáticos de marcas exibidos na coluna de estabelecimentos nos lançamentos
|
||||||
|
- Estabelecimentos: picker de logo por estabelecimento — clique no avatar para buscar e fixar um domínio Logo.dev específico (salvo por usuário no banco)
|
||||||
|
- API: rotas `/api/logo/search` e `/api/logo/mapping` — proxy seguro para Logo.dev Brand Search API (secret key server-side) e consulta de mapeamentos salvos
|
||||||
|
- Schema: tabela `establishment_logos` com PK composta `(user_id, name_key)` para persistir preferências de logo por usuário
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Dev: `.env.example` usava host `db` no `DATABASE_URL`, causando erro `EAI_AGAIN` ao rodar `pnpm dev` localmente — corrigido para `localhost`
|
||||||
|
|
||||||
|
### Documentação
|
||||||
|
|
||||||
|
- README: tabela comparativa entre Perfil 1 (Usar) e Perfil 2 (Desenvolver) com diferenças de setup, `DATABASE_URL` e instruções de atualização
|
||||||
|
- README: seção "Variáveis de Ambiente" esclarecida — distingue contexto Docker (Perfil 1) de desenvolvimento local (Perfil 2)
|
||||||
|
- Logo.dev: crie uma conta em logo.dev para obter as chaves `NEXT_PUBLIC_LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` — plano gratuito inclui 500.000 requisições/mês
|
||||||
|
|
||||||
|
## [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
|
## [2.3.7] - 2026-04-11
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Token público do Logo.dev — injetado em build time (NEXT_PUBLIC_* é inlined pelo Next.js)
|
||||||
|
ARG NEXT_PUBLIC_LOGO_DEV_TOKEN
|
||||||
|
ENV NEXT_PUBLIC_LOGO_DEV_TOKEN=$NEXT_PUBLIC_LOGO_DEV_TOKEN
|
||||||
|
|
||||||
# Build da aplicação Next.js
|
# Build da aplicação Next.js
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
|
|||||||
277
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/)
|
||||||
@@ -33,11 +33,8 @@
|
|||||||
|
|
||||||
- [Sobre o Projeto](#-sobre-o-projeto)
|
- [Sobre o Projeto](#-sobre-o-projeto)
|
||||||
- [Como rodar o OpenMonetis](#-como-rodar-o-openmonetis)
|
- [Como rodar o OpenMonetis](#-como-rodar-o-openmonetis)
|
||||||
- [Antes de tudo: preparar o servidor](#-antes-de-tudo-preparar-o-servidor-ubuntu-2404)
|
- [Perfil 1 — Usar](#perfil-1--usar-self-hosting)
|
||||||
- [Modo 1 — Só quero usar](#modo-1--só-quero-usar-sem-mexer-no-código)
|
- [Perfil 2 — Desenvolver](#perfil-2--desenvolver)
|
||||||
- [Modo 2 — Quero configurar opcionais](#modo-2--quero-configurar-opcionais-oauth-e-mail-ia)
|
|
||||||
- [Modo 3 — Quero contribuir com o código](#modo-3--quero-contribuir-com-o-código)
|
|
||||||
- [Modo 4 — Banco remoto](#modo-4--banco-remoto-supabase-neon-railway)
|
|
||||||
- [Scripts Disponíveis](#-scripts-disponíveis)
|
- [Scripts Disponíveis](#-scripts-disponíveis)
|
||||||
- [Docker](#-docker)
|
- [Docker](#-docker)
|
||||||
- [Backup](#-backup)
|
- [Backup](#-backup)
|
||||||
@@ -108,127 +105,120 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
|||||||
|
|
||||||
## 🚀 Como rodar o OpenMonetis
|
## 🚀 Como rodar o OpenMonetis
|
||||||
|
|
||||||
O OpenMonetis precisa de dois serviços funcionando ao mesmo tempo para operar: **o app** (a interface e a lógica do sistema) e **o banco de dados** (onde ficam guardados seus lançamentos, contas, categorias etc.). Existem algumas formas de subir os dois, dependendo do que você quer fazer.
|
Escolha o perfil que corresponde ao seu objetivo:
|
||||||
|
|
||||||
|
| | Perfil 1 — Usar | Perfil 2 — Desenvolver |
|
||||||
|
|---|---|---|
|
||||||
|
| **Objetivo** | Rodar o app pronto | Modificar o código |
|
||||||
|
| **Clonar repositório** | Não | Sim |
|
||||||
|
| **Node.js / pnpm** | Não | Sim (Node 22+) |
|
||||||
|
| **Docker** | Sim | Sim |
|
||||||
|
| **Como iniciar** | `docker compose up -d` | `pnpm docker:db` + `pnpm dev` |
|
||||||
|
| **App roda em** | Container Docker | Host local (hot-reload) |
|
||||||
|
| **Banco roda em** | Container Docker | Container Docker |
|
||||||
|
| **`DATABASE_URL` (host)** | `db` (automático pelo compose) | `localhost` |
|
||||||
|
| **Banco remoto (Supabase, Neon...)** | Sim (`docker compose up -d app`) | Sim (ajustar `DATABASE_URL`) |
|
||||||
|
| **Como atualizar** | `pnpm docker:update` | `git pull` + `pnpm install` + `pnpm db:push` |
|
||||||
|
| **Indicado para** | Self-hosting, VPS, servidor | Contribuidores, customizações |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🖥️ Antes de tudo: preparar o servidor (Ubuntu 24.04)
|
### Perfil 1 — Usar (self-hosting)
|
||||||
|
|
||||||
Se você está em um **servidor Ubuntu limpo** (VPS, Oracle Cloud, DigitalOcean...) sem Docker, Node.js ou pnpm instalados, rode esse script primeiro. Ele instala tudo que é necessário automaticamente e pula o que já estiver presente.
|
Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar Node.js** — apenas Docker.
|
||||||
|
|
||||||
> ⚠️ **Testado apenas em Ubuntu Server 24.04 LTS.** Em outras distribuições ou versões, instale as dependências manualmente.
|
```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
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
|
||||||
sudo sh install-deps.sh
|
sudo sh install-deps.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
O script instala:
|
> Ao final, faça **logout e login** para as permissões do grupo `docker` terem efeito.
|
||||||
|
|
||||||
| Ferramenta | Como instala |
|
#### Atualizando (Perfil 1)
|
||||||
|---|---|
|
|
||||||
| `git`, `curl`, `ca-certificates` | apt |
|
|
||||||
| Docker Engine + Docker Compose | Repositório oficial do Docker |
|
|
||||||
| Homebrew | Script oficial (como usuário não-root) |
|
|
||||||
| Node.js 22 | Via Homebrew |
|
|
||||||
| pnpm | Via corepack |
|
|
||||||
|
|
||||||
Ao final, o usuário atual é adicionado ao grupo `docker` — faça **logout e login** para a mudança ter efeito antes de continuar.
|
```bash
|
||||||
|
pnpm docker:update
|
||||||
|
# ou equivalente:
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
Se já tiver Docker e Node.js 22+ instalados, pule essa etapa.
|
O schema do banco é aplicado automaticamente no startup — nenhum passo extra necessário.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Modo 1 — Só quero usar (sem mexer no código)
|
### Perfil 2 — Desenvolver
|
||||||
|
|
||||||
Esse é o caminho mais simples. Você baixa apenas um arquivo de configuração e o Docker faz o resto: baixa a imagem do app pronta do Docker Hub e sobe o banco automaticamente. **Não precisa clonar o repositório nem instalar dependências.**
|
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
|
```bash
|
||||||
# 1. Baixa o arquivo de configuração
|
# 1. Clone o repositório
|
||||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
|
||||||
|
|
||||||
# 2. Sobe o app e o banco juntos
|
|
||||||
docker compose --profile local up
|
|
||||||
```
|
|
||||||
|
|
||||||
Acesse em: `http://localhost:3000`
|
|
||||||
|
|
||||||
> O banco sobe com as credenciais padrão. Se quiser usar uma senha personalizada, crie um arquivo `.env` na mesma pasta antes de subir — veja a seção [Variáveis de Ambiente](#-variáveis-de-ambiente).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modo 2 — Quero configurar opcionais (OAuth, e-mail, IA)
|
|
||||||
|
|
||||||
Se quiser ativar login com Google, envio de e-mails, integrações com IA ou outras configurações, use o assistente interativo. Ele faz perguntas passo a passo e gera o arquivo `.env` com tudo preenchido corretamente.
|
|
||||||
|
|
||||||
**Pré-requisito:** Node.js 22+
|
|
||||||
|
|
||||||
```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 assistente vai perguntar:
|
|
||||||
- Banco local (Docker) ou remoto (Supabase, Neon, Railway...)?
|
|
||||||
- URL da aplicação
|
|
||||||
- Google OAuth (opcional)
|
|
||||||
- E-mail via Resend (opcional)
|
|
||||||
- Chaves de IA — Claude, GPT, Gemini, OpenRouter (opcional)
|
|
||||||
- Domínio público separado para landing page (opcional)
|
|
||||||
|
|
||||||
Ao final, ele clona o repositório, instala as dependências e aplica o schema no banco. Depois é só subir:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd openmonetis
|
|
||||||
docker compose --profile local up
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modo 3 — Quero contribuir com o código
|
|
||||||
|
|
||||||
Nesse modo o Next.js roda **direto no seu servidor com hot-reload** — toda vez que você salva um arquivo, o sistema atualiza automaticamente sem precisar reiniciar nada. O banco continua rodando dentro do Docker (mais simples), só o app fica fora.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Clona o repositório e roda o assistente de configuração
|
|
||||||
git clone https://github.com/felipegcoutinho/openmonetis.git
|
git clone https://github.com/felipegcoutinho/openmonetis.git
|
||||||
cd openmonetis
|
cd openmonetis
|
||||||
node setup.mjs
|
|
||||||
|
|
||||||
# 2. Sobe apenas o banco em background (o app vai rodar fora do Docker)
|
# 2. Instale as dependências
|
||||||
pnpm docker:up:db
|
pnpm install
|
||||||
|
|
||||||
# 3. Inicia o app com hot-reload
|
# 3. Configure o ambiente
|
||||||
|
cp .env.example .env
|
||||||
|
# O DATABASE_URL já vem com host "localhost" (correto para dev local).
|
||||||
|
# Edite o .env com suas configurações (BETTER_AUTH_SECRET, etc.)
|
||||||
|
|
||||||
|
# 4. Suba o banco
|
||||||
|
pnpm docker:db
|
||||||
|
|
||||||
|
# 5. Habilite extensões do PostgreSQL (apenas no primeiro setup)
|
||||||
|
pnpm db:extensions
|
||||||
|
|
||||||
|
# 6. Aplique o schema no banco (apenas no primeiro setup)
|
||||||
|
pnpm db:push
|
||||||
|
|
||||||
|
# 7. Inicie o app com hot-reload
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Acesse em: `http://localhost:3000`
|
Acesse em: `http://localhost:3000`
|
||||||
|
|
||||||
---
|
Toda vez que salvar um arquivo, o app atualiza automaticamente sem precisar reiniciar.
|
||||||
|
|
||||||
### Modo 4 — Banco remoto (Supabase, Neon, Railway...)
|
#### Atualizando (Perfil 2)
|
||||||
|
|
||||||
Se você já tem um banco PostgreSQL hospedado em algum serviço externo, não precisa rodar o banco localmente. Informe a URL do banco durante o `setup.mjs` (escolha a opção "URL remota") e suba só o app:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node setup.mjs # escolha "URL remota" e cole a URL do seu banco
|
git pull
|
||||||
|
pnpm install # instala dependências novas, se houver
|
||||||
docker compose up
|
pnpm db:push # aplica mudanças de schema, se houver
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
O `pnpm dev` já em execução detecta as mudanças de código automaticamente — não precisa reiniciar.
|
||||||
|
|
||||||
### Comparativo
|
|
||||||
|
|
||||||
| O que quero fazer | Comandos principais |
|
|
||||||
|---|---|
|
|
||||||
| Só usar, da forma mais simples | `docker-compose.yml` → `docker compose --profile local up` |
|
|
||||||
| Usar com Google OAuth, e-mail ou IA | `node setup.mjs` → `docker compose --profile local up` |
|
|
||||||
| Desenvolver e contribuir com o código | `node setup.mjs` → `pnpm docker:up:db` → `pnpm dev` |
|
|
||||||
| Usar com banco remoto (Supabase etc.) | `node setup.mjs` → `docker compose up` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -263,75 +253,49 @@ pnpm backup # Backup completo do banco (ver seção Backup)
|
|||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm docker:up:local # Sobe app + banco PostgreSQL juntos (imagem do Hub)
|
pnpm docker:up # Sobe app (Docker Hub) + banco em background
|
||||||
pnpm docker:up # Sobe apenas o app com build local
|
pnpm docker:db # Sobe apenas o banco em background (usar com pnpm dev)
|
||||||
pnpm docker:up:d # Sobe apenas o app com build local em background
|
pnpm docker:down # Para e remove os containers
|
||||||
pnpm docker:up:db # Sobe apenas o banco em background
|
pnpm docker:logs # Logs em tempo real (todos os containers)
|
||||||
pnpm docker:down # Para e remove os containers
|
pnpm docker:update # Atualiza para a imagem mais recente do Hub e reinicia
|
||||||
pnpm docker:down:volumes # Para containers e remove volumes (⚠️ apaga dados!)
|
|
||||||
pnpm docker:logs # Logs em tempo real (todos os containers)
|
|
||||||
pnpm docker:logs:app # Logs do container da aplicação
|
|
||||||
pnpm docker:logs:db # Logs do container do banco
|
|
||||||
pnpm docker:restart # Reinicia todos os containers
|
|
||||||
pnpm docker:rebuild # Rebuild completo forçando recriação
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐳 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)
|
||||||
|
|
||||||
### Modos de uso
|
Baixe apenas o `docker-compose.yml` e suba tudo — sem clonar o repositório, sem instalar dependências:
|
||||||
|
|
||||||
**Modo 1 — App + banco local (recomendado para self-hosting)**
|
|
||||||
|
|
||||||
Puxa a imagem pronta do Docker Hub e sobe app + banco juntos. Não precisa de Node.js instalado nem de arquivo `.env` para começar — as credenciais padrão já estão configuradas.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Baixar o docker-compose.yml
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||||
|
docker compose up -d
|
||||||
# 2. Subir (sem precisar de .env)
|
|
||||||
docker compose --profile local up
|
|
||||||
|
|
||||||
# ou, se tiver o projeto clonado:
|
|
||||||
pnpm docker:up:local
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Se quiser personalizar senhas ou ativar opcionais, crie um arquivo `.env` na mesma pasta antes de subir. Veja a seção [Variáveis de Ambiente](#-variáveis-de-ambiente).
|
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).
|
||||||
|
|
||||||
**Modo 2 — App com banco remoto**
|
### Banco remoto (Supabase, Neon, Railway...)
|
||||||
|
|
||||||
Use quando o banco está em um provider externo (Supabase, Neon, Railway...). Configure o `DATABASE_URL` no `.env` com a URL do seu banco e suba só o app.
|
Suba apenas o app e aponte para o banco externo via `DATABASE_URL` no `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# DATABASE_URL deve apontar para o banco remoto no .env
|
docker compose up -d app
|
||||||
docker compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
**Modo 3 — Build local (desenvolvimento)**
|
|
||||||
|
|
||||||
Builda a imagem localmente a partir do código-fonte em vez de usar a imagem do Docker Hub. Útil para testar mudanças antes de publicar.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm docker:up:db # sobe o banco em background
|
|
||||||
pnpm docker:up # builda e sobe o app localmente
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Comandos úteis
|
### Comandos úteis
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec app sh # Shell da aplicação
|
docker compose exec app sh # Shell da aplicação
|
||||||
docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco
|
docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco
|
||||||
docker compose ps # Status
|
docker compose ps # Status
|
||||||
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup
|
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup
|
||||||
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
|
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
|
||||||
@@ -426,13 +390,49 @@ S3_BUCKET=
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🏷️ Logos de Estabelecimentos (Logo.dev)
|
||||||
|
|
||||||
|
O app exibe logos automáticos de marcas na coluna de estabelecimentos nos lançamentos. A integração usa a [Logo.dev](https://www.logo.dev) e é opcional — sem ela, o app exibe as iniciais coloridas normalmente.
|
||||||
|
|
||||||
|
### Variáveis
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_LOGO_DEV_TOKEN=pk_... # token público (obrigatório para exibir logos)
|
||||||
|
LOGO_DEV_SECRET_KEY=sk_... # chave secreta (obrigatório para o picker de busca)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Como configurar
|
||||||
|
|
||||||
|
**Self-hosted via Docker Hub (Coolify, Railway, etc.):**
|
||||||
|
|
||||||
|
O `NEXT_PUBLIC_LOGO_DEV_TOKEN` é inlinado pelo Next.js **em build time** — ele não pode ser injetado como variável de ambiente em runtime. Por isso o processo é diferente do usual:
|
||||||
|
|
||||||
|
1. Cadastre o secret `NEXT_PUBLIC_LOGO_DEV_TOKEN` no repositório GitHub Fork (Settings → Secrets → Actions)
|
||||||
|
2. O workflow de CI já está configurado para passar o valor como `--build-arg` no `docker build`
|
||||||
|
3. Faça um novo build (push ou Run workflow manual) — a imagem gerada já terá o token embutido
|
||||||
|
4. No Coolify (ou outro host), adicione apenas `LOGO_DEV_SECRET_KEY` como variável de ambiente runtime
|
||||||
|
|
||||||
|
**Desenvolvimento local:**
|
||||||
|
|
||||||
|
Adicione as duas variáveis no `.env` normalmente — o Next.js as lê em `pnpm dev` sem nenhuma etapa extra.
|
||||||
|
|
||||||
|
### Como usar
|
||||||
|
|
||||||
|
Após configurado, passe o mouse sobre o avatar de qualquer estabelecimento nos lançamentos — um ícone de lápis aparece. Clique para abrir o picker, busque pelo nome da marca e selecione o logo desejado. O mapeamento fica salvo por usuário no banco.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔐 Variáveis de Ambiente
|
## 🔐 Variáveis de Ambiente
|
||||||
|
|
||||||
Copie `.env.example` para `.env` e configure:
|
**Perfil 2 (dev):** copie `.env.example` para `.env` — o `DATABASE_URL` já vem com `localhost`, pronto para uso com `pnpm dev`.
|
||||||
|
|
||||||
|
**Perfil 1 (Docker):** não precisa definir `DATABASE_URL` — o compose já configura automaticamente com host `db`. Só defina se usar banco remoto (Supabase, Neon, etc.).
|
||||||
|
|
||||||
### Obrigatórias
|
### Obrigatórias
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
# Perfil 2 (dev): host "localhost" — o banco roda em container, o app no host
|
||||||
|
# Perfil 1 (Docker): não precisa definir — o compose usa "db" automaticamente
|
||||||
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
|
||||||
BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32
|
BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
@@ -469,6 +469,11 @@ 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=
|
||||||
|
|
||||||
|
# Logo.dev (opcional, necessário para logos automáticos de estabelecimentos)
|
||||||
|
# NEXT_PUBLIC_LOGO_DEV_TOKEN deve ser passado como build arg no CI — veja seção Logo.dev
|
||||||
|
NEXT_PUBLIC_LOGO_DEV_TOKEN=
|
||||||
|
LOGO_DEV_SECRET_KEY=
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -1,147 +1,52 @@
|
|||||||
# 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"
|
||||||
|
PGDATA: /var/lib/postgresql/data
|
||||||
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
|
||||||
|
|
||||||
# Banco local: use host "db" | Banco remoto: URL completa do provider
|
|
||||||
# O entrypoint converte automaticamente "localhost" → "db" se necessário
|
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db}
|
DATABASE_URL: ${DATABASE_URL:-postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db}
|
||||||
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
|
||||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||||
|
|
||||||
# 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:-}
|
|
||||||
|
|
||||||
# Multi-domínio (opcional)
|
|
||||||
PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-}
|
|
||||||
|
|
||||||
# Analytics (opcional)
|
|
||||||
UMAMI_URL: ${UMAMI_URL:-}
|
|
||||||
UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
|
|
||||||
UMAMI_DOMAINS: ${UMAMI_DOMAINS:-}
|
|
||||||
|
|
||||||
# required: false permite subir sem banco local (banco remoto via DATABASE_URL)
|
|
||||||
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,23 +1,26 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# Dentro do container Docker, "localhost" não alcança o serviço de banco.
|
echo "Habilitando extensão pgcrypto..."
|
||||||
# Substitui automaticamente para o nome do serviço "db" da rede Docker.
|
node -e "
|
||||||
# Não afeta URLs de bancos remotos (não contêm "@localhost:").
|
const { Client } = require('/app/migrate/node_modules/pg');
|
||||||
if [ -n "$DATABASE_URL" ]; then
|
const c = new Client({ connectionString: process.env.DATABASE_URL });
|
||||||
DATABASE_URL=$(echo "$DATABASE_URL" | sed 's|@localhost:|@db:|g')
|
c.connect()
|
||||||
export DATABASE_URL
|
.then(() => c.query('CREATE EXTENSION IF NOT EXISTS pgcrypto'))
|
||||||
fi
|
.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 "$@"
|
||||||
|
|||||||
24
drizzle/0025_burly_colonel_america.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
DROP INDEX "tokens_api_user_id_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "cartoes_user_id_status_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "dashboard_notification_states_user_id_archived_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "contas_user_id_status_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "antecipacoes_parcelas_series_id_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "pagadores_user_id_status_idx";--> statement-breakpoint
|
||||||
|
DROP INDEX "pagadores_user_id_role_idx";--> statement-breakpoint
|
||||||
|
CREATE INDEX "account_user_id_idx" ON "account" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "anexos_user_id_idx" ON "anexos" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "orcamentos_categoria_id_idx" ON "orcamentos" USING btree ("categoria_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "cartoes_conta_id_idx" ON "cartoes" USING btree ("conta_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "import_category_mappings_category_id_idx" ON "import_category_mappings" USING btree ("category_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "pre_lancamentos_lancamento_id_idx" ON "pre_lancamentos" USING btree ("lancamento_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "antecipacoes_parcelas_lancamento_id_idx" ON "antecipacoes_parcelas" USING btree ("lancamento_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "antecipacoes_parcelas_pagador_id_idx" ON "antecipacoes_parcelas" USING btree ("pagador_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "antecipacoes_parcelas_categoria_id_idx" ON "antecipacoes_parcelas" USING btree ("categoria_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "anotacoes_user_id_idx" ON "anotacoes" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "passkey_user_id_idx" ON "passkey" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "compartilhamentos_pagador_shared_with_user_id_idx" ON "compartilhamentos_pagador" USING btree ("shared_with_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "compartilhamentos_pagador_created_by_user_id_idx" ON "compartilhamentos_pagador" USING btree ("created_by_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "session_user_id_idx" ON "session" USING btree ("userId");--> statement-breakpoint
|
||||||
|
CREATE INDEX "lancamentos_conta_id_idx" ON "lancamentos" USING btree ("conta_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "lancamentos_categoria_id_idx" ON "lancamentos" USING btree ("categoria_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "lancamentos_antecipacao_id_idx" ON "lancamentos" USING btree ("antecipacao_id");
|
||||||
2889
drizzle/meta/0025_snapshot.json
Normal file
@@ -176,6 +176,13 @@
|
|||||||
"when": 1774891206703,
|
"when": 1774891206703,
|
||||||
"tag": "0024_petite_lucky_pierre",
|
"tag": "0024_petite_lucky_pierre",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776351838548,
|
||||||
|
"tag": "0025_burly_colonel_america",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
65
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.3.7",
|
"version": "2.4.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,50 +18,24 @@
|
|||||||
"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",
|
||||||
"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": "docker compose up -d",
|
||||||
"// --- Docker ---": "---",
|
"//docker:up": "Sobe app (Docker Hub) + banco PostgreSQL em background",
|
||||||
|
"docker:db": "docker compose up -d db",
|
||||||
"docker:up:local": "docker compose --profile local up",
|
"//docker:db": "Sobe apenas o banco em background (para usar com pnpm dev)",
|
||||||
"//docker:up:local": "Sobe app + banco PostgreSQL local juntos (imagem do Docker Hub)",
|
|
||||||
|
|
||||||
"docker:up": "docker compose up --build",
|
|
||||||
"//docker:up": "Sobe apenas o app com build local (banco deve estar rodando separado)",
|
|
||||||
|
|
||||||
"docker:up:d": "docker compose up --build -d",
|
|
||||||
"//docker:up:d": "Sobe apenas o app com build local em background (detached)",
|
|
||||||
|
|
||||||
"docker:up:db": "docker compose up -d db",
|
|
||||||
"//docker:up:db": "Sobe apenas o banco PostgreSQL em background",
|
|
||||||
|
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
"//docker:down": "Para e remove os containers",
|
"//docker:down": "Para e remove os containers",
|
||||||
|
|
||||||
"docker:down:volumes": "docker compose down -v",
|
|
||||||
"//docker:down:volumes": "Para containers e remove volumes (APAGA os dados!)",
|
|
||||||
|
|
||||||
"docker:logs": "docker compose logs -f",
|
"docker:logs": "docker compose logs -f",
|
||||||
"//docker:logs": "Acompanha logs de todos os containers em tempo real",
|
"//docker:logs": "Acompanha logs de todos os containers em tempo real",
|
||||||
|
"docker:update": "docker compose pull && docker compose up -d",
|
||||||
"docker:logs:app": "docker compose logs -f app",
|
"//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia",
|
||||||
"//docker:logs:app": "Acompanha logs do container da aplicação",
|
|
||||||
|
|
||||||
"docker:logs:db": "docker compose logs -f db",
|
|
||||||
"//docker:logs:db": "Acompanha logs do container do banco",
|
|
||||||
|
|
||||||
"docker:restart": "docker compose restart",
|
|
||||||
"//docker:restart": "Reinicia todos os containers",
|
|
||||||
|
|
||||||
"docker:rebuild": "docker compose up --build --force-recreate",
|
|
||||||
"//docker:rebuild": "Rebuild completo forçando recriação dos containers",
|
|
||||||
|
|
||||||
"backup": "bash scripts/backup.sh"
|
"backup": "bash scripts/backup.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.68",
|
"@ai-sdk/anthropic": "^3.0.69",
|
||||||
"@ai-sdk/google": "^3.0.61",
|
"@ai-sdk/google": "^3.0.63",
|
||||||
"@ai-sdk/openai": "^3.0.52",
|
"@ai-sdk/openai": "^3.0.52",
|
||||||
"@aws-sdk/client-s3": "^3.1027.0",
|
"@aws-sdk/client-s3": "^3.1030.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1027.0",
|
"@aws-sdk/s3-request-presigner": "^3.1030.0",
|
||||||
"@better-auth/passkey": "^1.6.2",
|
"@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",
|
||||||
@@ -75,11 +49,13 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "2.1.8",
|
"@radix-ui/react-label": "2.1.8",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "1.1.8",
|
"@radix-ui/react-progress": "1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-select": "2.2.6",
|
"@radix-ui/react-select": "2.2.6",
|
||||||
"@radix-ui/react-separator": "1.1.8",
|
"@radix-ui/react-separator": "1.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "1.2.4",
|
"@radix-ui/react-slot": "1.2.4",
|
||||||
"@radix-ui/react-switch": "1.2.6",
|
"@radix-ui/react-switch": "1.2.6",
|
||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
@@ -87,10 +63,10 @@
|
|||||||
"@radix-ui/react-toggle-group": "1.1.11",
|
"@radix-ui/react-toggle-group": "1.1.11",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "^5.97.0",
|
"@tanstack/react-query": "^5.99.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.154",
|
"ai": "^6.0.159",
|
||||||
"better-auth": "1.6.2",
|
"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",
|
||||||
@@ -105,12 +81,11 @@
|
|||||||
"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",
|
|
||||||
"react": "19.2.5",
|
"react": "19.2.5",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "19.2.5",
|
"react-dom": "19.2.5",
|
||||||
"recharts": "3.8.1",
|
"recharts": "3.8.1",
|
||||||
"resend": "^6.10.0",
|
"resend": "^6.11.0",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
@@ -122,16 +97,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.10",
|
"@biomejs/biome": "2.4.11",
|
||||||
"@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.2",
|
"@types/node": "25.6.0",
|
||||||
"@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.1",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"knip": "^6.3.1",
|
"knip": "^6.4.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"
|
||||||
|
|||||||
704
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 163 KiB |
23
src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Logo } from "@/shared/components/logo";
|
||||||
|
|
||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden flex items-center justify-center">
|
||||||
|
<div className="absolute -right-32 top-0 h-96 w-96 rounded-full bg-primary/10 blur-3xl animate-blob mix-blend-multiply" />
|
||||||
|
<div className="absolute -left-32 bottom-0 h-96 w-96 rounded-full bg-primary/7 blur-3xl animate-blob animation-delay-2000 mix-blend-multiply" />
|
||||||
|
<div className="absolute -bottom-32 left-1/2 h-80 w-80 rounded-full bg-secondary/30 blur-3xl animate-blob animation-delay-4000 mix-blend-multiply" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-6 flex md:hidden z-20">
|
||||||
|
<Logo variant="compact" colorIcon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-sm md:max-w-5xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,5 @@
|
|||||||
import { LoginForm } from "@/features/auth/components/login-form";
|
import { LoginForm } from "@/features/auth/components/login-form";
|
||||||
import { Logo } from "@/shared/components/logo";
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return <LoginForm />;
|
||||||
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
|
||||||
<div className="pointer-events-none absolute inset-0">
|
|
||||||
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
|
|
||||||
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mb-6 flex md:hidden">
|
|
||||||
<Logo variant="compact" colorIcon />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative w-full max-w-sm md:max-w-5xl">
|
|
||||||
<LoginForm />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import { SignupForm } from "@/features/auth/components/signup-form";
|
import { SignupForm } from "@/features/auth/components/signup-form";
|
||||||
import { Logo } from "@/shared/components/logo";
|
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
return (
|
return <SignupForm />;
|
||||||
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
|
||||||
<div className="pointer-events-none absolute inset-0">
|
|
||||||
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
|
|
||||||
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mb-6 flex md:hidden">
|
|
||||||
<Logo variant="compact" colorIcon />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative w-full max-w-sm md:max-w-5xl">
|
|
||||||
<SignupForm />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/app/api/logo/mapping/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||||
|
import { fetchEstablishmentLogoDomain } from "@/shared/lib/logo/establishment-logo-queries";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/logo/mapping?name={name}
|
||||||
|
*
|
||||||
|
* Retorna o domínio Logo.dev salvo pelo usuário para um estabelecimento.
|
||||||
|
* Usado pelo EstablishmentLogo para hidratar o domain salvo no banco.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await getOptionalUserSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ domain: null }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const name = searchParams.get("name")?.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json({ domain: null }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await fetchEstablishmentLogoDomain(session.user.id, name);
|
||||||
|
return NextResponse.json({ domain });
|
||||||
|
}
|
||||||
80
src/app/api/logo/search/route.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
|
const LOGO_DEV_SEARCH_URL = "https://api.logo.dev/search";
|
||||||
|
|
||||||
|
interface LogoResult {
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchByStrategy(
|
||||||
|
q: string,
|
||||||
|
strategy: "match" | "typeahead",
|
||||||
|
secretKey: string,
|
||||||
|
): Promise<LogoResult[]> {
|
||||||
|
try {
|
||||||
|
const url = `${LOGO_DEV_SEARCH_URL}?q=${encodeURIComponent(q)}&strategy=${strategy}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${secretKey}` },
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/logo/search?q={name}
|
||||||
|
*
|
||||||
|
* Proxy seguro para a Logo.dev Brand Search API.
|
||||||
|
* Faz duas buscas paralelas (match + typeahead) e retorna até 20 resultados únicos.
|
||||||
|
* Usa LOGO_DEV_SECRET_KEY server-side — nunca exposta ao cliente.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await getOptionalUserSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Não autorizado." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const q = searchParams.get("q")?.trim();
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Parâmetro q obrigatório." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretKey = process.env.LOGO_DEV_SECRET_KEY;
|
||||||
|
if (!secretKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Logo.dev não configurado." },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duas buscas paralelas para maximizar resultados (cada uma retorna até 10)
|
||||||
|
const [matchResults, typeaheadResults] = await Promise.all([
|
||||||
|
searchByStrategy(q, "match", secretKey),
|
||||||
|
searchByStrategy(q, "typeahead", secretKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mescla e deduplica por domain, mantendo ordem (match tem prioridade)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: LogoResult[] = [];
|
||||||
|
|
||||||
|
for (const result of [...matchResults, ...typeaheadResults]) {
|
||||||
|
if (!seen.has(result.domain)) {
|
||||||
|
seen.add(result.domain);
|
||||||
|
merged.push(result);
|
||||||
|
if (merged.length >= 20) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(merged);
|
||||||
|
}
|
||||||
@@ -354,3 +354,22 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
animation: blink-out 6s ease-in-out infinite;
|
animation: blink-out 6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes blob {
|
||||||
|
0% { transform: translate(0px, 0px) scale(1); }
|
||||||
|
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||||
|
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||||
|
100% { transform: translate(0px, 0px) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-blob {
|
||||||
|
animation: blob 10s infinite alternate ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-2000 {
|
||||||
|
animation-delay: 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-4000 {
|
||||||
|
animation-delay: 4s;
|
||||||
|
}
|
||||||
|
|||||||
355
src/db/schema.ts
@@ -32,57 +32,69 @@ export const user = pgTable("user", {
|
|||||||
}).notNull(),
|
}).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const account = pgTable("account", {
|
export const account = pgTable(
|
||||||
id: text("id").primaryKey(),
|
"account",
|
||||||
accountId: text("accountId").notNull(),
|
{
|
||||||
providerId: text("providerId").notNull(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("userId")
|
accountId: text("accountId").notNull(),
|
||||||
.notNull()
|
providerId: text("providerId").notNull(),
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
userId: text("userId")
|
||||||
accessToken: text("accessToken"),
|
.notNull()
|
||||||
refreshToken: text("refreshToken"),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
idToken: text("idToken"),
|
accessToken: text("accessToken"),
|
||||||
accessTokenExpiresAt: timestamp("accessTokenExpiresAt", {
|
refreshToken: text("refreshToken"),
|
||||||
mode: "date",
|
idToken: text("idToken"),
|
||||||
withTimezone: true,
|
accessTokenExpiresAt: timestamp("accessTokenExpiresAt", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
scope: text("scope"),
|
||||||
|
password: text("password"),
|
||||||
|
createdAt: timestamp("createdAt", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}).notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}).notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index("account_user_id_idx").on(table.userId),
|
||||||
}),
|
}),
|
||||||
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", {
|
);
|
||||||
mode: "date",
|
|
||||||
withTimezone: true,
|
|
||||||
}),
|
|
||||||
scope: text("scope"),
|
|
||||||
password: text("password"),
|
|
||||||
createdAt: timestamp("createdAt", {
|
|
||||||
mode: "date",
|
|
||||||
withTimezone: true,
|
|
||||||
}).notNull(),
|
|
||||||
updatedAt: timestamp("updatedAt", {
|
|
||||||
mode: "date",
|
|
||||||
withTimezone: true,
|
|
||||||
}).notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const session = pgTable("session", {
|
export const session = pgTable(
|
||||||
id: text("id").primaryKey(),
|
"session",
|
||||||
expiresAt: timestamp("expiresAt", {
|
{
|
||||||
mode: "date",
|
id: text("id").primaryKey(),
|
||||||
withTimezone: true,
|
expiresAt: timestamp("expiresAt", {
|
||||||
}).notNull(),
|
mode: "date",
|
||||||
token: text("token").notNull().unique(),
|
withTimezone: true,
|
||||||
createdAt: timestamp("createdAt", {
|
}).notNull(),
|
||||||
mode: "date",
|
token: text("token").notNull().unique(),
|
||||||
withTimezone: true,
|
createdAt: timestamp("createdAt", {
|
||||||
}).notNull(),
|
mode: "date",
|
||||||
updatedAt: timestamp("updatedAt", {
|
withTimezone: true,
|
||||||
mode: "date",
|
}).notNull(),
|
||||||
withTimezone: true,
|
updatedAt: timestamp("updatedAt", {
|
||||||
}).notNull(),
|
mode: "date",
|
||||||
ipAddress: text("ipAddress"),
|
withTimezone: true,
|
||||||
userAgent: text("userAgent"),
|
}).notNull(),
|
||||||
userId: text("userId")
|
ipAddress: text("ipAddress"),
|
||||||
.notNull()
|
userAgent: text("userAgent"),
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
userId: text("userId")
|
||||||
});
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index("session_user_id_idx").on(table.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const verification = pgTable("verification", {
|
export const verification = pgTable("verification", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -104,24 +116,30 @@ export const verification = pgTable("verification", {
|
|||||||
|
|
||||||
// ===================== PASSKEY (WebAuthn) =====================
|
// ===================== PASSKEY (WebAuthn) =====================
|
||||||
|
|
||||||
export const passkey = pgTable("passkey", {
|
export const passkey = pgTable(
|
||||||
id: text("id").primaryKey(),
|
"passkey",
|
||||||
name: text("name"),
|
{
|
||||||
publicKey: text("publicKey").notNull(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("userId")
|
name: text("name"),
|
||||||
.notNull()
|
publicKey: text("publicKey").notNull(),
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
userId: text("userId")
|
||||||
credentialID: text("credentialID").notNull(),
|
.notNull()
|
||||||
counter: integer("counter").notNull(),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
deviceType: text("deviceType").notNull(),
|
credentialID: text("credentialID").notNull(),
|
||||||
backedUp: boolean("backedUp").notNull(),
|
counter: integer("counter").notNull(),
|
||||||
transports: text("transports"),
|
deviceType: text("deviceType").notNull(),
|
||||||
aaguid: text("aaguid"),
|
backedUp: boolean("backedUp").notNull(),
|
||||||
createdAt: timestamp("createdAt", {
|
transports: text("transports"),
|
||||||
mode: "date",
|
aaguid: text("aaguid"),
|
||||||
withTimezone: true,
|
createdAt: timestamp("createdAt", {
|
||||||
|
mode: "date",
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index("passkey_user_id_idx").on(table.userId),
|
||||||
}),
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
export const userPreferences = pgTable("preferencias_usuario", {
|
export const userPreferences = pgTable("preferencias_usuario", {
|
||||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
@@ -157,39 +175,30 @@ export const userPreferences = pgTable("preferencias_usuario", {
|
|||||||
|
|
||||||
// ===================== PUBLIC TABLES =====================
|
// ===================== PUBLIC TABLES =====================
|
||||||
|
|
||||||
export const financialAccounts = pgTable(
|
export const financialAccounts = pgTable("contas", {
|
||||||
"contas",
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
{
|
name: text("nome").notNull(),
|
||||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
accountType: text("tipo_conta").notNull(),
|
||||||
name: text("nome").notNull(),
|
note: text("anotacao"),
|
||||||
accountType: text("tipo_conta").notNull(),
|
status: text("status").notNull(),
|
||||||
note: text("anotacao"),
|
logo: text("logo").notNull(),
|
||||||
status: text("status").notNull(),
|
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
|
||||||
logo: text("logo").notNull(),
|
.notNull()
|
||||||
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
|
.default("0"),
|
||||||
.notNull()
|
excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false),
|
||||||
.default("0"),
|
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
|
||||||
excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false),
|
.notNull()
|
||||||
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
|
.default(false),
|
||||||
.notNull()
|
userId: text("user_id")
|
||||||
.default(false),
|
.notNull()
|
||||||
userId: text("user_id")
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
.notNull()
|
createdAt: timestamp("created_at", {
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
mode: "date",
|
||||||
createdAt: timestamp("created_at", {
|
withTimezone: true,
|
||||||
mode: "date",
|
})
|
||||||
withTimezone: true,
|
.notNull()
|
||||||
})
|
.defaultNow(),
|
||||||
.notNull()
|
});
|
||||||
.defaultNow(),
|
|
||||||
},
|
|
||||||
(table) => ({
|
|
||||||
userIdStatusIdx: index("contas_user_id_status_idx").on(
|
|
||||||
table.userId,
|
|
||||||
table.status,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const categories = pgTable(
|
export const categories = pgTable(
|
||||||
"categorias",
|
"categorias",
|
||||||
@@ -248,14 +257,6 @@ export const payers = pgTable(
|
|||||||
uniqueShareCode: uniqueIndex("pagadores_share_code_key").on(
|
uniqueShareCode: uniqueIndex("pagadores_share_code_key").on(
|
||||||
table.shareCode,
|
table.shareCode,
|
||||||
),
|
),
|
||||||
userIdStatusIdx: index("pagadores_user_id_status_idx").on(
|
|
||||||
table.userId,
|
|
||||||
table.status,
|
|
||||||
),
|
|
||||||
userIdRoleIdx: index("pagadores_user_id_role_idx").on(
|
|
||||||
table.userId,
|
|
||||||
table.role,
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -285,6 +286,12 @@ export const payerShares = pgTable(
|
|||||||
table.payerId,
|
table.payerId,
|
||||||
table.sharedWithUserId,
|
table.sharedWithUserId,
|
||||||
),
|
),
|
||||||
|
sharedWithUserIdIdx: index(
|
||||||
|
"compartilhamentos_pagador_shared_with_user_id_idx",
|
||||||
|
).on(table.sharedWithUserId),
|
||||||
|
createdByUserIdIdx: index(
|
||||||
|
"compartilhamentos_pagador_created_by_user_id_idx",
|
||||||
|
).on(table.createdByUserId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -317,10 +324,7 @@ export const cards = pgTable(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
userIdStatusIdx: index("cartoes_user_id_status_idx").on(
|
accountIdIdx: index("cartoes_conta_id_idx").on(table.accountId),
|
||||||
table.userId,
|
|
||||||
table.status,
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -387,26 +391,33 @@ export const budgets = pgTable(
|
|||||||
userIdCategoryIdPeriodUnique: uniqueIndex(
|
userIdCategoryIdPeriodUnique: uniqueIndex(
|
||||||
"orcamentos_user_id_categoria_id_periodo_key",
|
"orcamentos_user_id_categoria_id_periodo_key",
|
||||||
).on(table.userId, table.categoryId, table.period),
|
).on(table.userId, table.categoryId, table.period),
|
||||||
|
categoryIdIdx: index("orcamentos_categoria_id_idx").on(table.categoryId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const notes = pgTable("anotacoes", {
|
export const notes = pgTable(
|
||||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
"anotacoes",
|
||||||
title: text("titulo"),
|
{
|
||||||
description: text("descricao"),
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
|
title: text("titulo"),
|
||||||
tasks: text("tasks"), // JSON stringificado com array de tarefas
|
description: text("descricao"),
|
||||||
archived: boolean("arquivada").notNull().default(false),
|
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
|
||||||
createdAt: timestamp("created_at", {
|
tasks: text("tasks"), // JSON stringificado com array de tarefas
|
||||||
mode: "date",
|
archived: boolean("arquivada").notNull().default(false),
|
||||||
withTimezone: true,
|
createdAt: timestamp("created_at", {
|
||||||
})
|
mode: "date",
|
||||||
.notNull()
|
withTimezone: true,
|
||||||
.defaultNow(),
|
})
|
||||||
userId: text("user_id")
|
.notNull()
|
||||||
.notNull()
|
.defaultNow(),
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
userId: text("user_id")
|
||||||
});
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index("anotacoes_user_id_idx").on(table.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const savedInsights = pgTable(
|
export const savedInsights = pgTable(
|
||||||
"insights_salvos",
|
"insights_salvos",
|
||||||
@@ -460,7 +471,6 @@ export const apiTokens = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
userIdIdx: index("tokens_api_user_id_idx").on(table.userId),
|
|
||||||
tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash),
|
tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -524,6 +534,9 @@ export const inboxItems = pgTable(
|
|||||||
table.userId,
|
table.userId,
|
||||||
table.createdAt,
|
table.createdAt,
|
||||||
),
|
),
|
||||||
|
transactionIdIdx: index("pre_lancamentos_lancamento_id_idx").on(
|
||||||
|
table.transactionId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -555,9 +568,6 @@ export const dashboardNotificationStates = pgTable(
|
|||||||
userIdNotificationKeyUnique: uniqueIndex(
|
userIdNotificationKeyUnique: uniqueIndex(
|
||||||
"dashboard_notification_states_user_id_key_unique",
|
"dashboard_notification_states_user_id_key_unique",
|
||||||
).on(table.userId, table.notificationKey),
|
).on(table.userId, table.notificationKey),
|
||||||
userIdArchivedAtIdx: index(
|
|
||||||
"dashboard_notification_states_user_id_archived_idx",
|
|
||||||
).on(table.userId, table.archivedAt),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -597,10 +607,14 @@ export const installmentAnticipations = pgTable(
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
seriesIdIdx: index("antecipacoes_parcelas_series_id_idx").on(
|
|
||||||
table.seriesId,
|
|
||||||
),
|
|
||||||
userIdIdx: index("antecipacoes_parcelas_user_id_idx").on(table.userId),
|
userIdIdx: index("antecipacoes_parcelas_user_id_idx").on(table.userId),
|
||||||
|
transactionIdIdx: index("antecipacoes_parcelas_lancamento_id_idx").on(
|
||||||
|
table.transactionId,
|
||||||
|
),
|
||||||
|
payerIdIdx: index("antecipacoes_parcelas_pagador_id_idx").on(table.payerId),
|
||||||
|
categoryIdIdx: index("antecipacoes_parcelas_categoria_id_idx").on(
|
||||||
|
table.categoryId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -700,6 +714,12 @@ export const transactions = pgTable(
|
|||||||
table.cardId,
|
table.cardId,
|
||||||
table.period,
|
table.period,
|
||||||
),
|
),
|
||||||
|
// FK indexes: evitam seq scan em deletes/updates nas tabelas pai
|
||||||
|
accountIdIdx: index("lancamentos_conta_id_idx").on(table.accountId),
|
||||||
|
categoryIdIdx: index("lancamentos_categoria_id_idx").on(table.categoryId),
|
||||||
|
anticipationIdIdx: index("lancamentos_antecipacao_id_idx").on(
|
||||||
|
table.anticipationId,
|
||||||
|
),
|
||||||
// Dedup OFX: garante FITID único por usuário
|
// Dedup OFX: garante FITID único por usuário
|
||||||
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
|
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
|
||||||
.on(table.userId, table.ofxFitId)
|
.on(table.userId, table.ofxFitId)
|
||||||
@@ -721,6 +741,7 @@ export const userRelations = relations(user, ({ many, one }) => ({
|
|||||||
installmentAnticipations: many(installmentAnticipations),
|
installmentAnticipations: many(installmentAnticipations),
|
||||||
apiTokens: many(apiTokens),
|
apiTokens: many(apiTokens),
|
||||||
inboxItems: many(inboxItems),
|
inboxItems: many(inboxItems),
|
||||||
|
establishmentLogos: many(establishmentLogos),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({ one }) => ({
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
@@ -904,19 +925,25 @@ export const installmentAnticipationsRelations = relations(
|
|||||||
|
|
||||||
// ===================== ATTACHMENTS =====================
|
// ===================== ATTACHMENTS =====================
|
||||||
|
|
||||||
export const attachments = pgTable("anexos", {
|
export const attachments = pgTable(
|
||||||
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
"anexos",
|
||||||
userId: text("user_id")
|
{
|
||||||
.notNull()
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
userId: text("user_id")
|
||||||
fileKey: text("chave_arquivo").notNull().unique(),
|
.notNull()
|
||||||
fileName: text("nome_arquivo").notNull(),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
fileSize: integer("tamanho_bytes").notNull(),
|
fileKey: text("chave_arquivo").notNull().unique(),
|
||||||
mimeType: text("mime_type").notNull(),
|
fileName: text("nome_arquivo").notNull(),
|
||||||
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
fileSize: integer("tamanho_bytes").notNull(),
|
||||||
.notNull()
|
mimeType: text("mime_type").notNull(),
|
||||||
.defaultNow(),
|
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
|
||||||
});
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index("anexos_user_id_idx").on(table.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const transactionAttachments = pgTable(
|
export const transactionAttachments = pgTable(
|
||||||
"lancamento_anexos",
|
"lancamento_anexos",
|
||||||
@@ -952,9 +979,31 @@ export const importCategoryMappings = pgTable(
|
|||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
pk: primaryKey({ columns: [table.userId, table.descriptionKey] }),
|
pk: primaryKey({ columns: [table.userId, table.descriptionKey] }),
|
||||||
|
categoryIdIdx: index("import_category_mappings_category_id_idx").on(
|
||||||
|
table.categoryId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const establishmentLogos = pgTable(
|
||||||
|
"establishment_logos",
|
||||||
|
{
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
nameKey: text("name_key").notNull(),
|
||||||
|
domain: text("domain").notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.userId, table.nameKey] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type EstablishmentLogo = typeof establishmentLogos.$inferSelect;
|
||||||
|
|
||||||
export type User = typeof user.$inferSelect;
|
export type User = typeof user.$inferSelect;
|
||||||
export type NewUser = typeof user.$inferInsert;
|
export type NewUser = typeof user.$inferInsert;
|
||||||
export type Account = typeof account.$inferSelect;
|
export type Account = typeof account.$inferSelect;
|
||||||
@@ -1004,3 +1053,13 @@ export const transactionAttachmentsRelations = relations(
|
|||||||
|
|
||||||
export type Attachment = typeof attachments.$inferSelect;
|
export type Attachment = typeof attachments.$inferSelect;
|
||||||
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
|
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
|
||||||
|
|
||||||
|
export const establishmentLogosRelations = relations(
|
||||||
|
establishmentLogos,
|
||||||
|
({ one }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [establishmentLogos.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import AuthSidebar from "./auth-sidebar";
|
|||||||
|
|
||||||
export function AuthCardShell({ children }: PropsWithChildren) {
|
export function AuthCardShell({ children }: PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden p-0">
|
<Card className="relative overflow-hidden rounded-2xl md:rounded-[2rem] p-0 shadow-lg border-primary/10">
|
||||||
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
|
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,var(--color-primary)_0%,transparent_70%)] opacity-10 blur-3xl animate-blob mix-blend-multiply" />
|
||||||
<DotPattern
|
<DotPattern
|
||||||
width={17}
|
width={17}
|
||||||
height={17}
|
height={17}
|
||||||
@@ -15,11 +16,13 @@ export function AuthCardShell({ children }: PropsWithChildren) {
|
|||||||
cr={1.3}
|
cr={1.3}
|
||||||
className="text-primary/8 mask-[linear-gradient(to_bottom,black,transparent_84%)]"
|
className="text-primary/8 mask-[linear-gradient(to_bottom,black,transparent_84%)]"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-linear-to-br from-primary/6 via-transparent to-transparent" />
|
<div className="absolute inset-0 bg-linear-to-br from-primary/6 via-transparent to-transparent opacity-80" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="relative z-10 grid gap-0 p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr]">
|
<CardContent className="relative z-10 grid gap-0 p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr] overflow-hidden rounded-[inherit]">
|
||||||
<div className="flex bg-card/92 backdrop-blur-[1px]">{children}</div>
|
<div className="flex bg-card/60 backdrop-blur-xl md:rounded-l-[2rem]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
<AuthSidebar />
|
<AuthSidebar />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ export type FeatureItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{ href: "#telas", label: "conheça as telas" },
|
{ href: "#telas", label: "Conheça as telas" },
|
||||||
{ href: "#funcionalidades", label: "funcionalidades" },
|
{ href: "#funcionalidades", label: "Funcionalidades" },
|
||||||
{ href: "#mobile", label: "mobile" },
|
{ href: "#mobile", label: "Mobile" },
|
||||||
{ href: "#stack", label: "stack" },
|
{ href: "#stack", label: "Stack" },
|
||||||
{ href: "#como-usar", label: "como usar" },
|
{ href: "#como-usar", label: "Como usar" },
|
||||||
{ href: "#para-quem-e", label: "para quem é" },
|
{ href: "#para-quem-e", label: "Para quem é" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const mainFeatures: FeatureItem[] = [
|
export const mainFeatures: FeatureItem[] = [
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ function buildColumns({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<EstablishmentLogo name={name} size={28} />
|
<EstablishmentLogo name={name} size={32} />
|
||||||
<span className="flex flex-col py-0.5">
|
<span className="flex flex-col py-0.5">
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-2">
|
<span className="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
{formatDate(purchaseDate)}
|
{formatDate(purchaseDate)}
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ function buildCsp(): string {
|
|||||||
|
|
||||||
const connectExtras = [umamiOrigin, s3Origin].filter(Boolean).join(" ");
|
const connectExtras = [umamiOrigin, s3Origin].filter(Boolean).join(" ");
|
||||||
|
|
||||||
const imgExtras = ["https://lh3.googleusercontent.com", s3Origin]
|
const imgExtras = [
|
||||||
|
"https://lh3.googleusercontent.com",
|
||||||
|
"https://img.logo.dev",
|
||||||
|
s3Origin,
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/shared/components/ui/popover";
|
||||||
|
import { Spinner } from "@/shared/components/ui/spinner";
|
||||||
|
import { buildLogoDevUrl, logoQueryKeys, toNameKey } from "@/shared/lib/logo";
|
||||||
|
import {
|
||||||
|
removeEstablishmentLogoAction,
|
||||||
|
saveEstablishmentLogoAction,
|
||||||
|
} from "@/shared/lib/logo/establishment-logo-actions";
|
||||||
|
import {
|
||||||
|
buildInitials,
|
||||||
|
getCategoryBgColorFromName,
|
||||||
|
getCategoryColorFromName,
|
||||||
|
} from "@/shared/utils/category-colors";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
|
interface LogoResult {
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLogoResults(query: string): Promise<LogoResult[]> {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/logo/search?q=${encodeURIComponent(query.trim())}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EstablishmentLogoPickerProps {
|
||||||
|
name: string;
|
||||||
|
resolvedDomain: string | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSelect: (domain: string | null) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EstablishmentLogoPicker({
|
||||||
|
name,
|
||||||
|
resolvedDomain,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSelect,
|
||||||
|
children,
|
||||||
|
}: EstablishmentLogoPickerProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [searchInput, setSearchInput] = useState(name);
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState(name);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSearchInput(name);
|
||||||
|
setDebouncedSearch(name);
|
||||||
|
}
|
||||||
|
}, [open, name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedSearch(searchInput), 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
const { data: results = [], isLoading } = useQuery({
|
||||||
|
queryKey: logoQueryKeys.search(debouncedSearch),
|
||||||
|
queryFn: () => fetchLogoResults(debouncedSearch),
|
||||||
|
enabled: open && debouncedSearch.trim().length > 0,
|
||||||
|
staleTime: 1000 * 60 * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSelect(domain: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await saveEstablishmentLogoAction(name, domain);
|
||||||
|
queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), {
|
||||||
|
domain,
|
||||||
|
});
|
||||||
|
onSelect(domain);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await removeEstablishmentLogoAction(name);
|
||||||
|
queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), {
|
||||||
|
domain: null,
|
||||||
|
});
|
||||||
|
onSelect(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-3" align="start" side="bottom">
|
||||||
|
<p className="mb-2 text-muted-foreground text-xs">
|
||||||
|
Escolha um logo para <strong>{name}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder="Buscar marca..."
|
||||||
|
className="mb-3 h-8 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-24 items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleReset}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1 rounded-md p-1.5 text-center transition-colors hover:bg-accent disabled:opacity-50",
|
||||||
|
resolvedDomain === null &&
|
||||||
|
"ring-2 ring-primary ring-offset-1",
|
||||||
|
)}
|
||||||
|
title="Usar iniciais coloridas"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center rounded-md font-medium text-xs"
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
backgroundColor: getCategoryBgColorFromName(name),
|
||||||
|
color: getCategoryColorFromName(name),
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{buildInitials(name)}
|
||||||
|
</div>
|
||||||
|
<span className="w-full truncate text-[10px] leading-tight text-muted-foreground">
|
||||||
|
Iniciais
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{results.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.domain}
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => handleSelect(r.domain)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1 rounded-md p-1.5 text-center transition-colors hover:bg-accent disabled:opacity-50",
|
||||||
|
resolvedDomain === r.domain &&
|
||||||
|
"ring-2 ring-primary ring-offset-1",
|
||||||
|
)}
|
||||||
|
title={r.name}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={buildLogoDevUrl(r.domain) ?? ""}
|
||||||
|
alt={r.name}
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="rounded-md object-contain"
|
||||||
|
style={{ width: 36, height: 36 }}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="w-full truncate text-[10px] leading-tight">
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RiPencilLine } from "@remixicon/react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
buildLogoDevUrl,
|
||||||
|
LOGO_DEV_TOKEN,
|
||||||
|
logoQueryKeys,
|
||||||
|
toNameKey,
|
||||||
|
} from "@/shared/lib/logo";
|
||||||
import {
|
import {
|
||||||
buildInitials,
|
buildInitials,
|
||||||
getCategoryBgColorFromName,
|
getCategoryBgColorFromName,
|
||||||
getCategoryColorFromName,
|
getCategoryColorFromName,
|
||||||
} from "@/shared/utils/category-colors";
|
} from "@/shared/utils/category-colors";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
import { EstablishmentLogoPicker } from "./establishment-logo-picker";
|
||||||
|
|
||||||
|
async function fetchLogoMapping(
|
||||||
|
name: string,
|
||||||
|
): Promise<{ domain: string | null }> {
|
||||||
|
const res = await fetch(`/api/logo/mapping?name=${encodeURIComponent(name)}`);
|
||||||
|
if (!res.ok) return { domain: null };
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
interface EstablishmentLogoProps {
|
interface EstablishmentLogoProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Domínio Logo.dev pré-carregado pelo servidor (otimização opcional). */
|
||||||
|
domain?: string | null;
|
||||||
size?: number;
|
size?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EstablishmentLogo({
|
export function EstablishmentLogo({
|
||||||
name,
|
name,
|
||||||
|
domain: initialDomain,
|
||||||
size = 32,
|
size = 32,
|
||||||
className,
|
className,
|
||||||
}: EstablishmentLogoProps) {
|
}: EstablishmentLogoProps) {
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [imgError, setImgError] = useState(false);
|
||||||
|
|
||||||
|
const { data: mappingData } = useQuery({
|
||||||
|
queryKey: logoQueryKeys.mapping(toNameKey(name)),
|
||||||
|
queryFn: () => fetchLogoMapping(name),
|
||||||
|
placeholderData:
|
||||||
|
initialDomain !== undefined
|
||||||
|
? { domain: initialDomain ?? null }
|
||||||
|
: undefined,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
enabled: LOGO_DEV_TOKEN !== undefined && LOGO_DEV_TOKEN !== "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedDomain = mappingData?.domain ?? null;
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: resetar imgError é o efeito de domain mudar
|
||||||
|
useEffect(() => {
|
||||||
|
setImgError(false);
|
||||||
|
}, [resolvedDomain]);
|
||||||
|
|
||||||
|
const logoUrl = buildLogoDevUrl(resolvedDomain);
|
||||||
|
const showLogo = Boolean(logoUrl) && !imgError;
|
||||||
|
|
||||||
const initials = buildInitials(name);
|
const initials = buildInitials(name);
|
||||||
const color = getCategoryColorFromName(name);
|
const color = getCategoryColorFromName(name);
|
||||||
const bgColor = getCategoryBgColorFromName(name);
|
const bgColor = getCategoryBgColorFromName(name);
|
||||||
|
|
||||||
return (
|
const initialsAvatar = (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="flex shrink-0 items-center justify-center rounded-full font-medium"
|
||||||
"flex shrink-0 items-center justify-center rounded-full font-medium",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@@ -38,4 +82,60 @@ export function EstablishmentLogo({
|
|||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const logoImage =
|
||||||
|
showLogo && logoUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt={name}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
className="shrink-0 rounded-full object-cover"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initialsAvatar
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!LOGO_DEV_TOKEN) {
|
||||||
|
return (
|
||||||
|
<div className={cn("shrink-0", className)} aria-hidden>
|
||||||
|
{initialsAvatar}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EstablishmentLogoPicker
|
||||||
|
name={name}
|
||||||
|
resolvedDomain={resolvedDomain}
|
||||||
|
open={pickerOpen}
|
||||||
|
onOpenChange={setPickerOpen}
|
||||||
|
onSelect={() => setPickerOpen(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn("group relative shrink-0 cursor-pointer", className)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title={`Alterar logo de ${name}`}
|
||||||
|
aria-label={`Alterar logo de ${name}`}
|
||||||
|
>
|
||||||
|
{logoImage}
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<RiPencilLine
|
||||||
|
style={{
|
||||||
|
width: Math.max(10, Math.round(size * 0.38)),
|
||||||
|
height: Math.max(10, Math.round(size * 0.38)),
|
||||||
|
}}
|
||||||
|
className="text-white"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</EstablishmentLogoPicker>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export type {
|
|||||||
} from "./category-icon-badge";
|
} from "./category-icon-badge";
|
||||||
export { CategoryIconBadge } from "./category-icon-badge";
|
export { CategoryIconBadge } from "./category-icon-badge";
|
||||||
export { EstablishmentLogo } from "./establishment-logo";
|
export { EstablishmentLogo } from "./establishment-logo";
|
||||||
|
export { EstablishmentLogoPicker } from "./establishment-logo-picker";
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: "/transactions",
|
href: "/transactions",
|
||||||
label: "lançamentos",
|
label: "Lançamentos",
|
||||||
description: "Registre e gerencie suas transações",
|
description: "Registre e gerencie suas transações",
|
||||||
icon: <RiArrowLeftRightLine className="size-4" />,
|
icon: <RiArrowLeftRightLine className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
@@ -45,14 +45,14 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/inbox",
|
href: "/inbox",
|
||||||
label: "pré-lançamentos",
|
label: "Pré-lançamentos",
|
||||||
description: "Notificações capturadas pelo Companion",
|
description: "Notificações capturadas pelo Companion",
|
||||||
icon: <RiAtLine className="size-4" />,
|
icon: <RiAtLine className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/calendar",
|
href: "/calendar",
|
||||||
label: "calendário",
|
label: "Calendário",
|
||||||
description: "Visualize lançamentos por dia",
|
description: "Visualize lançamentos por dia",
|
||||||
icon: <RiCalendarEventLine className="size-4" />,
|
icon: <RiCalendarEventLine className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
@@ -65,21 +65,21 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: "/cards",
|
href: "/cards",
|
||||||
label: "cartões",
|
label: "Cartões",
|
||||||
description: "Faturas e limites dos seus cartões",
|
description: "Faturas e limites dos seus cartões",
|
||||||
icon: <RiBankCard2Line className="size-4" />,
|
icon: <RiBankCard2Line className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/accounts",
|
href: "/accounts",
|
||||||
label: "contas",
|
label: "Contas",
|
||||||
description: "Saldos e extratos bancários",
|
description: "Saldos e extratos bancários",
|
||||||
icon: <RiBankLine className="size-4" />,
|
icon: <RiBankLine className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/budgets",
|
href: "/budgets",
|
||||||
label: "orçamentos",
|
label: "Orçamentos",
|
||||||
description: "Defina limites de gastos por categoria",
|
description: "Defina limites de gastos por categoria",
|
||||||
icon: <RiBarChart2Line className="size-4" />,
|
icon: <RiBarChart2Line className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
@@ -92,28 +92,28 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: "/payers",
|
href: "/payers",
|
||||||
label: "pagadores",
|
label: "Pagadores",
|
||||||
description: "Gerencie quem divide as despesas",
|
description: "Gerencie quem divide as despesas",
|
||||||
icon: <RiGroupLine className="size-4" />,
|
icon: <RiGroupLine className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/categories",
|
href: "/categories",
|
||||||
label: "categorias",
|
label: "Categorias",
|
||||||
description: "Agrupe seus lançamentos",
|
description: "Agrupe seus lançamentos",
|
||||||
icon: <RiPriceTag3Line className="size-4" />,
|
icon: <RiPriceTag3Line className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/notes",
|
href: "/notes",
|
||||||
label: "anotações",
|
label: "Anotações",
|
||||||
description: "Guarde lembretes e observações",
|
description: "Guarde lembretes e observações",
|
||||||
icon: <RiTodoLine className="size-4" />,
|
icon: <RiTodoLine className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/attachments",
|
href: "/attachments",
|
||||||
label: "anexos",
|
label: "Anexos",
|
||||||
description: "Comprovantes e documentos",
|
description: "Comprovantes e documentos",
|
||||||
icon: <RiAttachmentLine className="size-4" />,
|
icon: <RiAttachmentLine className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
@@ -126,7 +126,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: "/insights",
|
href: "/insights",
|
||||||
label: "insights",
|
label: "Insights",
|
||||||
description: "Análises inteligentes dos seus dados",
|
description: "Análises inteligentes dos seus dados",
|
||||||
icon: <RiSparklingLine className="size-4" />,
|
icon: <RiSparklingLine className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
@@ -134,14 +134,14 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/reports/category-trends",
|
href: "/reports/category-trends",
|
||||||
label: "tendências",
|
label: "Tendências",
|
||||||
description: "Evolução de gastos por categoria",
|
description: "Evolução de gastos por categoria",
|
||||||
icon: <RiFileChartLine className="size-4" />,
|
icon: <RiFileChartLine className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/reports/card-usage",
|
href: "/reports/card-usage",
|
||||||
label: "uso de cartões",
|
label: "Uso de cartões",
|
||||||
description: "Resumo de gastos por cartão",
|
description: "Resumo de gastos por cartão",
|
||||||
icon: <RiBankCard2Line className="size-4" />,
|
icon: <RiBankCard2Line className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
@@ -149,14 +149,14 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/reports/installment-analysis",
|
href: "/reports/installment-analysis",
|
||||||
label: "análise de parcelas",
|
label: "Análise de parcelas",
|
||||||
description: "Acompanhe parcelas em aberto",
|
description: "Acompanhe parcelas em aberto",
|
||||||
icon: <RiSecurePaymentLine className="size-4" />,
|
icon: <RiSecurePaymentLine className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/reports/establishments",
|
href: "/reports/establishments",
|
||||||
label: "estabelecimentos",
|
label: "Estabelecimentos",
|
||||||
description: "Top gastos por estabelecimento",
|
description: "Top gastos por estabelecimento",
|
||||||
icon: <RiStore2Line className="size-4" />,
|
icon: <RiStore2Line className="size-4" />,
|
||||||
iconClass: "text-primary",
|
iconClass: "text-primary",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiDashboardLine, RiMenuLine } from "@remixicon/react";
|
import { RiDashboardLine, RiMenuLine } from "@remixicon/react";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CalculatorDialogContent } from "@/shared/components/calculator/calculator-dialog";
|
import { CalculatorDialogContent } from "@/shared/components/calculator/calculator-dialog";
|
||||||
@@ -28,7 +29,7 @@ import { NavPill } from "./nav-pill";
|
|||||||
import { MobileTools, NavToolsDropdown } from "./nav-tools";
|
import { MobileTools, NavToolsDropdown } from "./nav-tools";
|
||||||
|
|
||||||
const triggerClass =
|
const triggerClass =
|
||||||
"h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! lowercase! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10!";
|
"h-8! rounded-md! px-2! py-0! text-sm! font-medium! bg-transparent! shadow-none! capitalize! [&_svg]:text-current! text-black/75! hover:text-black! hover:bg-black/10! focus:text-black! focus:bg-black/10! focus-visible:ring-black/20! data-[state=open]:text-black! data-[state=open]:bg-black/10!";
|
||||||
|
|
||||||
const triggerActiveClass = "bg-black/15! text-black!";
|
const triggerActiveClass = "bg-black/15! text-black!";
|
||||||
|
|
||||||
@@ -42,9 +43,9 @@ export function NavMenu() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop */}
|
{/* Desktop */}
|
||||||
<nav className="hidden md:flex items-center justify-center flex-1">
|
<nav className="hidden md:flex items-center justify-center flex-1 gap-4">
|
||||||
<NavigationMenu viewport={false}>
|
<NavigationMenu viewport={false}>
|
||||||
<NavigationMenuList className="gap-0">
|
<NavigationMenuList className="gap-2">
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavPill href="/dashboard" preservePeriod>
|
<NavPill href="/dashboard" preservePeriod>
|
||||||
Dashboard
|
Dashboard
|
||||||
@@ -63,6 +64,7 @@ export function NavMenu() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
triggerClass,
|
triggerClass,
|
||||||
isSectionActive && triggerActiveClass,
|
isSectionActive && triggerActiveClass,
|
||||||
|
"capitalize",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{section.label}
|
{section.label}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function NavPill({ href, preservePeriod, children }: NavPillProps) {
|
|||||||
preservePeriod={preservePeriod}
|
preservePeriod={preservePeriod}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "navbar", size: "sm" }),
|
buttonVariants({ variant: "navbar", size: "sm" }),
|
||||||
"lowercase",
|
"capitalize",
|
||||||
isActive && "bg-black/15 text-black",
|
isActive && "bg-black/15 text-black",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function NavToolsDropdown({ onOpenCalculator }: NavToolsDropdownProps) {
|
|||||||
<RiCalculatorLine className="size-4" />
|
<RiCalculatorLine className="size-4" />
|
||||||
</span>
|
</span>
|
||||||
<span className="flex flex-col flex-1 text-left">
|
<span className="flex flex-col flex-1 text-left">
|
||||||
<span className="font-semibold">calculadora</span>
|
<span className="font-medium">Calculadora</span>
|
||||||
<span className="text-xs text-muted-foreground lowercase">
|
<span className="text-xs text-muted-foreground lowercase">
|
||||||
Faça cálculos rápidos
|
Faça cálculos rápidos
|
||||||
</span>
|
</span>
|
||||||
@@ -39,7 +39,7 @@ export function NavToolsDropdown({ onOpenCalculator }: NavToolsDropdownProps) {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex flex-col flex-1 text-left">
|
<span className="flex flex-col flex-1 text-left">
|
||||||
<span className="font-semibold">privacidade</span>
|
<span className="font-medium">Privacidade</span>
|
||||||
<span className="text-xs text-muted-foreground lowercase">
|
<span className="text-xs text-muted-foreground lowercase">
|
||||||
Oculta valores na tela
|
Oculta valores na tela
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||||
import { RiArrowDropDownLine } from "@remixicon/react";
|
import { RiArrowDropDownLine } from "@remixicon/react";
|
||||||
import { cva } from "class-variance-authority";
|
import { cva } from "class-variance-authority";
|
||||||
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Slider as SliderPrimitive } from "radix-ui";
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
|
|||||||
64
src/shared/lib/logo/establishment-logo-actions.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { establishmentLogos } from "@/db/schema";
|
||||||
|
import type { ActionResult } from "@/shared/lib/actions/helpers";
|
||||||
|
import {
|
||||||
|
handleActionError,
|
||||||
|
revalidateForEntity,
|
||||||
|
} from "@/shared/lib/actions/helpers";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { toNameKey } from "@/shared/lib/logo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salva ou atualiza o domínio Logo.dev preferido para um estabelecimento.
|
||||||
|
*/
|
||||||
|
export async function saveEstablishmentLogoAction(
|
||||||
|
name: string,
|
||||||
|
domain: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const userId = await getUserId();
|
||||||
|
const nameKey = toNameKey(name);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(establishmentLogos)
|
||||||
|
.values({ userId, nameKey, domain })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [establishmentLogos.userId, establishmentLogos.nameKey],
|
||||||
|
set: { domain, updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateForEntity("establishments", userId);
|
||||||
|
return { success: true, message: "Logo salvo." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove o mapeamento salvo, voltando ao comportamento automático do Logo.dev.
|
||||||
|
*/
|
||||||
|
export async function removeEstablishmentLogoAction(
|
||||||
|
name: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const userId = await getUserId();
|
||||||
|
const nameKey = toNameKey(name);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(establishmentLogos)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(establishmentLogos.userId, userId),
|
||||||
|
eq(establishmentLogos.nameKey, nameKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidateForEntity("establishments", userId);
|
||||||
|
return { success: true, message: "Logo restaurado." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/shared/lib/logo/establishment-logo-queries.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { establishmentLogos } from "@/db/schema";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { toNameKey } from "@/shared/lib/logo";
|
||||||
|
|
||||||
|
export { toNameKey };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca o domínio salvo para um único estabelecimento.
|
||||||
|
*/
|
||||||
|
export async function fetchEstablishmentLogoDomain(
|
||||||
|
userId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const nameKey = toNameKey(name);
|
||||||
|
const row = await db.query.establishmentLogos.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(establishmentLogos.userId, userId),
|
||||||
|
eq(establishmentLogos.nameKey, nameKey),
|
||||||
|
),
|
||||||
|
columns: { domain: true },
|
||||||
|
});
|
||||||
|
return row?.domain ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca domínios salvos para múltiplos nomes de uma vez (evita N+1).
|
||||||
|
* Retorna um Map de nameKey → domain.
|
||||||
|
*/
|
||||||
|
export async function fetchEstablishmentLogoMap(
|
||||||
|
userId: string,
|
||||||
|
names: string[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const nameKeys = [...new Set(names.map(toNameKey))];
|
||||||
|
if (nameKeys.length === 0) return new Map();
|
||||||
|
|
||||||
|
const rows = await db.query.establishmentLogos.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(establishmentLogos.userId, userId),
|
||||||
|
inArray(establishmentLogos.nameKey, nameKeys),
|
||||||
|
),
|
||||||
|
columns: { nameKey: true, domain: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Map(rows.map((r) => [r.nameKey, r.domain]));
|
||||||
|
}
|
||||||
@@ -39,6 +39,27 @@ export const deriveNameFromLogo = (logo?: string | null) => {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliza o nome do estabelecimento para usar como chave de lookup no banco.
|
||||||
|
*/
|
||||||
|
export const toNameKey = (name: string): string => name.trim().toLowerCase();
|
||||||
|
|
||||||
|
// === Logo.dev ===
|
||||||
|
|
||||||
|
export const LOGO_DEV_TOKEN = process.env.NEXT_PUBLIC_LOGO_DEV_TOKEN;
|
||||||
|
|
||||||
|
export function buildLogoDevUrl(domain?: string | null): string | null {
|
||||||
|
if (!LOGO_DEV_TOKEN || !domain) return null;
|
||||||
|
return `https://img.logo.dev/${domain}?token=${LOGO_DEV_TOKEN}&size=64&format=png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logoQueryKeys = {
|
||||||
|
mapping: (nameKey: string) => ["logo-mapping", nameKey] as const,
|
||||||
|
search: (query: string) => ["logo-search", query] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Local logo resolution ===
|
||||||
|
|
||||||
const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/;
|
const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/;
|
||||||
|
|
||||||
type ResolveLogoSrcOptions = {
|
type ResolveLogoSrcOptions = {
|
||||||
|
|||||||