From 3d3a9e1414a0305e2ef2b5033ee02319521f5fc0 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Mon, 2 Mar 2026 01:33:05 +0000 Subject: [PATCH 1/4] feat(auth): implementar passkeys e gerenciamento em ajustes --- CHANGELOG.md | 19 + app/(dashboard)/ajustes/page.tsx | 17 + app/globals.css | 2 +- components/ajustes/passkeys-form.tsx | 418 +++++ components/auth/login-form.tsx | 83 +- db/schema.ts | 21 + drizzle/0017_previous_warstar.sql | 17 + drizzle/meta/0017_snapshot.json | 2293 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/auth/client.ts | 2 + lib/auth/config.ts | 8 + package.json | 3 +- pnpm-lock.yaml | 281 ++++ 13 files changed, 3164 insertions(+), 7 deletions(-) create mode 100644 components/ajustes/passkeys-form.tsx create mode 100644 drizzle/0017_previous_warstar.sql create mode 100644 drizzle/meta/0017_snapshot.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bde629e..3aedcd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo. O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). +## [1.7.6] - 2026-03-02 + +### Adicionado + +- Suporte completo a Passkeys (WebAuthn) com plugin `@better-auth/passkey` no servidor e `passkeyClient` no cliente de autenticação +- Tabela `passkey` no banco de dados para persistência de credenciais WebAuthn vinculadas ao usuário +- Nova aba **Passkeys** em `/ajustes` com gerenciamento de credenciais: listar, adicionar, renomear e remover passkeys +- Ação de login com passkey na tela de autenticação (`/login`) + +### Alterado + +- `PasskeysForm` refatorado para melhor experiência com React 19/Next 16: detecção de suporte do navegador, bloqueio de ações simultâneas e atualização da lista sem loader global após operações + +### Corrigido + +- Login com passkey na tela de autenticação agora fica disponível em navegadores com WebAuthn, mesmo sem suporte a Conditional UI +- Listagem de passkeys em Ajustes agora trata `createdAt` ausente sem gerar data inválida na interface +- Migração `0017_previous_warstar` tornou-se idempotente para colunas de `preferencias_usuario` com `IF NOT EXISTS`, evitando falha em bancos já migrados + ## [1.7.5] - 2026-02-28 ### Adicionado diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx index 4eb846e..c575cf8 100644 --- a/app/(dashboard)/ajustes/page.tsx +++ b/app/(dashboard)/ajustes/page.tsx @@ -4,6 +4,7 @@ import { redirect } from "next/navigation"; import { CompanionTab } from "@/components/ajustes/companion-tab"; import { DeleteAccountForm } from "@/components/ajustes/delete-account-form"; +import { PasskeysForm } from "@/components/ajustes/passkeys-form"; import { PreferencesForm } from "@/components/ajustes/preferences-form"; import { UpdateEmailForm } from "@/components/ajustes/update-email-form"; import { UpdateNameForm } from "@/components/ajustes/update-name-form"; @@ -39,6 +40,7 @@ export default async function Page() { Companion Alterar nome Alterar senha + Passkeys Alterar e-mail Deletar conta @@ -114,6 +116,21 @@ export default async function Page() { + + +
+
+

Passkeys

+

+ Passkeys permitem login sem senha, usando biometria (Face ID, + Touch ID, Windows Hello) ou chaves de segurança. +

