10 Commits

Author SHA1 Message Date
Felipe Coutinho
3e80d5995b chore(release): v2.4.1
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:44 +00:00
Felipe Coutinho
68daae7926 fix(docker): fixar PGDATA para compatibilidade com postgres:18-alpine (#41)
Container do PostgreSQL falhava ao iniciar em instalações existentes
após atualização da imagem: o entrypoint passou a recusar dados no
caminho legado. Variável PGDATA fixa o caminho e preserva dados de
quem já tinha o volume populado.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:40 +00:00
Felipe Coutinho
9413c470a8 style(ui): restaurar indentação tabs no dashboard layout
PR #42 trocou tabs por spaces no arquivo inteiro, quebrando o Biome.
Revertido pelo lint:fix para manter consistência com o resto do projeto.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:35 +00:00
Felipe Coutinho
ad1b0aa979 refactor(settings): remover tab órfã de Integrações
A tab foi introduzida no PR #42 mas não tinha TabsContent correspondente
e o value tinha typo ("intergrations") — UI vazia.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:31 +00:00
Felipe Coutinho
4d9a1c0a35 perf(db): otimizar índices — remover 7 sem uso, adicionar 17 em FKs
Baseado em análise do pg_stat_user_indexes (187 dias de estatísticas):
removidos 7 índices com 0 scans e adicionados 17 índices em foreign
keys que antes geravam sequential scans durante deletes nas tabelas pai.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 21:16:26 +00:00
Felipe Coutinho
5635705c56 feat(ui): layout animado auth, capitalização navbar (PR #42) 2026-04-16 15:19:53 +00:00
Alexsandro
4c97ed569d Merge branch 'main' into feat/fix-ui 2026-04-16 11:52:37 -03:00
Alexsandro
22a88de993 style(ui): update auth pages layout and navigation capitalization
This commit improves the visual design of the auth pages by adding a new layout wrapper with an animated blob background effect and updating the auth card shell with a glassmorphism style. It also updates the navigation items to use capitalized labels instead of lowercase for better readability.
2026-04-15 14:35:44 -03:00
Felipe Coutinho
9456aa98bc fix(ci): passar NEXT_PUBLIC_LOGO_DEV_TOKEN como build arg no Docker
NEXT_PUBLIC_* é inlined pelo Next.js em build time — a variável precisa
ser injetada via ARG no Dockerfile e build-args no workflow do CI.
Sem isso, o token fica undefined e os logos nunca são exibidos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:06:23 +00:00
Felipe Coutinho
21c6a8d9d0 fix(lint): corrigir schema biome.json e formatação de imports
- biome.json: bump schema 2.4.10 → 2.4.11
- establishment-logo-picker.tsx, establishment-logo.tsx, navigation-menu.tsx, logo/index.ts: organizar imports e ajustar formatação conforme Biome 2.4.11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:31:46 +00:00
25 changed files with 3255 additions and 229 deletions

View File

@@ -85,6 +85,8 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
build-args: |
NEXT_PUBLIC_LOGO_DEV_TOKEN=${{ secrets.NEXT_PUBLIC_LOGO_DEV_TOKEN }}
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}

View File

@@ -7,6 +7,26 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [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

View File

@@ -40,6 +40,10 @@ COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
ENV NEXT_TELEMETRY_DISABLED=1 \
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
RUN pnpm build

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.4.0-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.4.1-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -390,6 +390,38 @@ 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
**Perfil 2 (dev):** copie `.env.example` para `.env` — o `DATABASE_URL` já vem com `localhost`, pronto para uso com `pnpm dev`.
@@ -437,6 +469,11 @@ ANTHROPIC_API_KEY=
OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_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=
```
---

View File

@@ -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": {
"enabled": true,
"clientKind": "git",

View File

@@ -10,6 +10,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
PGDATA: /var/lib/postgresql/data
ports:
- "${DB_PORT:-5432}:5432"
volumes:

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -176,6 +176,13 @@
"when": 1774891206703,
"tag": "0024_petite_lucky_pierre",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1776351838548,
"tag": "0025_burly_colonel_america",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "openmonetis",
"version": "2.4.0",
"version": "2.4.1",
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {

23
src/app/(auth)/layout.tsx Normal file
View 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>
);
}

View File

@@ -1,21 +1,5 @@
import { LoginForm } from "@/features/auth/components/login-form";
import { Logo } from "@/shared/components/logo";
export default function LoginPage() {
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">
<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>
);
return <LoginForm />;
}

View File

@@ -1,21 +1,5 @@
import { SignupForm } from "@/features/auth/components/signup-form";
import { Logo } from "@/shared/components/logo";
export default function SignupPage() {
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">
<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>
);
return <SignupForm />;
}

View File

@@ -354,3 +354,22 @@
justify-content: flex-end;
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;
}

View File

@@ -32,57 +32,69 @@ export const user = pgTable("user", {
}).notNull(),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: timestamp("accessTokenExpiresAt", {
mode: "date",
withTimezone: true,
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
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", {
id: text("id").primaryKey(),
expiresAt: timestamp("expiresAt", {
mode: "date",
withTimezone: true,
}).notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}).notNull(),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}).notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expiresAt", {
mode: "date",
withTimezone: true,
}).notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}).notNull(),
updatedAt: timestamp("updatedAt", {
mode: "date",
withTimezone: true,
}).notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => ({
userIdIdx: index("session_user_id_idx").on(table.userId),
}),
);
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
@@ -104,24 +116,30 @@ export const verification = pgTable("verification", {
// ===================== PASSKEY (WebAuthn) =====================
export const passkey = pgTable("passkey", {
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("publicKey").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credentialID").notNull(),
counter: integer("counter").notNull(),
deviceType: text("deviceType").notNull(),
backedUp: boolean("backedUp").notNull(),
transports: text("transports"),
aaguid: text("aaguid"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
export const passkey = pgTable(
"passkey",
{
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("publicKey").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credentialID").notNull(),
counter: integer("counter").notNull(),
deviceType: text("deviceType").notNull(),
backedUp: boolean("backedUp").notNull(),
transports: text("transports"),
aaguid: text("aaguid"),
createdAt: timestamp("createdAt", {
mode: "date",
withTimezone: true,
}),
},
(table) => ({
userIdIdx: index("passkey_user_id_idx").on(table.userId),
}),
});
);
export const userPreferences = pgTable("preferencias_usuario", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
@@ -157,39 +175,30 @@ export const userPreferences = pgTable("preferencias_usuario", {
// ===================== PUBLIC TABLES =====================
export const financialAccounts = pgTable(
"contas",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
accountType: text("tipo_conta").notNull(),
note: text("anotacao"),
status: text("status").notNull(),
logo: text("logo").notNull(),
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
.notNull()
.default("0"),
excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false),
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
.notNull()
.default(false),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
userIdStatusIdx: index("contas_user_id_status_idx").on(
table.userId,
table.status,
),
}),
);
export const financialAccounts = pgTable("contas", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
accountType: text("tipo_conta").notNull(),
note: text("anotacao"),
status: text("status").notNull(),
logo: text("logo").notNull(),
initialBalance: numeric("saldo_inicial", { precision: 12, scale: 2 })
.notNull()
.default("0"),
excludeFromBalance: boolean("excluir_do_saldo").notNull().default(false),
excludeInitialBalanceFromIncome: boolean("excluir_saldo_inicial_receitas")
.notNull()
.default(false),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
});
export const categories = pgTable(
"categorias",
@@ -248,14 +257,6 @@ export const payers = pgTable(
uniqueShareCode: uniqueIndex("pagadores_share_code_key").on(
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.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) => ({
userIdStatusIdx: index("cartoes_user_id_status_idx").on(
table.userId,
table.status,
),
accountIdIdx: index("cartoes_conta_id_idx").on(table.accountId),
}),
);
@@ -387,26 +391,33 @@ export const budgets = pgTable(
userIdCategoryIdPeriodUnique: uniqueIndex(
"orcamentos_user_id_categoria_id_periodo_key",
).on(table.userId, table.categoryId, table.period),
categoryIdIdx: index("orcamentos_categoria_id_idx").on(table.categoryId),
}),
);
export const notes = pgTable("anotacoes", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
title: text("titulo"),
description: text("descricao"),
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
tasks: text("tasks"), // JSON stringificado com array de tarefas
archived: boolean("arquivada").notNull().default(false),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const notes = pgTable(
"anotacoes",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
title: text("titulo"),
description: text("descricao"),
type: text("tipo").notNull().default("nota"), // "nota" ou "tarefa"
tasks: text("tasks"), // JSON stringificado com array de tarefas
archived: boolean("arquivada").notNull().default(false),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => ({
userIdIdx: index("anotacoes_user_id_idx").on(table.userId),
}),
);
export const savedInsights = pgTable(
"insights_salvos",
@@ -460,7 +471,6 @@ export const apiTokens = pgTable(
.defaultNow(),
},
(table) => ({
userIdIdx: index("tokens_api_user_id_idx").on(table.userId),
tokenHashIdx: uniqueIndex("tokens_api_token_hash_idx").on(table.tokenHash),
}),
);
@@ -524,6 +534,9 @@ export const inboxItems = pgTable(
table.userId,
table.createdAt,
),
transactionIdIdx: index("pre_lancamentos_lancamento_id_idx").on(
table.transactionId,
),
}),
);
@@ -555,9 +568,6 @@ export const dashboardNotificationStates = pgTable(
userIdNotificationKeyUnique: uniqueIndex(
"dashboard_notification_states_user_id_key_unique",
).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(),
},
(table) => ({
seriesIdIdx: index("antecipacoes_parcelas_series_id_idx").on(
table.seriesId,
),
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.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
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
.on(table.userId, table.ofxFitId)
@@ -905,19 +925,25 @@ export const installmentAnticipationsRelations = relations(
// ===================== ATTACHMENTS =====================
export const attachments = pgTable("anexos", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
fileKey: text("chave_arquivo").notNull().unique(),
fileName: text("nome_arquivo").notNull(),
fileSize: integer("tamanho_bytes").notNull(),
mimeType: text("mime_type").notNull(),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
});
export const attachments = pgTable(
"anexos",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
fileKey: text("chave_arquivo").notNull().unique(),
fileName: text("nome_arquivo").notNull(),
fileSize: integer("tamanho_bytes").notNull(),
mimeType: text("mime_type").notNull(),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
userIdIdx: index("anexos_user_id_idx").on(table.userId),
}),
);
export const transactionAttachments = pgTable(
"lancamento_anexos",
@@ -953,6 +979,9 @@ export const importCategoryMappings = pgTable(
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.descriptionKey] }),
categoryIdIdx: index("import_category_mappings_category_id_idx").on(
table.categoryId,
),
}),
);

View File

@@ -5,8 +5,9 @@ import AuthSidebar from "./auth-sidebar";
export function AuthCardShell({ children }: PropsWithChildren) {
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="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
width={17}
height={17}
@@ -15,11 +16,13 @@ export function AuthCardShell({ children }: PropsWithChildren) {
cr={1.3}
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>
<CardContent className="relative z-10 grid gap-0 p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr]">
<div className="flex bg-card/92 backdrop-blur-[1px]">{children}</div>
<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/60 backdrop-blur-xl md:rounded-l-[2rem]">
{children}
</div>
<AuthSidebar />
</CardContent>
</Card>

View File

@@ -36,12 +36,12 @@ export type FeatureItem = {
};
export const navLinks = [
{ href: "#telas", label: "conheça as telas" },
{ href: "#funcionalidades", label: "funcionalidades" },
{ href: "#mobile", label: "mobile" },
{ href: "#stack", label: "stack" },
{ href: "#como-usar", label: "como usar" },
{ href: "#para-quem-e", label: "para quem é" },
{ href: "#telas", label: "Conheça as telas" },
{ href: "#funcionalidades", label: "Funcionalidades" },
{ href: "#mobile", label: "Mobile" },
{ href: "#stack", label: "Stack" },
{ href: "#como-usar", label: "Como usar" },
{ href: "#para-quem-e", label: "Para quem é" },
] as const;
export const mainFeatures: FeatureItem[] = [

View File

@@ -9,11 +9,7 @@ import {
PopoverTrigger,
} from "@/shared/components/ui/popover";
import { Spinner } from "@/shared/components/ui/spinner";
import {
buildLogoDevUrl,
logoQueryKeys,
toNameKey,
} from "@/shared/lib/logo";
import { buildLogoDevUrl, logoQueryKeys, toNameKey } from "@/shared/lib/logo";
import {
removeEstablishmentLogoAction,
saveEstablishmentLogoAction,
@@ -84,7 +80,9 @@ export function EstablishmentLogoPicker({
function handleSelect(domain: string) {
startTransition(async () => {
await saveEstablishmentLogoAction(name, domain);
queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), { domain });
queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), {
domain,
});
onSelect(domain);
});
}
@@ -128,7 +126,8 @@ export function EstablishmentLogoPicker({
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",
resolvedDomain === null &&
"ring-2 ring-primary ring-offset-1",
)}
title="Usar iniciais coloridas"
>
@@ -157,7 +156,8 @@ export function EstablishmentLogoPicker({
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",
resolvedDomain === r.domain &&
"ring-2 ring-primary ring-offset-1",
)}
title={r.name}
>

View File

@@ -4,8 +4,8 @@ import { RiPencilLine } from "@remixicon/react";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import {
LOGO_DEV_TOKEN,
buildLogoDevUrl,
LOGO_DEV_TOKEN,
logoQueryKeys,
toNameKey,
} from "@/shared/lib/logo";

View File

@@ -37,7 +37,7 @@ export const NAV_SECTIONS: NavSection[] = [
items: [
{
href: "/transactions",
label: "lançamentos",
label: "Lançamentos",
description: "Registre e gerencie suas transações",
icon: <RiArrowLeftRightLine className="size-4" />,
iconClass: "text-primary",
@@ -45,14 +45,14 @@ export const NAV_SECTIONS: NavSection[] = [
},
{
href: "/inbox",
label: "pré-lançamentos",
label: "Pré-lançamentos",
description: "Notificações capturadas pelo Companion",
icon: <RiAtLine className="size-4" />,
iconClass: "text-primary",
},
{
href: "/calendar",
label: "calendário",
label: "Calendário",
description: "Visualize lançamentos por dia",
icon: <RiCalendarEventLine className="size-4" />,
iconClass: "text-primary",
@@ -65,21 +65,21 @@ export const NAV_SECTIONS: NavSection[] = [
items: [
{
href: "/cards",
label: "cartões",
label: "Cartões",
description: "Faturas e limites dos seus cartões",
icon: <RiBankCard2Line className="size-4" />,
iconClass: "text-primary",
},
{
href: "/accounts",
label: "contas",
label: "Contas",
description: "Saldos e extratos bancários",
icon: <RiBankLine className="size-4" />,
iconClass: "text-primary",
},
{
href: "/budgets",
label: "orçamentos",
label: "Orçamentos",
description: "Defina limites de gastos por categoria",
icon: <RiBarChart2Line className="size-4" />,
iconClass: "text-primary",
@@ -92,28 +92,28 @@ export const NAV_SECTIONS: NavSection[] = [
items: [
{
href: "/payers",
label: "pagadores",
label: "Pagadores",
description: "Gerencie quem divide as despesas",
icon: <RiGroupLine className="size-4" />,
iconClass: "text-primary",
},
{
href: "/categories",
label: "categorias",
label: "Categorias",
description: "Agrupe seus lançamentos",
icon: <RiPriceTag3Line className="size-4" />,
iconClass: "text-primary",
},
{
href: "/notes",
label: "anotações",
label: "Anotações",
description: "Guarde lembretes e observações",
icon: <RiTodoLine className="size-4" />,
iconClass: "text-primary",
},
{
href: "/attachments",
label: "anexos",
label: "Anexos",
description: "Comprovantes e documentos",
icon: <RiAttachmentLine className="size-4" />,
iconClass: "text-primary",
@@ -126,7 +126,7 @@ export const NAV_SECTIONS: NavSection[] = [
items: [
{
href: "/insights",
label: "insights",
label: "Insights",
description: "Análises inteligentes dos seus dados",
icon: <RiSparklingLine className="size-4" />,
iconClass: "text-primary",
@@ -134,14 +134,14 @@ export const NAV_SECTIONS: NavSection[] = [
},
{
href: "/reports/category-trends",
label: "tendências",
label: "Tendências",
description: "Evolução de gastos por categoria",
icon: <RiFileChartLine className="size-4" />,
iconClass: "text-primary",
},
{
href: "/reports/card-usage",
label: "uso de cartões",
label: "Uso de cartões",
description: "Resumo de gastos por cartão",
icon: <RiBankCard2Line className="size-4" />,
iconClass: "text-primary",
@@ -149,14 +149,14 @@ export const NAV_SECTIONS: NavSection[] = [
},
{
href: "/reports/installment-analysis",
label: "análise de parcelas",
label: "Análise de parcelas",
description: "Acompanhe parcelas em aberto",
icon: <RiSecurePaymentLine className="size-4" />,
iconClass: "text-primary",
},
{
href: "/reports/establishments",
label: "estabelecimentos",
label: "Estabelecimentos",
description: "Top gastos por estabelecimento",
icon: <RiStore2Line className="size-4" />,
iconClass: "text-primary",

View File

@@ -1,6 +1,7 @@
"use client";
import { RiDashboardLine, RiMenuLine } from "@remixicon/react";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { CalculatorDialogContent } from "@/shared/components/calculator/calculator-dialog";
@@ -28,7 +29,7 @@ import { NavPill } from "./nav-pill";
import { MobileTools, NavToolsDropdown } from "./nav-tools";
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!";
@@ -42,9 +43,9 @@ export function NavMenu() {
return (
<>
{/* 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}>
<NavigationMenuList className="gap-0">
<NavigationMenuList className="gap-2">
<NavigationMenuItem>
<NavPill href="/dashboard" preservePeriod>
Dashboard
@@ -63,6 +64,7 @@ export function NavMenu() {
className={cn(
triggerClass,
isSectionActive && triggerActiveClass,
"capitalize",
)}
>
{section.label}

View File

@@ -25,7 +25,7 @@ export function NavPill({ href, preservePeriod, children }: NavPillProps) {
preservePeriod={preservePeriod}
className={cn(
buttonVariants({ variant: "navbar", size: "sm" }),
"lowercase",
"capitalize",
isActive && "bg-black/15 text-black",
)}
>

View File

@@ -22,7 +22,7 @@ export function NavToolsDropdown({ onOpenCalculator }: NavToolsDropdownProps) {
<RiCalculatorLine className="size-4" />
</span>
<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">
Faça cálculos rápidos
</span>
@@ -39,7 +39,7 @@ export function NavToolsDropdown({ onOpenCalculator }: NavToolsDropdownProps) {
)}
</span>
<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">
Oculta valores na tela
</span>

View File

@@ -1,6 +1,6 @@
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { RiArrowDropDownLine } from "@remixicon/react";
import { cva } from "class-variance-authority";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import type * as React from "react";
import { cn } from "@/shared/utils";

View File

@@ -48,9 +48,7 @@ export const toNameKey = (name: string): string => name.trim().toLowerCase();
export const LOGO_DEV_TOKEN = process.env.NEXT_PUBLIC_LOGO_DEV_TOKEN;
export function buildLogoDevUrl(
domain?: string | null,
): string | null {
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`;
}