+
+ +
+
+
+
diff --git a/app/globals.css b/app/globals.css index 0025c16..c24a2e2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -105,7 +105,7 @@ /* Base surfaces - warm dark with consistent hue family */ --background: oklch(18.5% 0.002 70); --foreground: oklch(92% 0.015 80); - --card: oklch(22.717% 0.00244 67.467); + --card: oklch(0.13 0.01 64.18); --card-foreground: oklch(92% 0.015 80); --popover: oklch(24% 0.003 70); --popover-foreground: oklch(92% 0.015 80); diff --git a/components/ajustes/passkeys-form.tsx b/components/ajustes/passkeys-form.tsx new file mode 100644 index 0000000..8695e73 --- /dev/null +++ b/components/ajustes/passkeys-form.tsx @@ -0,0 +1,418 @@ +"use client"; + +import { + RiAddLine, + RiAlertLine, + RiDeleteBinLine, + RiFingerprintLine, + RiLoader4Line, + RiPencilLine, +} from "@remixicon/react"; +import { formatDistanceToNow } from "date-fns"; +import { ptBR } from "date-fns/locale"; +import { useCallback, useEffect, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth/client"; + +interface Passkey { + id: string; + name: string | null; + deviceType: string; + createdAt: Date | null; +} + +export function PasskeysForm() { + const [passkeys, setPasskeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [passkeySupported, setPasskeySupported] = useState(false); + + // Add passkey + const [isAddOpen, setIsAddOpen] = useState(false); + const [addName, setAddName] = useState(""); + const [isAdding, setIsAdding] = useState(false); + + // Rename passkey + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [isRenaming, setIsRenaming] = useState(false); + + // Delete passkey + const [deleteId, setDeleteId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const isMutating = isAdding || isRenaming || isDeleting; + + const fetchPasskeys = useCallback( + async (options?: { showLoader?: boolean }) => { + const showLoader = options?.showLoader ?? true; + if (showLoader) setIsLoading(true); + setError(null); + try { + const { data, error: fetchError } = + await authClient.passkey.listUserPasskeys(); + if (fetchError) { + setError(fetchError.message || "Erro ao carregar passkeys."); + return; + } + setPasskeys( + (data ?? []).map((p) => ({ + id: p.id, + name: p.name, + deviceType: p.deviceType, + createdAt: p.createdAt ? new Date(p.createdAt) : null, + })), + ); + } catch { + setError("Erro ao carregar passkeys."); + } finally { + if (showLoader) setIsLoading(false); + } + }, + [], + ); + + useEffect(() => { + if (typeof window === "undefined") return; + setPasskeySupported(typeof PublicKeyCredential !== "undefined"); + fetchPasskeys(); + }, [fetchPasskeys]); + + const handleAdd = async () => { + if (!passkeySupported) { + setError("Passkeys não são suportadas neste navegador/dispositivo."); + return; + } + setIsAdding(true); + setError(null); + try { + const { error: addError } = await authClient.passkey.addPasskey({ + name: addName.trim() || undefined, + }); + if (addError) { + setError(addError.message || "Erro ao registrar passkey."); + return; + } + setAddName(""); + setIsAddOpen(false); + await fetchPasskeys({ showLoader: false }); + } catch { + setError("Erro ao registrar passkey."); + } finally { + setIsAdding(false); + } + }; + + const handleRename = async (id: string) => { + if (!editName.trim()) return; + setIsRenaming(true); + setError(null); + try { + const { error: renameError } = await authClient.passkey.updatePasskey({ + id, + name: editName.trim(), + }); + if (renameError) { + setError(renameError.message || "Erro ao renomear passkey."); + return; + } + setEditingId(null); + setEditName(""); + await fetchPasskeys({ showLoader: false }); + } catch { + setError("Erro ao renomear passkey."); + } finally { + setIsRenaming(false); + } + }; + + const handleDelete = async () => { + if (!deleteId) return; + setIsDeleting(true); + setError(null); + try { + const { error: deleteError } = await authClient.passkey.deletePasskey({ + id: deleteId, + }); + if (deleteError) { + setError(deleteError.message || "Erro ao remover passkey."); + return; + } + setDeleteId(null); + await fetchPasskeys({ showLoader: false }); + } catch { + setError("Erro ao remover passkey."); + } finally { + setIsDeleting(false); + } + }; + + const startEditing = (passkey: Passkey) => { + setEditingId(passkey.id); + setEditName(passkey.name || ""); + }; + + const cancelEditing = () => { + setEditingId(null); + setEditName(""); + }; + + const deviceTypeLabel = (type: string) => { + switch (type) { + case "singleDevice": + return "Dispositivo único"; + case "multiDevice": + return "Multi-dispositivo"; + default: + return type; + } + }; + + return ( +
+
+
+

Suas passkeys

+

+ Gerencie suas passkeys para login sem senha. +

+
+ { + if (!open) { + setAddName(""); + setError(null); + } + setIsAddOpen(open); + }} + > + + + + + + Registrar Passkey + + Dê um nome para identificar esta passkey (opcional). Em seguida, + seu navegador solicitará a confirmação biométrica. + + +
+
+ + setAddName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleAdd(); + } + }} + disabled={isAdding} + /> +
+ {error && ( +
+ + {error} +
+ )} +
+ + + + +
+
+
+ + {error && !isAddOpen && ( +
+ + {error} +
+ )} + {!passkeySupported && !isLoading && ( +
+ + Este navegador/dispositivo não suporta passkeys. +
+ )} + + {isLoading ? ( +
+ +
+ ) : passkeys.length === 0 ? ( +
+ +

+ Nenhuma passkey cadastrada. Adicione uma para login sem senha. +

+
+ ) : ( +
+ {passkeys.map((pk) => ( +
+
+
+ +
+
+ {editingId === pk.id ? ( +
+ setEditName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(pk.id); + if (e.key === "Escape") cancelEditing(); + }} + autoFocus + disabled={isRenaming} + /> + + +
+ ) : ( + <> +
+ + {pk.name || "Passkey sem nome"} + + +
+

+ {deviceTypeLabel(pk.deviceType)} + {pk.createdAt + ? ` · Criada ${formatDistanceToNow(pk.createdAt, { + addSuffix: true, + locale: ptBR, + })}` + : " · Data de criação indisponível"} +

+ + )} +
+
+ {editingId !== pk.id && ( + + )} +
+ ))} +
+ )} + + {/* Delete Confirmation Dialog */} + !open && setDeleteId(null)} + > + + + Remover passkey? + + Esta passkey não poderá mais ser usada para login. Esta ação não + pode ser desfeita. + + + + + Cancelar + + + {isDeleting ? "Removendo..." : "Remover"} + + + + +
+ ); +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index 9042c1a..a828dfd 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -1,7 +1,7 @@ "use client"; -import { RiLoader4Line } from "@remixicon/react"; +import { RiFingerprintLine, RiLoader4Line } from "@remixicon/react"; import { useRouter } from "next/navigation"; -import { type FormEvent, useState } from "react"; +import { type FormEvent, useEffect, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -33,6 +33,32 @@ export function LoginForm({ className, ...props }: DivProps) { const [error, setError] = useState(""); const [loadingEmail, setLoadingEmail] = useState(false); const [loadingGoogle, setLoadingGoogle] = useState(false); + const [loadingPasskey, setLoadingPasskey] = useState(false); + const [passkeySupported, setPasskeySupported] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + if (typeof PublicKeyCredential === "undefined") return; + + setPasskeySupported(true); + + if ( + typeof PublicKeyCredential.isConditionalMediationAvailable === "function" + ) { + PublicKeyCredential.isConditionalMediationAvailable() + .then((available) => { + if (available) { + // Conditional UI é opcional: habilita autofill quando disponível. + authClient.signIn.passkey({ + mediation: "conditional", + }); + } + }) + .catch(() => { + // Ignora falhas de detecção e mantém login manual por passkey. + }); + } + }, []); async function handleSubmit(e: FormEvent) { e.preventDefault(); @@ -97,6 +123,29 @@ export function LoginForm({ className, ...props }: DivProps) { ); } + async function handlePasskey() { + setError(""); + setLoadingPasskey(true); + + const { error: passkeyError } = await authClient.signIn.passkey({ + fetchOptions: { + onSuccess: () => { + setLoadingPasskey(false); + router.replace("/dashboard"); + }, + onError: (ctx) => { + setError(ctx.error.message); + setLoadingPasskey(false); + }, + }, + }); + + if (passkeyError) { + setError(passkeyError.message || "Erro ao entrar com passkey."); + setLoadingPasskey(false); + } + } + return (
@@ -118,7 +167,7 @@ export function LoginForm({ className, ...props }: DivProps) { id="email" type="email" placeholder="Digite seu e-mail" - autoComplete="email" + autoComplete="username webauthn" required value={email} onChange={(e) => setEmail(e.target.value)} @@ -145,7 +194,7 @@ export function LoginForm({ className, ...props }: DivProps) { + + )} + Não tem uma conta?{" "} diff --git a/db/schema.ts b/db/schema.ts index 9816cec..e7aea38 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -100,6 +100,27 @@ 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 preferenciasUsuario = pgTable("preferencias_usuario", { id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), userId: text("user_id") diff --git a/drizzle/0017_previous_warstar.sql b/drizzle/0017_previous_warstar.sql new file mode 100644 index 0000000..832086a --- /dev/null +++ b/drizzle/0017_previous_warstar.sql @@ -0,0 +1,17 @@ +CREATE TABLE "passkey" ( + "id" text PRIMARY KEY NOT NULL, + "name" text, + "publicKey" text NOT NULL, + "userId" text NOT NULL, + "credentialID" text NOT NULL, + "counter" integer NOT NULL, + "deviceType" text NOT NULL, + "backedUp" boolean NOT NULL, + "transports" text, + "aaguid" text, + "createdAt" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "extrato_note_as_column" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "preferencias_usuario" ADD COLUMN IF NOT EXISTS "lancamentos_column_order" jsonb;--> statement-breakpoint +ALTER TABLE "passkey" ADD CONSTRAINT "passkey_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; diff --git a/drizzle/meta/0017_snapshot.json b/drizzle/meta/0017_snapshot.json new file mode 100644 index 0000000..dc5193d --- /dev/null +++ b/drizzle/meta/0017_snapshot.json @@ -0,0 +1,2293 @@ +{ + "id": "ad4a0401-4eb8-47ab-a18d-a85643544d73", + "prevId": "2606b7bf-6abb-4dfd-8fda-9addd38bf70e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anotacoes": { + "name": "anotacoes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "titulo": { + "name": "titulo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "descricao": { + "name": "descricao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tipo": { + "name": "tipo", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'nota'" + }, + "tasks": { + "name": "tasks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "arquivada": { + "name": "arquivada", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "anotacoes_user_id_user_id_fk": { + "name": "anotacoes_user_id_user_id_fk", + "tableFrom": "anotacoes", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.antecipacoes_parcelas": { + "name": "antecipacoes_parcelas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "periodo_antecipacao": { + "name": "periodo_antecipacao", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_antecipacao": { + "name": "data_antecipacao", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "parcelas_antecipadas": { + "name": "parcelas_antecipadas", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "valor_total": { + "name": "valor_total", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "qtde_parcelas": { + "name": "qtde_parcelas", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "desconto": { + "name": "desconto", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "lancamento_id": { + "name": "lancamento_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pagador_id": { + "name": "pagador_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "categoria_id": { + "name": "categoria_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "anotacao": { + "name": "anotacao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "antecipacoes_parcelas_series_id_idx": { + "name": "antecipacoes_parcelas_series_id_idx", + "columns": [ + { + "expression": "series_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "antecipacoes_parcelas_user_id_idx": { + "name": "antecipacoes_parcelas_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk": { + "name": "antecipacoes_parcelas_lancamento_id_lancamentos_id_fk", + "tableFrom": "antecipacoes_parcelas", + "tableTo": "lancamentos", + "columnsFrom": ["lancamento_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "antecipacoes_parcelas_pagador_id_pagadores_id_fk": { + "name": "antecipacoes_parcelas_pagador_id_pagadores_id_fk", + "tableFrom": "antecipacoes_parcelas", + "tableTo": "pagadores", + "columnsFrom": ["pagador_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "antecipacoes_parcelas_categoria_id_categorias_id_fk": { + "name": "antecipacoes_parcelas_categoria_id_categorias_id_fk", + "tableFrom": "antecipacoes_parcelas", + "tableTo": "categorias", + "columnsFrom": ["categoria_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "antecipacoes_parcelas_user_id_user_id_fk": { + "name": "antecipacoes_parcelas_user_id_user_id_fk", + "tableFrom": "antecipacoes_parcelas", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cartoes": { + "name": "cartoes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "nome": { + "name": "nome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dt_fechamento": { + "name": "dt_fechamento", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dt_vencimento": { + "name": "dt_vencimento", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anotacao": { + "name": "anotacao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "limite": { + "name": "limite", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "bandeira": { + "name": "bandeira", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conta_id": { + "name": "conta_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cartoes_user_id_status_idx": { + "name": "cartoes_user_id_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cartoes_user_id_user_id_fk": { + "name": "cartoes_user_id_user_id_fk", + "tableFrom": "cartoes", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cartoes_conta_id_contas_id_fk": { + "name": "cartoes_conta_id_contas_id_fk", + "tableFrom": "cartoes", + "tableTo": "contas", + "columnsFrom": ["conta_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categorias": { + "name": "categorias", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "nome": { + "name": "nome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tipo": { + "name": "tipo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icone": { + "name": "icone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "categorias_user_id_type_idx": { + "name": "categorias_user_id_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tipo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "categorias_user_id_user_id_fk": { + "name": "categorias_user_id_user_id_fk", + "tableFrom": "categorias", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compartilhamentos_pagador": { + "name": "compartilhamentos_pagador", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pagador_id": { + "name": "pagador_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shared_with_user_id": { + "name": "shared_with_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "compartilhamentos_pagador_unique": { + "name": "compartilhamentos_pagador_unique", + "columns": [ + { + "expression": "pagador_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_with_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compartilhamentos_pagador_pagador_id_pagadores_id_fk": { + "name": "compartilhamentos_pagador_pagador_id_pagadores_id_fk", + "tableFrom": "compartilhamentos_pagador", + "tableTo": "pagadores", + "columnsFrom": ["pagador_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compartilhamentos_pagador_shared_with_user_id_user_id_fk": { + "name": "compartilhamentos_pagador_shared_with_user_id_user_id_fk", + "tableFrom": "compartilhamentos_pagador", + "tableTo": "user", + "columnsFrom": ["shared_with_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compartilhamentos_pagador_created_by_user_id_user_id_fk": { + "name": "compartilhamentos_pagador_created_by_user_id_user_id_fk", + "tableFrom": "compartilhamentos_pagador", + "tableTo": "user", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contas": { + "name": "contas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "nome": { + "name": "nome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tipo_conta": { + "name": "tipo_conta", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anotacao": { + "name": "anotacao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "saldo_inicial": { + "name": "saldo_inicial", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "excluir_do_saldo": { + "name": "excluir_do_saldo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "excluir_saldo_inicial_receitas": { + "name": "excluir_saldo_inicial_receitas", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contas_user_id_status_idx": { + "name": "contas_user_id_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contas_user_id_user_id_fk": { + "name": "contas_user_id_user_id_fk", + "tableFrom": "contas", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.faturas": { + "name": "faturas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status_pagamento": { + "name": "status_pagamento", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "periodo": { + "name": "periodo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cartao_id": { + "name": "cartao_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "faturas_user_id_period_idx": { + "name": "faturas_user_id_period_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "faturas_cartao_id_period_idx": { + "name": "faturas_cartao_id_period_idx", + "columns": [ + { + "expression": "cartao_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "faturas_user_id_user_id_fk": { + "name": "faturas_user_id_user_id_fk", + "tableFrom": "faturas", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "faturas_cartao_id_cartoes_id_fk": { + "name": "faturas_cartao_id_cartoes_id_fk", + "tableFrom": "faturas", + "tableTo": "cartoes", + "columnsFrom": ["cartao_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights_salvos": { + "name": "insights_salvos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "insights_salvos_user_period_idx": { + "name": "insights_salvos_user_period_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_salvos_user_id_user_id_fk": { + "name": "insights_salvos_user_id_user_id_fk", + "tableFrom": "insights_salvos", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lancamentos": { + "name": "lancamentos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "condicao": { + "name": "condicao", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nome": { + "name": "nome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "forma_pagamento": { + "name": "forma_pagamento", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anotacao": { + "name": "anotacao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "valor": { + "name": "valor", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "data_compra": { + "name": "data_compra", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "tipo_transacao": { + "name": "tipo_transacao", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qtde_parcela": { + "name": "qtde_parcela", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "periodo": { + "name": "periodo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parcela_atual": { + "name": "parcela_atual", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "qtde_recorrencia": { + "name": "qtde_recorrencia", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "data_vencimento": { + "name": "data_vencimento", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "dt_pagamento_boleto": { + "name": "dt_pagamento_boleto", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "realizado": { + "name": "realizado", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "dividido": { + "name": "dividido", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "antecipado": { + "name": "antecipado", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "antecipacao_id": { + "name": "antecipacao_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cartao_id": { + "name": "cartao_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "conta_id": { + "name": "conta_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "categoria_id": { + "name": "categoria_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pagador_id": { + "name": "pagador_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "transfer_id": { + "name": "transfer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "lancamentos_user_id_period_idx": { + "name": "lancamentos_user_id_period_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_user_id_period_type_idx": { + "name": "lancamentos_user_id_period_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tipo_transacao", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_pagador_id_period_idx": { + "name": "lancamentos_pagador_id_period_idx", + "columns": [ + { + "expression": "pagador_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_user_id_purchase_date_idx": { + "name": "lancamentos_user_id_purchase_date_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "data_compra", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_series_id_idx": { + "name": "lancamentos_series_id_idx", + "columns": [ + { + "expression": "series_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_transfer_id_idx": { + "name": "lancamentos_transfer_id_idx", + "columns": [ + { + "expression": "transfer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_user_id_condition_idx": { + "name": "lancamentos_user_id_condition_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "condicao", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "lancamentos_cartao_id_period_idx": { + "name": "lancamentos_cartao_id_period_idx", + "columns": [ + { + "expression": "cartao_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk": { + "name": "lancamentos_antecipacao_id_antecipacoes_parcelas_id_fk", + "tableFrom": "lancamentos", + "tableTo": "antecipacoes_parcelas", + "columnsFrom": ["antecipacao_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "lancamentos_user_id_user_id_fk": { + "name": "lancamentos_user_id_user_id_fk", + "tableFrom": "lancamentos", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lancamentos_cartao_id_cartoes_id_fk": { + "name": "lancamentos_cartao_id_cartoes_id_fk", + "tableFrom": "lancamentos", + "tableTo": "cartoes", + "columnsFrom": ["cartao_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "lancamentos_conta_id_contas_id_fk": { + "name": "lancamentos_conta_id_contas_id_fk", + "tableFrom": "lancamentos", + "tableTo": "contas", + "columnsFrom": ["conta_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "lancamentos_categoria_id_categorias_id_fk": { + "name": "lancamentos_categoria_id_categorias_id_fk", + "tableFrom": "lancamentos", + "tableTo": "categorias", + "columnsFrom": ["categoria_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "lancamentos_pagador_id_pagadores_id_fk": { + "name": "lancamentos_pagador_id_pagadores_id_fk", + "tableFrom": "lancamentos", + "tableTo": "pagadores", + "columnsFrom": ["pagador_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orcamentos": { + "name": "orcamentos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "valor": { + "name": "valor", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "periodo": { + "name": "periodo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "categoria_id": { + "name": "categoria_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "orcamentos_user_id_period_idx": { + "name": "orcamentos_user_id_period_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "periodo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orcamentos_user_id_user_id_fk": { + "name": "orcamentos_user_id_user_id_fk", + "tableFrom": "orcamentos", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "orcamentos_categoria_id_categorias_id_fk": { + "name": "orcamentos_categoria_id_categorias_id_fk", + "tableFrom": "orcamentos", + "tableTo": "categorias", + "columnsFrom": ["categoria_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pagadores": { + "name": "pagadores", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "nome": { + "name": "nome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anotacao": { + "name": "anotacao", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_auto_send": { + "name": "is_auto_send", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_code": { + "name": "share_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "substr(encode(gen_random_bytes(24), 'base64'), 1, 24)" + }, + "last_mail": { + "name": "last_mail", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pagadores_share_code_key": { + "name": "pagadores_share_code_key", + "columns": [ + { + "expression": "share_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pagadores_user_id_status_idx": { + "name": "pagadores_user_id_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pagadores_user_id_role_idx": { + "name": "pagadores_user_id_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pagadores_user_id_user_id_fk": { + "name": "pagadores_user_id_user_id_fk", + "tableFrom": "pagadores", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publicKey": { + "name": "publicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credentialID": { + "name": "credentialID", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deviceType": { + "name": "deviceType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backedUp": { + "name": "backedUp", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "passkey_userId_user_id_fk": { + "name": "passkey_userId_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pre_lancamentos": { + "name": "pre_lancamentos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app_name": { + "name": "source_app_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_title": { + "name": "original_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_text": { + "name": "original_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_timestamp": { + "name": "notification_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "parsed_name": { + "name": "parsed_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_amount": { + "name": "parsed_amount", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lancamento_id": { + "name": "lancamento_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "discarded_at": { + "name": "discarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pre_lancamentos_user_id_status_idx": { + "name": "pre_lancamentos_user_id_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pre_lancamentos_user_id_created_at_idx": { + "name": "pre_lancamentos_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pre_lancamentos_user_id_user_id_fk": { + "name": "pre_lancamentos_user_id_user_id_fk", + "tableFrom": "pre_lancamentos", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pre_lancamentos_lancamento_id_lancamentos_id_fk": { + "name": "pre_lancamentos_lancamento_id_lancamentos_id_fk", + "tableFrom": "pre_lancamentos", + "tableTo": "lancamentos", + "columnsFrom": ["lancamento_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferencias_usuario": { + "name": "preferencias_usuario", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "disable_magnetlines": { + "name": "disable_magnetlines", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "extrato_note_as_column": { + "name": "extrato_note_as_column", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "system_font": { + "name": "system_font", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ai-sans'" + }, + "money_font": { + "name": "money_font", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ai-sans'" + }, + "lancamentos_column_order": { + "name": "lancamentos_column_order", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dashboard_widgets": { + "name": "dashboard_widgets", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "preferencias_usuario_user_id_user_id_fk": { + "name": "preferencias_usuario_user_id_user_id_fk", + "tableFrom": "preferencias_usuario", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "preferencias_usuario_user_id_unique": { + "name": "preferencias_usuario_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tokens_api": { + "name": "tokens_api", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_ip": { + "name": "last_used_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tokens_api_user_id_idx": { + "name": "tokens_api_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tokens_api_token_hash_idx": { + "name": "tokens_api_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tokens_api_user_id_user_id_fk": { + "name": "tokens_api_user_id_user_id_fk", + "tableFrom": "tokens_api", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bdc4c82..23712e6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1771166328908, "tag": "0016_complete_randall", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1772400510326, + "tag": "0017_previous_warstar", + "breakpoints": true } ] } diff --git a/lib/auth/client.ts b/lib/auth/client.ts index 70f17ed..0d49e2b 100644 --- a/lib/auth/client.ts +++ b/lib/auth/client.ts @@ -1,9 +1,11 @@ +import { passkeyClient } from "@better-auth/passkey/client"; import { createAuthClient } from "better-auth/react"; const baseURL = process.env.BETTER_AUTH_URL?.replace(/\/$/, ""); export const authClient = createAuthClient({ ...(baseURL ? { baseURL } : {}), + plugins: [passkeyClient()], }); /** diff --git a/lib/auth/config.ts b/lib/auth/config.ts index 25c2e44..546ece6 100644 --- a/lib/auth/config.ts +++ b/lib/auth/config.ts @@ -1,3 +1,4 @@ +import { passkey } from "@better-auth/passkey"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import type { GoogleProfile } from "better-auth/social-providers"; @@ -83,6 +84,13 @@ export const auth = betterAuth({ }, }, + // Plugins + plugins: [ + passkey({ + rpName: "OpenMonetis", + }), + ], + // Google OAuth (se configurado) socialProviders: googleClientId && googleClientSecret diff --git a/package.json b/package.json index 8554214..f0e3d0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openmonetis", - "version": "1.7.5", + "version": "1.7.6", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -30,6 +30,7 @@ "@ai-sdk/anthropic": "^3.0.48", "@ai-sdk/google": "^3.0.33", "@ai-sdk/openai": "^3.0.36", + "@better-auth/passkey": "^1.5.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 428126f..083b015 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@ai-sdk/openai': specifier: ^3.0.36 version: 3.0.36(zod@4.3.6) + '@better-auth/passkey': + specifier: ^1.5.0 + version: 1.5.0(@better-auth/core@1.5.0(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.4.19(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.19.0))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.19.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(better-call@1.3.2(zod@4.3.6))(nanostores@1.1.0) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -268,6 +271,30 @@ packages: kysely: ^0.28.5 nanostores: ^1.0.1 + '@better-auth/core@1.5.0': + resolution: {integrity: sha512-nDPmW7I9VGRACEei31fHaZxGwD/yICraDllZ/f25jbWXYaxDaW88RuH1ZhbOUKmGJlZtDxcjN1+YmcVIc1ioNw==} + peerDependencies: + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + '@cloudflare/workers-types': '>=4' + better-call: 1.3.2 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + '@better-auth/passkey@1.5.0': + resolution: {integrity: sha512-joupofIxoUEzwd3/T/d7JRSHxPx9kBLYJs6eBhcuso564/O9SrAOKbzfepEmouBMow6VA78bpwAGkVHpMkSwyg==} + peerDependencies: + '@better-auth/core': 1.5.0 + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + better-auth: 1.5.0 + better-call: 1.3.2 + nanostores: ^1.0.1 + '@better-auth/telemetry@1.4.19': resolution: {integrity: sha512-ApGNS7olCTtDpKF8Ow3Z+jvFAirOj7c4RyFUpu8axklh3mH57ndpfUAUjhgA8UVoaaH/mnm/Tl884BlqiewLyw==} peerDependencies: @@ -276,6 +303,9 @@ packages: '@better-auth/utils@0.3.0': resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + '@better-auth/utils@0.3.1': + resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==} + '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} @@ -834,6 +864,9 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@img/colour@1.0.0': resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} @@ -1003,6 +1036,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@next/env@16.1.6': resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} @@ -1077,6 +1113,43 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@peculiar/asn1-android@2.6.0': + resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} + + '@peculiar/asn1-cms@2.6.1': + resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==} + + '@peculiar/asn1-csr@2.6.1': + resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==} + + '@peculiar/asn1-ecc@2.6.1': + resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==} + + '@peculiar/asn1-pfx@2.6.1': + resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==} + + '@peculiar/asn1-pkcs8@2.6.1': + resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==} + + '@peculiar/asn1-pkcs9@2.6.1': + resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==} + + '@peculiar/asn1-rsa@2.6.1': + resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/asn1-x509-attr@2.6.1': + resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==} + + '@peculiar/asn1-x509@2.6.1': + resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1866,6 +1939,13 @@ packages: peerDependencies: react: '>=18.2.0' + '@simplewebauthn/browser@13.2.2': + resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==} + + '@simplewebauthn/server@13.2.3': + resolution: {integrity: sha512-ZhcVBOw63birYx9jVfbhK6rTehckVes8PeWV324zpmdxr0BUfylospwMzcrxrdMcOi48MHWj2LCA+S528LnGvg==} + engines: {node: '>=20.0.0'} + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -2105,6 +2185,10 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + asn1js@3.0.7: + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} + engines: {node: '>=12.0.0'} + babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} @@ -2186,6 +2270,14 @@ packages: zod: optional: true + better-call@1.3.2: + resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2699,6 +2791,13 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} peerDependencies: @@ -2791,6 +2890,9 @@ packages: redux@5.0.1: resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} @@ -2827,6 +2929,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@3.0.1: + resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2899,6 +3004,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2907,6 +3015,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3041,6 +3153,29 @@ snapshots: nanostores: 1.1.0 zod: 4.3.6 + '@better-auth/core@1.5.0(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.2(zod@4.3.6) + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.6 + + '@better-auth/passkey@1.5.0(@better-auth/core@1.5.0(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.4.19(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.19.0))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.19.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(better-call@1.3.2(zod@4.3.6))(nanostores@1.1.0)': + dependencies: + '@better-auth/core': 1.5.0(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + '@simplewebauthn/browser': 13.2.2 + '@simplewebauthn/server': 13.2.3 + better-auth: 1.4.19(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.19.0))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.19.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + better-call: 1.3.2(zod@4.3.6) + nanostores: 1.1.0 + zod: 4.3.6 + '@better-auth/telemetry@1.4.19(@better-auth/core@1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': dependencies: '@better-auth/core': 1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) @@ -3049,6 +3184,8 @@ snapshots: '@better-auth/utils@0.3.0': {} + '@better-auth/utils@0.3.1': {} + '@better-fetch/fetch@1.1.21': {} '@biomejs/biome@2.4.4': @@ -3369,6 +3506,8 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@hexagon/base64@1.1.28': {} + '@img/colour@1.0.0': optional: true @@ -3485,6 +3624,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@levischuck/tiny-cbor@0.2.11': {} + '@next/env@16.1.6': {} '@next/swc-darwin-arm64@16.1.6': @@ -3522,6 +3663,102 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@peculiar/asn1-android@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pfx': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.6.0': + dependencies: + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-csr': 2.6.1 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-pkcs9': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -4348,6 +4585,19 @@ snapshots: dependencies: react: 19.2.4 + '@simplewebauthn/browser@13.2.2': {} + + '@simplewebauthn/server@13.2.3': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.6.0 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/x509': 1.14.3 + '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -4515,6 +4765,12 @@ snapshots: dependencies: tslib: 2.8.1 + asn1js@3.0.7: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + babel-plugin-react-compiler@1.0.0: dependencies: '@babel/types': 7.29.0 @@ -4556,6 +4812,15 @@ snapshots: optionalDependencies: zod: 4.3.6 + better-call@1.3.2(zod@4.3.6): + dependencies: + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.0.1 + optionalDependencies: + zod: 4.3.6 + buffer-from@1.1.2: {} caniuse-lite@1.0.30001770: {} @@ -5007,6 +5272,12 @@ snapshots: dependencies: xtend: 4.0.2 + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@radix-ui/primitive': 1.1.3 @@ -5154,6 +5425,8 @@ snapshots: redux@5.0.1: {} + reflect-metadata@0.2.2: {} + regenerator-runtime@0.13.11: optional: true @@ -5178,6 +5451,8 @@ snapshots: set-cookie-parser@2.7.2: {} + set-cookie-parser@3.0.1: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -5264,6 +5539,8 @@ snapshots: tiny-invariant@1.3.3: {} + tslib@1.14.1: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -5273,6 +5550,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + typescript@5.9.3: {} undici-types@7.18.2: {} From 2a21bef2daa5daf9da9d485d35d15d03a2d9ea93 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Mon, 2 Mar 2026 17:20:28 +0000 Subject: [PATCH 2/4] feat(dashboard): add quick actions and new overview widgets --- app/(dashboard)/dashboard/page.tsx | 43 ++++- .../relatorios/estabelecimentos/layout.tsx | 23 +++ .../relatorios/estabelecimentos/loading.tsx | 58 +++++++ .../relatorios/estabelecimentos/page.tsx | 76 +++++++++ .../dashboard/dashboard-grid-editable.tsx | 157 +++++++++++++----- .../dashboard/goals-progress-widget.tsx | 146 ++++++++++++++++ components/dashboard/notes-widget.tsx | 157 ++++++++++++++++++ components/dashboard/pagadores-widget.tsx | 31 ++++ .../dashboard/payment-overview-widget.tsx | 50 ++++++ components/dashboard/sortable-widget.tsx | 3 +- .../dashboard/spending-overview-widget.tsx | 57 +++++++ components/navbar/nav-items.tsx | 6 + components/orcamentos/budget-dialog.tsx | 49 +++++- .../top-estabelecimentos/period-filter.tsx | 2 +- components/ui/slider.tsx | 37 +++++ lib/actions/helpers.ts | 3 +- lib/dashboard/goals-progress.ts | 147 ++++++++++++++++ lib/dashboard/notes.ts | 73 ++++++++ lib/dashboard/pagadores.ts | 60 ++++++- lib/dashboard/top-establishments.ts | 5 +- lib/dashboard/widgets/widgets-config.tsx | 99 ++++++----- 21 files changed, 1166 insertions(+), 116 deletions(-) create mode 100644 app/(dashboard)/relatorios/estabelecimentos/layout.tsx create mode 100644 app/(dashboard)/relatorios/estabelecimentos/loading.tsx create mode 100644 app/(dashboard)/relatorios/estabelecimentos/page.tsx create mode 100644 components/dashboard/goals-progress-widget.tsx create mode 100644 components/dashboard/notes-widget.tsx create mode 100644 components/dashboard/payment-overview-widget.tsx create mode 100644 components/dashboard/spending-overview-widget.tsx create mode 100644 components/ui/slider.tsx create mode 100644 lib/dashboard/goals-progress.ts create mode 100644 lib/dashboard/notes.ts diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 0b049e2..fd5da45 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -4,7 +4,13 @@ import { SectionCards } from "@/components/dashboard/section-cards"; import MonthNavigation from "@/components/month-picker/month-navigation"; import { getUser } from "@/lib/auth/server"; import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data"; +import { + buildOptionSets, + buildSluggedFilters, + fetchLancamentoFilterSources, +} from "@/lib/lancamentos/page-helpers"; import { parsePeriodParam } from "@/lib/utils/period"; +import { getRecentEstablishmentsAction } from "../lancamentos/actions"; import { fetchUserDashboardPreferences } from "./data"; type PageSearchParams = Promise>; @@ -28,12 +34,26 @@ export default async function Page({ searchParams }: PageProps) { const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const { period: selectedPeriod } = parsePeriodParam(periodoParam); - const [data, preferences] = await Promise.all([ - fetchDashboardData(user.id, selectedPeriod), - fetchUserDashboardPreferences(user.id), - ]); - + const [dashboardData, preferences, filterSources, estabelecimentos] = + await Promise.all([ + fetchDashboardData(user.id, selectedPeriod), + fetchUserDashboardPreferences(user.id), + fetchLancamentoFilterSources(user.id), + getRecentEstablishmentsAction(), + ]); const { disableMagnetlines, dashboardWidgets } = preferences; + const sluggedFilters = buildSluggedFilters(filterSources); + const { + pagadorOptions, + splitPagadorOptions, + defaultPagadorId, + contaOptions, + cartaoOptions, + categoriaOptions, + } = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + }); return (
@@ -42,11 +62,20 @@ export default async function Page({ searchParams }: PageProps) { disableMagnetlines={disableMagnetlines} /> - +
); diff --git a/app/(dashboard)/relatorios/estabelecimentos/layout.tsx b/app/(dashboard)/relatorios/estabelecimentos/layout.tsx new file mode 100644 index 0000000..c20ca10 --- /dev/null +++ b/app/(dashboard)/relatorios/estabelecimentos/layout.tsx @@ -0,0 +1,23 @@ +import { RiStore2Line } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; + +export const metadata = { + title: "Top Estabelecimentos | OpenMonetis", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ } + title="Top Estabelecimentos" + subtitle="Análise dos locais onde você mais compra e gasta" + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/relatorios/estabelecimentos/loading.tsx b/app/(dashboard)/relatorios/estabelecimentos/loading.tsx new file mode 100644 index 0000000..ac15aa9 --- /dev/null +++ b/app/(dashboard)/relatorios/estabelecimentos/loading.tsx @@ -0,0 +1,58 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+ +
+ {[1, 2, 3, 4].map((i) => ( + + + + + + ))} +
+ +
+ + +
+ +
+
+ + + + + + {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( + + ))} + + +
+
+ + + + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + +
+
+
+ ); +} diff --git a/app/(dashboard)/relatorios/estabelecimentos/page.tsx b/app/(dashboard)/relatorios/estabelecimentos/page.tsx new file mode 100644 index 0000000..4c0d359 --- /dev/null +++ b/app/(dashboard)/relatorios/estabelecimentos/page.tsx @@ -0,0 +1,76 @@ +import { EstablishmentsList } from "@/components/top-estabelecimentos/establishments-list"; +import { HighlightsCards } from "@/components/top-estabelecimentos/highlights-cards"; +import { PeriodFilterButtons } from "@/components/top-estabelecimentos/period-filter"; +import { SummaryCards } from "@/components/top-estabelecimentos/summary-cards"; +import { TopCategories } from "@/components/top-estabelecimentos/top-categories"; +import { Card } from "@/components/ui/card"; +import { getUser } from "@/lib/auth/server"; +import { + fetchTopEstabelecimentosData, + type PeriodFilter, +} from "@/lib/top-estabelecimentos/fetch-data"; +import { parsePeriodParam } from "@/lib/utils/period"; + +type PageSearchParams = Promise>; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | undefined, + key: string, +) => { + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; +}; + +const validatePeriodFilter = (value: string | null): PeriodFilter => { + if (value === "3" || value === "6" || value === "12") { + return value; + } + return "6"; +}; + +export default async function TopEstabelecimentosPage({ + searchParams, +}: PageProps) { + const user = await getUser(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const mesesParam = getSingleParam(resolvedSearchParams, "meses"); + + const { period: currentPeriod } = parsePeriodParam(periodoParam); + const periodFilter = validatePeriodFilter(mesesParam); + + const data = await fetchTopEstabelecimentosData( + user.id, + currentPeriod, + periodFilter, + ); + + return ( +
+ + + Selecione o intervalo de meses + + + + + + + + +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/components/dashboard/dashboard-grid-editable.tsx b/components/dashboard/dashboard-grid-editable.tsx index bff17e8..d2cb153 100644 --- a/components/dashboard/dashboard-grid-editable.tsx +++ b/components/dashboard/dashboard-grid-editable.tsx @@ -1,7 +1,7 @@ "use client"; import { - closestCenter, + closestCorners, DndContext, type DragEndEvent, KeyboardSensor, @@ -16,15 +16,21 @@ import { sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; import { + RiArrowDownLine, + RiArrowUpLine, RiCheckLine, RiCloseLine, RiDragMove2Line, RiEyeOffLine, + RiTodoLine, } from "@remixicon/react"; import { useCallback, useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; +import { NoteDialog } from "@/components/anotacoes/note-dialog"; import { SortableWidget } from "@/components/dashboard/sortable-widget"; import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog"; +import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog"; +import type { SelectOption } from "@/components/lancamentos/types"; import { Button } from "@/components/ui/button"; import WidgetCard from "@/components/widget-card"; import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data"; @@ -42,12 +48,22 @@ type DashboardGridEditableProps = { data: DashboardData; period: string; initialPreferences: WidgetPreferences | null; + quickActionOptions: { + pagadorOptions: SelectOption[]; + splitPagadorOptions: SelectOption[]; + defaultPagadorId: string | null; + contaOptions: SelectOption[]; + cartaoOptions: SelectOption[]; + categoriaOptions: SelectOption[]; + estabelecimentos: string[]; + }; }; export function DashboardGridEditable({ data, period, initialPreferences, + quickActionOptions, }: DashboardGridEditableProps) { const [isEditing, setIsEditing] = useState(false); const [isPending, startTransition] = useTransition(); @@ -183,53 +199,112 @@ export function DashboardGridEditable({ return (
{/* Toolbar */} -
- {isEditing ? ( - <> - - - +
+ {!isEditing ? ( +
+ + Ação rápida + +
+ + + Nova receita + + } + /> + + + Nova despesa + + } + /> + + + Nova anotação + + } + /> +
+
) : ( - <> - - - +
)} + +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
{/* Grid */} + Math.min(max, Math.max(min, value)); + +const formatPercentage = (value: number, withSign = false) => + `${new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + ...(withSign ? { signDisplay: "always" as const } : {}), + }).format(value)}%`; + +export function GoalsProgressWidget({ data }: GoalsProgressWidgetProps) { + const [editOpen, setEditOpen] = useState(false); + const [selectedBudget, setSelectedBudget] = useState(null); + + const categories = useMemo( + () => + data.categories.map((category) => ({ + id: category.id, + name: category.name, + icon: category.icon, + })), + [data.categories], + ); + + const defaultPeriod = data.items[0]?.period ?? ""; + + const handleEdit = useCallback((item: GoalsProgressData["items"][number]) => { + setSelectedBudget({ + id: item.id, + amount: item.budgetAmount, + spent: item.spentAmount, + period: item.period, + createdAt: item.createdAt, + category: item.categoryId + ? { + id: item.categoryId, + name: item.categoryName, + icon: item.categoryIcon, + } + : null, + }); + setEditOpen(true); + }, []); + + const handleEditOpenChange = useCallback((open: boolean) => { + setEditOpen(open); + if (!open) { + setSelectedBudget(null); + } + }, []); + + if (data.items.length === 0) { + return ( + } + title="Nenhum orçamento para o período" + description="Cadastre orçamentos para acompanhar o progresso das metas." + /> + ); + } + + return ( +
+
    + {data.items.map((item, index) => { + const statusColor = + item.status === "exceeded" ? "text-destructive" : ""; + const progressValue = clamp(item.usedPercentage, 0, 100); + const percentageDelta = item.usedPercentage - 100; + + return ( +
  • +
    +
    + +
    +

    + {item.categoryName} +

    +

    + de{" "} + +

    +
    +
    + +
    + + {formatPercentage(percentageDelta, true)} + + +
    +
    +
    + +
    +
  • + ); + })} +
+ + +
+ ); +} diff --git a/components/dashboard/notes-widget.tsx b/components/dashboard/notes-widget.tsx new file mode 100644 index 0000000..8e70ad2 --- /dev/null +++ b/components/dashboard/notes-widget.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { RiEyeLine, RiPencilLine, RiTodoLine } from "@remixicon/react"; +import { useCallback, useMemo, useState } from "react"; +import { NoteDetailsDialog } from "@/components/anotacoes/note-details-dialog"; +import { NoteDialog } from "@/components/anotacoes/note-dialog"; +import type { Note } from "@/components/anotacoes/types"; +import { Button } from "@/components/ui/button"; +import { CardContent } from "@/components/ui/card"; +import type { DashboardNote } from "@/lib/dashboard/notes"; +import { Badge } from "../ui/badge"; +import { WidgetEmptyState } from "../widget-empty-state"; + +type NotesWidgetProps = { + notes: DashboardNote[]; +}; + +const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { + day: "2-digit", + month: "short", + year: "numeric", + timeZone: "UTC", +}); + +const buildDisplayTitle = (value: string) => { + const trimmed = value.trim(); + return trimmed.length ? trimmed : "Anotação sem título"; +}; + +const mapDashboardNoteToNote = (note: DashboardNote): Note => ({ + id: note.id, + title: note.title, + description: note.description, + type: note.type, + tasks: note.tasks, + arquivada: note.arquivada, + createdAt: note.createdAt, +}); + +const getTasksSummary = (note: DashboardNote) => { + if (note.type !== "tarefa") { + return "Nota"; + } + + const tasks = note.tasks ?? []; + const completed = tasks.filter((task) => task.completed).length; + return `${completed}/${tasks.length} concluídas`; +}; + +export function NotesWidget({ notes }: NotesWidgetProps) { + const [noteToEdit, setNoteToEdit] = useState(null); + const [isEditOpen, setIsEditOpen] = useState(false); + const [noteDetails, setNoteDetails] = useState(null); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + + const mappedNotes = useMemo(() => notes.map(mapDashboardNoteToNote), [notes]); + + const handleOpenEdit = useCallback((note: Note) => { + setNoteToEdit(note); + setIsEditOpen(true); + }, []); + + const handleOpenDetails = useCallback((note: Note) => { + setNoteDetails(note); + setIsDetailsOpen(true); + }, []); + + const handleEditOpenChange = useCallback((open: boolean) => { + setIsEditOpen(open); + if (!open) { + setNoteToEdit(null); + } + }, []); + + const handleDetailsOpenChange = useCallback((open: boolean) => { + setIsDetailsOpen(open); + if (!open) { + setNoteDetails(null); + } + }, []); + + return ( + <> + + {mappedNotes.length === 0 ? ( + } + title="Nenhuma anotação ativa" + description="Crie anotações para acompanhar lembretes e tarefas financeiras." + /> + ) : ( +
    + {mappedNotes.map((note) => ( +
  • +
    +

    + {buildDisplayTitle(note.title)} +

    +
    + + {getTasksSummary(note)} + +

    + {DATE_FORMATTER.format(new Date(note.createdAt))} +

    +
    +
    + +
    + + +
    +
  • + ))} +
+ )} +
+ + + + + + ); +} diff --git a/components/dashboard/pagadores-widget.tsx b/components/dashboard/pagadores-widget.tsx index 19c87cb..afe22fa 100644 --- a/components/dashboard/pagadores-widget.tsx +++ b/components/dashboard/pagadores-widget.tsx @@ -1,6 +1,8 @@ "use client"; import { + RiArrowDownSFill, + RiArrowUpSFill, RiExternalLinkLine, RiGroupLine, RiVerifiedBadgeFill, @@ -17,6 +19,10 @@ type PagadoresWidgetProps = { pagadores: DashboardPagador[]; }; +const formatPercentage = (value: number) => { + return `${Math.abs(value).toFixed(0)}%`; +}; + const buildInitials = (value: string) => { const parts = value.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) { @@ -44,6 +50,12 @@ export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
    {pagadores.map((pagador) => { const initials = buildInitials(pagador.name); + const hasValidPercentageChange = + typeof pagador.percentageChange === "number" && + Number.isFinite(pagador.percentageChange); + const percentageChange = hasValidPercentageChange + ? pagador.percentageChange + : null; return (
  • + {percentageChange !== null && ( + 0 + ? "text-destructive" + : percentageChange < 0 + ? "text-success" + : "text-muted-foreground" + }`} + > + {percentageChange > 0 && ( + + )} + {percentageChange < 0 && ( + + )} + {formatPercentage(percentageChange)} + + )}
); diff --git a/components/dashboard/payment-overview-widget.tsx b/components/dashboard/payment-overview-widget.tsx new file mode 100644 index 0000000..e9bd20c --- /dev/null +++ b/components/dashboard/payment-overview-widget.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { RiMoneyDollarCircleLine, RiSlideshowLine } from "@remixicon/react"; +import { useState } from "react"; +import type { PaymentConditionsData } from "@/lib/dashboard/payments/payment-conditions"; +import type { PaymentMethodsData } from "@/lib/dashboard/payments/payment-methods"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; +import { PaymentConditionsWidget } from "./payment-conditions-widget"; +import { PaymentMethodsWidget } from "./payment-methods-widget"; + +type PaymentOverviewWidgetProps = { + paymentConditionsData: PaymentConditionsData; + paymentMethodsData: PaymentMethodsData; +}; + +export function PaymentOverviewWidget({ + paymentConditionsData, + paymentMethodsData, +}: PaymentOverviewWidgetProps) { + const [activeTab, setActiveTab] = useState<"conditions" | "methods">( + "conditions", + ); + + return ( + setActiveTab(value as "conditions" | "methods")} + className="w-full" + > + + + + Condições + + + + Formas + + + + + + + + + + + + ); +} diff --git a/components/dashboard/sortable-widget.tsx b/components/dashboard/sortable-widget.tsx index a158ee8..93b783e 100644 --- a/components/dashboard/sortable-widget.tsx +++ b/components/dashboard/sortable-widget.tsx @@ -37,7 +37,8 @@ export function SortableWidget({ className={cn( "relative", isDragging && "z-50 opacity-90", - isEditing && "cursor-grab active:cursor-grabbing", + isEditing && + "cursor-grab active:cursor-grabbing touch-none select-none", )} {...(isEditing ? { ...attributes, ...listeners } : {})} > diff --git a/components/dashboard/spending-overview-widget.tsx b/components/dashboard/spending-overview-widget.tsx new file mode 100644 index 0000000..72af23f --- /dev/null +++ b/components/dashboard/spending-overview-widget.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { RiArrowUpDoubleLine, RiStore2Line } from "@remixicon/react"; +import { useState } from "react"; +import type { TopExpensesData } from "@/lib/dashboard/expenses/top-expenses"; +import type { TopEstablishmentsData } from "@/lib/dashboard/top-establishments"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; +import { TopEstablishmentsWidget } from "./top-establishments-widget"; +import { TopExpensesWidget } from "./top-expenses-widget"; + +type SpendingOverviewWidgetProps = { + topExpensesAll: TopExpensesData; + topExpensesCardOnly: TopExpensesData; + topEstablishmentsData: TopEstablishmentsData; +}; + +export function SpendingOverviewWidget({ + topExpensesAll, + topExpensesCardOnly, + topEstablishmentsData, +}: SpendingOverviewWidgetProps) { + const [activeTab, setActiveTab] = useState<"expenses" | "establishments">( + "expenses", + ); + + return ( + + setActiveTab(value as "expenses" | "establishments") + } + className="w-full" + > + + + + Top gastos + + + + Estabelecimentos + + + + + + + + + + + + ); +} diff --git a/components/navbar/nav-items.tsx b/components/navbar/nav-items.tsx index b937bb3..7623184 100644 --- a/components/navbar/nav-items.tsx +++ b/components/navbar/nav-items.tsx @@ -9,6 +9,7 @@ import { RiGroupLine, RiPriceTag3Line, RiSparklingLine, + RiStore2Line, RiTodoLine, } from "@remixicon/react"; @@ -110,6 +111,11 @@ export const NAV_SECTIONS: NavSection[] = [ icon: , preservePeriod: true, }, + { + href: "/relatorios/estabelecimentos", + label: "estabelecimentos", + icon: , + }, ], }, ]; diff --git a/components/orcamentos/budget-dialog.tsx b/components/orcamentos/budget-dialog.tsx index b614680..b29387e 100644 --- a/components/orcamentos/budget-dialog.tsx +++ b/components/orcamentos/budget-dialog.tsx @@ -9,7 +9,6 @@ import { import { CategoryIcon } from "@/components/categorias/category-icon"; import { PeriodPicker } from "@/components/period-picker"; import { Button } from "@/components/ui/button"; -import { CurrencyInput } from "@/components/ui/currency-input"; import { Dialog, DialogContent, @@ -27,6 +26,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; import { useControlledState } from "@/hooks/use-controlled-state"; import { useFormState } from "@/hooks/use-form-state"; @@ -54,6 +54,12 @@ const buildInitialValues = ({ amount: budget ? (Math.round(budget.amount * 100) / 100).toFixed(2) : "", }); +const formatCurrency = (value: number) => + new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + }).format(value); + export function BudgetDialog({ mode, trigger, @@ -164,6 +170,15 @@ export function BudgetDialog({ const submitLabel = mode === "create" ? "Salvar orçamento" : "Atualizar orçamento"; const disabled = categories.length === 0; + const parsedAmount = Number.parseFloat(formState.amount); + const sliderValue = Number.isFinite(parsedAmount) + ? Math.max(0, parsedAmount) + : 0; + const baseForSlider = Math.max(budget?.spent ?? 0, sliderValue, 1000); + const sliderMax = Math.max( + 1000, + Math.ceil((baseForSlider * 1.5) / 100) * 100, + ); return ( @@ -215,7 +230,7 @@ export function BudgetDialog({
-
+
- updateField("amount", value)} - /> +
+
+ Limite atual + + {formatCurrency(sliderValue)} + +
+ + + updateField("amount", value[0]?.toFixed(2) ?? "0.00") + } + /> + +
+ {formatCurrency(0)} + {formatCurrency(sliderMax)} +
+
diff --git a/components/top-estabelecimentos/period-filter.tsx b/components/top-estabelecimentos/period-filter.tsx index 2bc9495..d1bf19a 100644 --- a/components/top-estabelecimentos/period-filter.tsx +++ b/components/top-estabelecimentos/period-filter.tsx @@ -22,7 +22,7 @@ export function PeriodFilterButtons({ currentFilter }: PeriodFilterProps) { const handleFilterChange = (filter: PeriodFilter) => { const params = new URLSearchParams(searchParams.toString()); params.set("meses", filter); - router.push(`/top-estabelecimentos?${params.toString()}`); + router.push(`/relatorios/estabelecimentos?${params.toString()}`); }; return ( diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx new file mode 100644 index 0000000..250cc19 --- /dev/null +++ b/components/ui/slider.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Slider as SliderPrimitive } from "radix-ui"; +import type * as React from "react"; +import { cn } from "@/lib/utils/ui"; + +function Slider({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + + ); +} + +export { Slider }; diff --git a/lib/actions/helpers.ts b/lib/actions/helpers.ts index 196bb62..ba75655 100644 --- a/lib/actions/helpers.ts +++ b/lib/actions/helpers.ts @@ -27,7 +27,7 @@ export const revalidateConfig = { estabelecimentos: ["/estabelecimentos", "/lancamentos"], orcamentos: ["/orcamentos"], pagadores: ["/pagadores"], - anotacoes: ["/anotacoes", "/anotacoes/arquivadas"], + anotacoes: ["/anotacoes", "/anotacoes/arquivadas", "/dashboard"], lancamentos: ["/lancamentos", "/contas"], inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"], } as const; @@ -39,6 +39,7 @@ const DASHBOARD_ENTITIES: ReadonlySet = new Set([ "cartoes", "orcamentos", "pagadores", + "anotacoes", "inbox", ]); diff --git a/lib/dashboard/goals-progress.ts b/lib/dashboard/goals-progress.ts new file mode 100644 index 0000000..3142a57 --- /dev/null +++ b/lib/dashboard/goals-progress.ts @@ -0,0 +1,147 @@ +import { and, eq, ne, sql } from "drizzle-orm"; +import { categorias, lancamentos, orcamentos } from "@/db/schema"; +import { toNumber } from "@/lib/dashboard/common"; +import { db } from "@/lib/db"; +import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; + +const BUDGET_CRITICAL_THRESHOLD = 80; + +export type GoalProgressStatus = "on-track" | "critical" | "exceeded"; + +export type GoalProgressItem = { + id: string; + categoryId: string | null; + categoryName: string; + categoryIcon: string | null; + period: string; + createdAt: string; + budgetAmount: number; + spentAmount: number; + usedPercentage: number; + status: GoalProgressStatus; +}; + +export type GoalProgressCategory = { + id: string; + name: string; + icon: string | null; +}; + +export type GoalsProgressData = { + items: GoalProgressItem[]; + categories: GoalProgressCategory[]; + totalBudgets: number; + exceededCount: number; + criticalCount: number; +}; + +const resolveStatus = (usedPercentage: number): GoalProgressStatus => { + if (usedPercentage >= 100) { + return "exceeded"; + } + if (usedPercentage >= BUDGET_CRITICAL_THRESHOLD) { + return "critical"; + } + return "on-track"; +}; + +export async function fetchGoalsProgressData( + userId: string, + period: string, +): Promise { + const adminPagadorId = await getAdminPagadorId(userId); + + if (!adminPagadorId) { + return { + items: [], + categories: [], + totalBudgets: 0, + exceededCount: 0, + criticalCount: 0, + }; + } + + const [rows, categoryRows] = await Promise.all([ + db + .select({ + orcamentoId: orcamentos.id, + categoryId: categorias.id, + categoryName: categorias.name, + categoryIcon: categorias.icon, + period: orcamentos.period, + createdAt: orcamentos.createdAt, + budgetAmount: orcamentos.amount, + spentAmount: sql`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`, + }) + .from(orcamentos) + .innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id)) + .leftJoin( + lancamentos, + and( + eq(lancamentos.categoriaId, orcamentos.categoriaId), + eq(lancamentos.userId, orcamentos.userId), + eq(lancamentos.period, orcamentos.period), + eq(lancamentos.pagadorId, adminPagadorId), + eq(lancamentos.transactionType, "Despesa"), + ne(lancamentos.condition, "cancelado"), + ), + ) + .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))) + .groupBy( + orcamentos.id, + categorias.id, + categorias.name, + categorias.icon, + orcamentos.period, + orcamentos.createdAt, + orcamentos.amount, + ), + db.query.categorias.findMany({ + where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")), + orderBy: (category, { asc }) => [asc(category.name)], + }), + ]); + + const categories: GoalProgressCategory[] = categoryRows.map((category) => ({ + id: category.id, + name: category.name, + icon: category.icon, + })); + + const items: GoalProgressItem[] = rows + .map((row) => { + const budgetAmount = toNumber(row.budgetAmount); + const spentAmount = toNumber(row.spentAmount); + const usedPercentage = + budgetAmount > 0 ? (spentAmount / budgetAmount) * 100 : 0; + + return { + id: row.orcamentoId, + categoryId: row.categoryId, + categoryName: row.categoryName, + categoryIcon: row.categoryIcon, + period: row.period, + createdAt: row.createdAt.toISOString(), + budgetAmount, + spentAmount, + usedPercentage, + status: resolveStatus(usedPercentage), + }; + }) + .sort((a, b) => b.usedPercentage - a.usedPercentage); + + const exceededCount = items.filter( + (item) => item.status === "exceeded", + ).length; + const criticalCount = items.filter( + (item) => item.status === "critical", + ).length; + + return { + items, + categories, + totalBudgets: items.length, + exceededCount, + criticalCount, + }; +} diff --git a/lib/dashboard/notes.ts b/lib/dashboard/notes.ts new file mode 100644 index 0000000..fae68c2 --- /dev/null +++ b/lib/dashboard/notes.ts @@ -0,0 +1,73 @@ +import { and, eq } from "drizzle-orm"; +import { anotacoes } from "@/db/schema"; +import { db } from "@/lib/db"; + +export type DashboardTask = { + id: string; + text: string; + completed: boolean; +}; + +export type DashboardNote = { + id: string; + title: string; + description: string; + type: "nota" | "tarefa"; + tasks?: DashboardTask[]; + arquivada: boolean; + createdAt: string; +}; + +const parseTasks = (value: string | null): DashboardTask[] | undefined => { + if (!value) { + return undefined; + } + + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + return undefined; + } + + return parsed + .filter((item): item is DashboardTask => { + if (!item || typeof item !== "object") { + return false; + } + const candidate = item as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.text === "string" && + typeof candidate.completed === "boolean" + ); + }) + .map((task) => ({ + id: task.id, + text: task.text, + completed: task.completed, + })); + } catch (error) { + console.error("Failed to parse dashboard note tasks", error); + return undefined; + } +}; + +export async function fetchDashboardNotes( + userId: string, +): Promise { + const notes = await db.query.anotacoes.findMany({ + where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)), + orderBy: (note, { desc }) => [desc(note.createdAt)], + limit: 5, + }); + + return notes.map((note) => ({ + id: note.id, + title: (note.title ?? "").trim(), + description: (note.description ?? "").trim(), + type: (note.type ?? "nota") as "nota" | "tarefa", + tasks: parseTasks(note.tasks), + arquivada: note.arquivada, + createdAt: note.createdAt.toISOString(), + })); +} diff --git a/lib/dashboard/pagadores.ts b/lib/dashboard/pagadores.ts index 9b648b8..2c7e19c 100644 --- a/lib/dashboard/pagadores.ts +++ b/lib/dashboard/pagadores.ts @@ -1,9 +1,11 @@ -import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { lancamentos, pagadores } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants"; import { toNumber } from "@/lib/dashboard/common"; import { db } from "@/lib/db"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { calculatePercentageChange } from "@/lib/utils/math"; +import { getPreviousPeriod } from "@/lib/utils/period"; export type DashboardPagador = { id: string; @@ -11,6 +13,8 @@ export type DashboardPagador = { email: string | null; avatarUrl: string | null; totalExpenses: number; + previousExpenses: number; + percentageChange: number | null; isAdmin: boolean; }; @@ -23,6 +27,8 @@ export async function fetchDashboardPagadores( userId: string, period: string, ): Promise { + const previousPeriod = getPreviousPeriod(period); + const rows = await db .select({ id: pagadores.id, @@ -30,6 +36,7 @@ export async function fetchDashboardPagadores( email: pagadores.email, avatarUrl: pagadores.avatarUrl, role: pagadores.role, + period: lancamentos.period, totalExpenses: sql`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`, }) .from(lancamentos) @@ -37,7 +44,7 @@ export async function fetchDashboardPagadores( .where( and( eq(lancamentos.userId, userId), - eq(lancamentos.period, period), + inArray(lancamentos.period, [period, previousPeriod]), eq(lancamentos.transactionType, "Despesa"), or( isNull(lancamentos.note), @@ -51,19 +58,60 @@ export async function fetchDashboardPagadores( pagadores.email, pagadores.avatarUrl, pagadores.role, + lancamentos.period, ) .orderBy(desc(sql`SUM(ABS(${lancamentos.amount}))`)); - const pagadoresList = rows - .map((row) => ({ + const groupedPagadores = new Map< + string, + { + id: string; + name: string; + email: string | null; + avatarUrl: string | null; + isAdmin: boolean; + currentExpenses: number; + previousExpenses: number; + } + >(); + + for (const row of rows) { + const entry = groupedPagadores.get(row.id) ?? { id: row.id, name: row.name, email: row.email, avatarUrl: row.avatarUrl, - totalExpenses: toNumber(row.totalExpenses), isAdmin: row.role === PAGADOR_ROLE_ADMIN, + currentExpenses: 0, + previousExpenses: 0, + }; + + const amount = toNumber(row.totalExpenses); + if (row.period === period) { + entry.currentExpenses = amount; + } else { + entry.previousExpenses = amount; + } + + groupedPagadores.set(row.id, entry); + } + + const pagadoresList = Array.from(groupedPagadores.values()) + .filter((p) => p.currentExpenses > 0) + .map((pagador) => ({ + id: pagador.id, + name: pagador.name, + email: pagador.email, + avatarUrl: pagador.avatarUrl, + totalExpenses: pagador.currentExpenses, + previousExpenses: pagador.previousExpenses, + percentageChange: calculatePercentageChange( + pagador.currentExpenses, + pagador.previousExpenses, + ), + isAdmin: pagador.isAdmin, })) - .filter((p) => p.totalExpenses > 0); + .sort((a, b) => b.totalExpenses - a.totalExpenses); const totalExpenses = pagadoresList.reduce( (sum, p) => sum + p.totalExpenses, diff --git a/lib/dashboard/top-establishments.ts b/lib/dashboard/top-establishments.ts index 69fd1ac..d7057c7 100644 --- a/lib/dashboard/top-establishments.ts +++ b/lib/dashboard/top-establishments.ts @@ -69,7 +69,10 @@ export async function fetchTopEstablishments( ), ) .groupBy(lancamentos.name) - .orderBy(sql`ABS(sum(${lancamentos.amount})) DESC`) + .orderBy( + sql`count(${lancamentos.id}) DESC`, + sql`ABS(sum(${lancamentos.amount})) DESC`, + ) .limit(10); const establishments = rows diff --git a/lib/dashboard/widgets/widgets-config.tsx b/lib/dashboard/widgets/widgets-config.tsx index 57eab3e..b1461f9 100644 --- a/lib/dashboard/widgets/widgets-config.tsx +++ b/lib/dashboard/widgets/widgets-config.tsx @@ -7,33 +7,30 @@ import { RiExchangeLine, RiGroupLine, RiLineChartLine, - RiMoneyDollarCircleLine, RiNumbersLine, RiPieChartLine, RiRefreshLine, - RiSlideshowLine, - RiStore2Line, RiStore3Line, + RiTodoLine, RiWallet3Line, } from "@remixicon/react"; import Link from "next/link"; import type { ReactNode } from "react"; import { BoletosWidget } from "@/components/dashboard/boletos-widget"; import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart"; +import { GoalsProgressWidget } from "@/components/dashboard/goals-progress-widget"; import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart"; import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget"; import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget"; import { InvoicesWidget } from "@/components/dashboard/invoices-widget"; import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget"; +import { NotesWidget } from "@/components/dashboard/notes-widget"; import { PagadoresWidget } from "@/components/dashboard/pagadores-widget"; -import { PaymentConditionsWidget } from "@/components/dashboard/payment-conditions-widget"; -import { PaymentMethodsWidget } from "@/components/dashboard/payment-methods-widget"; +import { PaymentOverviewWidget } from "@/components/dashboard/payment-overview-widget"; import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget"; import { PurchasesByCategoryWidget } from "@/components/dashboard/purchases-by-category-widget"; -import { RecentTransactionsWidget } from "@/components/dashboard/recent-transactions-widget"; import { RecurringExpensesWidget } from "@/components/dashboard/recurring-expenses-widget"; -import { TopEstablishmentsWidget } from "@/components/dashboard/top-establishments-widget"; -import { TopExpensesWidget } from "@/components/dashboard/top-expenses-widget"; +import { SpendingOverviewWidget } from "@/components/dashboard/spending-overview-widget"; import type { DashboardData } from "./fetch-dashboard-data"; export type WidgetConfig = { @@ -114,30 +111,49 @@ export const widgetsConfig: WidgetConfig[] = [ ), }, { - id: "recent-transactions", - title: "Lançamentos Recentes", - subtitle: "Últimas 5 despesas registradas", + id: "notes", + title: "Anotações", + subtitle: "Últimas anotações ativas", + icon: , + component: ({ data }) => , + action: ( + + Ver todas + + + ), + }, + { + id: "goals-progress", + title: "Progresso de Orçamentos", + subtitle: "Orçamentos por categoria no período", icon: , component: ({ data }) => ( - + + ), + action: ( + + Ver todos + + ), }, { - id: "payment-conditions", - title: "Condições de Pagamentos", - subtitle: "Análise das condições", - icon: , + id: "payment-overview", + title: "Comportamento de Pagamento", + subtitle: "Despesas por condição e forma de pagamento", + icon: , component: ({ data }) => ( - - ), - }, - { - id: "payment-methods", - title: "Formas de Pagamento", - subtitle: "Distribuição das despesas", - icon: , - component: ({ data }) => ( - + ), }, { @@ -168,35 +184,18 @@ export const widgetsConfig: WidgetConfig[] = [ ), }, { - id: "top-expenses", - title: "Maiores Gastos do Mês", - subtitle: "Top 10 Despesas", + id: "spending-overview", + title: "Panorama de Gastos", + subtitle: "Principais despesas e frequência por local", icon: , component: ({ data }) => ( - ), }, - { - id: "top-establishments", - title: "Top Estabelecimentos", - subtitle: "Frequência de gastos no período", - icon: , - component: ({ data }) => ( - - ), - action: ( - - Ver mais - - - ), - }, { id: "purchases-by-category", title: "Lançamentos por Categorias", From bff72d0504d78222a6349b076b44a793b26bc60d Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Mon, 2 Mar 2026 17:20:46 +0000 Subject: [PATCH 3/4] chore(cleanup): remove dead code and legacy top-estabelecimentos route --- CHANGELOG.md | 15 +++ .../top-estabelecimentos/layout.tsx | 23 ---- .../top-estabelecimentos/loading.tsx | 58 ---------- app/(dashboard)/top-estabelecimentos/page.tsx | 76 ------------- app/robots.ts | 1 - components/auth/auth-footer.tsx | 17 --- components/auth/login-form.tsx | 1 - components/auth/signup-form.tsx | 1 - components/categorias/category-card.tsx | 103 ------------------ .../dashboard/recent-transactions-widget.tsx | 67 ------------ components/lancamentos/index.ts | 21 ---- components/lancamentos/shared/index.ts | 4 - components/relatorios/index.ts | 13 --- lib/dashboard/fetch-dashboard-data.ts | 16 +-- lib/dashboard/recent-transactions.ts | 78 ------------- 15 files changed, 23 insertions(+), 471 deletions(-) delete mode 100644 app/(dashboard)/top-estabelecimentos/layout.tsx delete mode 100644 app/(dashboard)/top-estabelecimentos/loading.tsx delete mode 100644 app/(dashboard)/top-estabelecimentos/page.tsx delete mode 100644 components/auth/auth-footer.tsx delete mode 100644 components/categorias/category-card.tsx delete mode 100644 components/dashboard/recent-transactions-widget.tsx delete mode 100644 components/lancamentos/index.ts delete mode 100644 components/lancamentos/shared/index.ts delete mode 100644 components/relatorios/index.ts delete mode 100644 lib/dashboard/recent-transactions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aedcd7..a834dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,17 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR - Tabela `passkey` no banco de dados para persistência de credenciais WebAuthn vinculadas ao usuário - Nova aba **Passkeys** em `/ajustes` com gerenciamento de credenciais: listar, adicionar, renomear e remover passkeys - Ação de login com passkey na tela de autenticação (`/login`) +- Dashboard: botões rápidos na toolbar de widgets para `Nova receita`, `Nova despesa` e `Nova anotação` com abertura direta dos diálogos correspondentes +- Widget de **Anotações** no dashboard com listagem das anotações ativas, ações discretas de editar e ver detalhes, e atalho para `/anotacoes` ### Alterado - `PasskeysForm` refatorado para melhor experiência com React 19/Next 16: detecção de suporte do navegador, bloqueio de ações simultâneas e atualização da lista sem loader global após operações +- Widget de pagadores no dashboard agora exibe variação percentual em relação ao mês anterior (seta + cor semântica), seguindo o padrão visual dos widgets de categorias +- Dashboard: widgets `Condições de Pagamentos` + `Formas de Pagamento` unificados em um único widget com abas; `Top Estabelecimentos` + `Maiores Gastos do Mês` também unificados em widget com abas +- Relatórios: rota de Top Estabelecimentos consolidada em `/relatorios/estabelecimentos` +- Dashboard: widget `Lançamentos recentes` removido e substituído por `Progresso de metas` com lista de orçamentos do período (gasto, limite configurado e percentual de uso por categoria) +- Dashboard: `fetchDashboardData` deixou de carregar `notificationsSnapshot` (notificações continuam sendo carregadas no layout), reduzindo uma query no carregamento da página inicial ### Corrigido @@ -24,6 +31,14 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR - Listagem de passkeys em Ajustes agora trata `createdAt` ausente sem gerar data inválida na interface - Migração `0017_previous_warstar` tornou-se idempotente para colunas de `preferencias_usuario` com `IF NOT EXISTS`, evitando falha em bancos já migrados +### Removido + +- Código legado não utilizado no dashboard: widget e fetcher de `Lançamentos Recentes` +- Componente legado `CategoryCard` em categorias (substituído pelo layout atual em tabela) +- Componente `AuthFooter` não utilizado na autenticação +- Barrel files sem consumo em `components/relatorios`, `components/lancamentos` e `components/lancamentos/shared` +- Rota legada `/top-estabelecimentos` e arquivos auxiliares (`layout.tsx` e `loading.tsx`) removidos + ## [1.7.5] - 2026-02-28 ### Adicionado diff --git a/app/(dashboard)/top-estabelecimentos/layout.tsx b/app/(dashboard)/top-estabelecimentos/layout.tsx deleted file mode 100644 index c20ca10..0000000 --- a/app/(dashboard)/top-estabelecimentos/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { RiStore2Line } from "@remixicon/react"; -import PageDescription from "@/components/page-description"; - -export const metadata = { - title: "Top Estabelecimentos | OpenMonetis", -}; - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
- } - title="Top Estabelecimentos" - subtitle="Análise dos locais onde você mais compra e gasta" - /> - {children} -
- ); -} diff --git a/app/(dashboard)/top-estabelecimentos/loading.tsx b/app/(dashboard)/top-estabelecimentos/loading.tsx deleted file mode 100644 index b0fb8e5..0000000 --- a/app/(dashboard)/top-estabelecimentos/loading.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
- - -
- -
- -
- {[1, 2, 3, 4].map((i) => ( - - - - - - ))} -
- -
- - -
- -
-
- - - - - - {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( - - ))} - - -
-
- - - - - - {[1, 2, 3, 4, 5].map((i) => ( - - ))} - - -
-
-
- ); -} diff --git a/app/(dashboard)/top-estabelecimentos/page.tsx b/app/(dashboard)/top-estabelecimentos/page.tsx deleted file mode 100644 index deb9c5c..0000000 --- a/app/(dashboard)/top-estabelecimentos/page.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { EstablishmentsList } from "@/components/top-estabelecimentos/establishments-list"; -import { HighlightsCards } from "@/components/top-estabelecimentos/highlights-cards"; -import { PeriodFilterButtons } from "@/components/top-estabelecimentos/period-filter"; -import { SummaryCards } from "@/components/top-estabelecimentos/summary-cards"; -import { TopCategories } from "@/components/top-estabelecimentos/top-categories"; -import { Card } from "@/components/ui/card"; -import { getUser } from "@/lib/auth/server"; -import { - fetchTopEstabelecimentosData, - type PeriodFilter, -} from "@/lib/top-estabelecimentos/fetch-data"; -import { parsePeriodParam } from "@/lib/utils/period"; - -type PageSearchParams = Promise>; - -type PageProps = { - searchParams?: PageSearchParams; -}; - -const getSingleParam = ( - params: Record | undefined, - key: string, -) => { - const value = params?.[key]; - if (!value) return null; - return Array.isArray(value) ? (value[0] ?? null) : value; -}; - -const validatePeriodFilter = (value: string | null): PeriodFilter => { - if (value === "3" || value === "6" || value === "12") { - return value; - } - return "6"; -}; - -export default async function TopEstabelecimentosPage({ - searchParams, -}: PageProps) { - const user = await getUser(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); - const mesesParam = getSingleParam(resolvedSearchParams, "meses"); - - const { period: currentPeriod } = parsePeriodParam(periodoParam); - const periodFilter = validatePeriodFilter(mesesParam); - - const data = await fetchTopEstabelecimentosData( - user.id, - currentPeriod, - periodFilter, - ); - - return ( -
- - - Selecione o intervalo de meses - - - - - - - - -
-
- -
-
- -
-
-
- ); -} diff --git a/app/robots.ts b/app/robots.ts index 8fcca48..446c333 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -24,7 +24,6 @@ export default function robots(): MetadataRoute.Robots { "/consultor", "/ajustes", "/relatorios", - "/top-estabelecimentos", "/pre-lancamentos", "/login", "/api/", diff --git a/components/auth/auth-footer.tsx b/components/auth/auth-footer.tsx deleted file mode 100644 index 09cb9d9..0000000 --- a/components/auth/auth-footer.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { FieldDescription } from "@/components/ui/field"; - -export function AuthFooter() { - return ( - - Ao continuar, você concorda com nossos{" "} -
- Termos de Serviço - {" "} - e{" "} - - Política de Privacidade - - . - - ); -} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index a828dfd..4a2ff0f 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -255,7 +255,6 @@ export function LoginForm({ className, ...props }: DivProps) { - {/* */} Voltar para o site diff --git a/components/auth/signup-form.tsx b/components/auth/signup-form.tsx index aeb8f35..1efeb98 100644 --- a/components/auth/signup-form.tsx +++ b/components/auth/signup-form.tsx @@ -281,7 +281,6 @@ export function SignupForm({ className, ...props }: DivProps) { - {/* */} Voltar para o site diff --git a/components/categorias/category-card.tsx b/components/categorias/category-card.tsx deleted file mode 100644 index f129a5d..0000000 --- a/components/categorias/category-card.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { - RiDeleteBin5Line, - RiFileList2Line, - RiPencilLine, -} from "@remixicon/react"; -import Link from "next/link"; -import { Card, CardContent, CardFooter } from "@/components/ui/card"; -import { cn } from "@/lib/utils/ui"; -import { CategoryIconBadge } from "./category-icon-badge"; -import type { Category } from "./types"; - -interface CategoryCardProps { - category: Category; - colorIndex: number; - onEdit: (category: Category) => void; - onRemove: (category: Category) => void; -} - -export function CategoryCard({ - category, - colorIndex, - onEdit, - onRemove, -}: CategoryCardProps) { - const categoriasProtegidas = [ - "Transferência interna", - "Saldo inicial", - "Pagamentos", - ]; - const isProtegida = categoriasProtegidas.includes(category.name); - - const actions = [ - { - label: "editar", - icon: , - onClick: () => onEdit(category), - variant: "default" as const, - disabled: isProtegida, - }, - { - label: "detalhes", - icon: , - href: `/categorias/${category.id}`, - variant: "default" as const, - disabled: false, - }, - { - label: "remover", - icon: , - onClick: () => onRemove(category), - variant: "destructive" as const, - disabled: isProtegida, - }, - ].filter((action) => !action.disabled); - - return ( - - -
- -

{category.name}

-
-
- - - {actions.map(({ label, icon, onClick, href, variant }) => { - const className = cn( - "flex items-center gap-1 font-medium transition-opacity hover:opacity-80", - variant === "destructive" ? "text-destructive" : "text-primary", - ); - - if (href) { - return ( - - {icon} - {label} - - ); - } - - return ( - - ); - })} - -
- ); -} diff --git a/components/dashboard/recent-transactions-widget.tsx b/components/dashboard/recent-transactions-widget.tsx deleted file mode 100644 index 083e256..0000000 --- a/components/dashboard/recent-transactions-widget.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { RiExchangeLine } from "@remixicon/react"; -import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo"; -import MoneyValues from "@/components/money-values"; -import type { RecentTransactionsData } from "@/lib/dashboard/recent-transactions"; -import { WidgetEmptyState } from "../widget-empty-state"; - -type RecentTransactionsWidgetProps = { - data: RecentTransactionsData; -}; - -const formatTransactionDate = (date: Date | string) => { - const d = date instanceof Date ? date : new Date(date); - const formatter = new Intl.DateTimeFormat("pt-BR", { - weekday: "short", - day: "2-digit", - month: "short", - timeZone: "UTC", - }); - - const formatted = formatter.format(d); - // Capitaliza a primeira letra do dia da semana - return formatted.charAt(0).toUpperCase() + formatted.slice(1); -}; - -export function RecentTransactionsWidget({ - data, -}: RecentTransactionsWidgetProps) { - return ( -
- {data.transactions.length === 0 ? ( - } - title="Nenhum lançamento encontrado" - description="Quando houver despesas registradas, elas aparecerão aqui." - /> - ) : ( -
    - {data.transactions.map((transaction) => { - return ( -
  • -
    - - -
    -

    - {transaction.name} -

    -

    - {formatTransactionDate(transaction.purchaseDate)} -

    -
    -
    - -
    - -
    -
  • - ); - })} -
- )} -
- ); -} diff --git a/components/lancamentos/index.ts b/components/lancamentos/index.ts deleted file mode 100644 index 822a933..0000000 --- a/components/lancamentos/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Main page component - -export { default as AnticipateInstallmentsDialog } from "./dialogs/anticipate-installments-dialog/anticipate-installments-dialog"; -export { default as BulkActionDialog } from "./dialogs/bulk-action-dialog"; -export { default as LancamentoDetailsDialog } from "./dialogs/lancamento-details-dialog"; - -// Main dialogs -export { default as LancamentoDialog } from "./dialogs/lancamento-dialog/lancamento-dialog"; -export type * from "./dialogs/lancamento-dialog/lancamento-dialog-types"; -export { default as MassAddDialog } from "./dialogs/mass-add-dialog"; -export { default as LancamentosPage } from "./page/lancamentos-page"; -export * from "./select-items"; -export { default as AnticipationCard } from "./shared/anticipation-card"; -// Shared components -export { default as EstabelecimentoInput } from "./shared/estabelecimento-input"; -export { default as InstallmentTimeline } from "./shared/installment-timeline"; -export { default as LancamentosFilters } from "./table/lancamentos-filters"; -// Table components -export { default as LancamentosTable } from "./table/lancamentos-table"; -// Types and utilities -export type * from "./types"; diff --git a/components/lancamentos/shared/index.ts b/components/lancamentos/shared/index.ts deleted file mode 100644 index 199b606..0000000 --- a/components/lancamentos/shared/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { AnticipationCard } from "./anticipation-card"; -export { EstabelecimentoInput } from "./estabelecimento-input"; -export { EstabelecimentoLogo } from "./estabelecimento-logo"; -export { InstallmentTimeline } from "./installment-timeline"; diff --git a/components/relatorios/index.ts b/components/relatorios/index.ts deleted file mode 100644 index 42f6a4f..0000000 --- a/components/relatorios/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { CategoryCell } from "./category-cell"; -export { CategoryReportCards } from "./category-report-cards"; -export { CategoryReportChart } from "./category-report-chart"; -export { CategoryReportExport } from "./category-report-export"; -export { CategoryReportFilters } from "./category-report-filters"; -export { CategoryReportPage } from "./category-report-page"; -export { CategoryReportTable } from "./category-report-table"; -export { CategoryTable } from "./category-table"; -export type { - CategoryOption, - CategoryReportFiltersProps, - FilterState, -} from "./types"; diff --git a/lib/dashboard/fetch-dashboard-data.ts b/lib/dashboard/fetch-dashboard-data.ts index c66fbb0..989a535 100644 --- a/lib/dashboard/fetch-dashboard-data.ts +++ b/lib/dashboard/fetch-dashboard-data.ts @@ -6,16 +6,16 @@ import { fetchIncomeByCategory } from "./categories/income-by-category"; import { fetchInstallmentExpenses } from "./expenses/installment-expenses"; import { fetchRecurringExpenses } from "./expenses/recurring-expenses"; import { fetchTopExpenses } from "./expenses/top-expenses"; +import { fetchGoalsProgressData } from "./goals-progress"; import { fetchIncomeExpenseBalance } from "./income-expense-balance"; import { fetchDashboardInvoices } from "./invoices"; import { fetchDashboardCardMetrics } from "./metrics"; -import { fetchDashboardNotifications } from "./notifications"; +import { fetchDashboardNotes } from "./notes"; import { fetchDashboardPagadores } from "./pagadores"; import { fetchPaymentConditions } from "./payments/payment-conditions"; import { fetchPaymentMethods } from "./payments/payment-methods"; import { fetchPaymentStatus } from "./payments/payment-status"; import { fetchPurchasesByCategory } from "./purchases-by-category"; -import { fetchRecentTransactions } from "./recent-transactions"; import { fetchTopEstablishments } from "./top-establishments"; async function fetchDashboardDataInternal(userId: string, period: string) { @@ -24,11 +24,11 @@ async function fetchDashboardDataInternal(userId: string, period: string) { accountsSnapshot, invoicesSnapshot, boletosSnapshot, - notificationsSnapshot, + goalsProgressData, paymentStatusData, incomeExpenseBalanceData, pagadoresSnapshot, - recentTransactionsData, + notesData, paymentConditionsData, paymentMethodsData, recurringExpensesData, @@ -44,11 +44,11 @@ async function fetchDashboardDataInternal(userId: string, period: string) { fetchDashboardAccounts(userId), fetchDashboardInvoices(userId, period), fetchDashboardBoletos(userId, period), - fetchDashboardNotifications(userId, period), + fetchGoalsProgressData(userId, period), fetchPaymentStatus(userId, period), fetchIncomeExpenseBalance(userId, period), fetchDashboardPagadores(userId, period), - fetchRecentTransactions(userId, period), + fetchDashboardNotes(userId), fetchPaymentConditions(userId, period), fetchPaymentMethods(userId, period), fetchRecurringExpenses(userId, period), @@ -66,11 +66,11 @@ async function fetchDashboardDataInternal(userId: string, period: string) { accountsSnapshot, invoicesSnapshot, boletosSnapshot, - notificationsSnapshot, + goalsProgressData, paymentStatusData, incomeExpenseBalanceData, pagadoresSnapshot, - recentTransactionsData, + notesData, paymentConditionsData, paymentMethodsData, recurringExpensesData, diff --git a/lib/dashboard/recent-transactions.ts b/lib/dashboard/recent-transactions.ts deleted file mode 100644 index 29bb8df..0000000 --- a/lib/dashboard/recent-transactions.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; -import { cartoes, contas, lancamentos } from "@/db/schema"; -import { - ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, - INITIAL_BALANCE_NOTE, -} from "@/lib/accounts/constants"; -import { toNumber } from "@/lib/dashboard/common"; -import { db } from "@/lib/db"; -import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; - -export type RecentTransaction = { - id: string; - name: string; - amount: number; - purchaseDate: Date; - cardLogo: string | null; - accountLogo: string | null; -}; - -export type RecentTransactionsData = { - transactions: RecentTransaction[]; -}; - -export async function fetchRecentTransactions( - userId: string, - period: string, -): Promise { - const adminPagadorId = await getAdminPagadorId(userId); - if (!adminPagadorId) { - return { transactions: [] }; - } - - const results = await db - .select({ - id: lancamentos.id, - name: lancamentos.name, - amount: lancamentos.amount, - purchaseDate: lancamentos.purchaseDate, - cardLogo: cartoes.logo, - accountLogo: contas.logo, - note: lancamentos.note, - }) - .from(lancamentos) - .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id)) - .leftJoin(contas, eq(lancamentos.contaId, contas.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(lancamentos.transactionType, "Despesa"), - eq(lancamentos.pagadorId, adminPagadorId), - or( - isNull(lancamentos.note), - and( - sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`, - sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, - ), - ), - ), - ) - .orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)) - .limit(5); - - const transactions = results.map((row): RecentTransaction => { - return { - id: row.id, - name: row.name, - amount: Math.abs(toNumber(row.amount)), - purchaseDate: row.purchaseDate, - cardLogo: row.cardLogo, - accountLogo: row.accountLogo, - }; - }); - - return { - transactions, - }; -} From 4cde5ccae39d65f8133f7658555bb8255fe6a254 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Mon, 2 Mar 2026 23:21:35 +0000 Subject: [PATCH 4/4] feat: refine passkey login and editable dashboard grid --- components/auth/login-form.tsx | 19 +------------------ .../dashboard/dashboard-grid-editable.tsx | 4 ++-- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index 4a2ff0f..9516012 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -41,23 +41,6 @@ export function LoginForm({ className, ...props }: DivProps) { if (typeof PublicKeyCredential === "undefined") return; setPasskeySupported(true); - - if ( - typeof PublicKeyCredential.isConditionalMediationAvailable === "function" - ) { - PublicKeyCredential.isConditionalMediationAvailable() - .then((available) => { - if (available) { - // Conditional UI é opcional: habilita autofill quando disponível. - authClient.signIn.passkey({ - mediation: "conditional", - }); - } - }) - .catch(() => { - // Ignora falhas de detecção e mantém login manual por passkey. - }); - } }, []); async function handleSubmit(e: FormEvent) { @@ -237,7 +220,7 @@ export function LoginForm({ className, ...props }: DivProps) { ) : ( )} - Entrar com Passkey + Entrar com passkey )} diff --git a/components/dashboard/dashboard-grid-editable.tsx b/components/dashboard/dashboard-grid-editable.tsx index d2cb153..f3158ba 100644 --- a/components/dashboard/dashboard-grid-editable.tsx +++ b/components/dashboard/dashboard-grid-editable.tsx @@ -201,9 +201,9 @@ export function DashboardGridEditable({ {/* Toolbar */}
{!isEditing ? ( -
+
- Ação rápida + Ações rápidas