refactor: migrate from ESLint to Biome and extract SQL queries to data.ts

- Replace ESLint with Biome for linting and formatting
- Configure Biome with tabs, double quotes, and organized imports
- Move all SQL/Drizzle queries from page.tsx files to data.ts files
- Create new data.ts files for: ajustes, dashboard, relatorios/categorias
- Update existing data.ts files: extrato, fatura (add lancamentos queries)
- Remove all drizzle-orm imports from page.tsx files
- Update README.md with new tooling info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -1,11 +1,11 @@
import { LoginForm } from "@/components/auth/login-form";
export default function LoginPage() {
return (
<div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<LoginForm />
</div>
</div>
);
return (
<div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<LoginForm />
</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
import { SignupForm } from "@/components/auth/signup-form";
export default function Page() {
return (
<div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<SignupForm />
</div>
</div>
);
return (
<div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-4xl">
<SignupForm />
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
import { desc, eq } from "drizzle-orm";
import { apiTokens } from "@/db/schema";
import { db, schema } from "@/lib/db";
export interface UserPreferences {
disableMagnetlines: boolean;
}
export interface ApiToken {
id: string;
name: string;
tokenPrefix: string;
lastUsedAt: Date | null;
lastUsedIp: string | null;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
}
export async function fetchAuthProvider(userId: string): Promise<string> {
const userAccount = await db.query.account.findFirst({
where: eq(schema.account.userId, userId),
});
return userAccount?.providerId || "credential";
}
export async function fetchUserPreferences(
userId: string,
): Promise<UserPreferences | null> {
const result = await db
.select({
disableMagnetlines: schema.userPreferences.disableMagnetlines,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId))
.limit(1);
return result[0] || null;
}
export async function fetchApiTokens(userId: string): Promise<ApiToken[]> {
return db
.select({
id: apiTokens.id,
name: apiTokens.name,
tokenPrefix: apiTokens.tokenPrefix,
lastUsedAt: apiTokens.lastUsedAt,
lastUsedIp: apiTokens.lastUsedIp,
createdAt: apiTokens.createdAt,
expiresAt: apiTokens.expiresAt,
revokedAt: apiTokens.revokedAt,
})
.from(apiTokens)
.where(eq(apiTokens.userId, userId))
.orderBy(desc(apiTokens.createdAt));
}
export async function fetchAjustesPageData(userId: string) {
const [authProvider, userPreferences, userApiTokens] = await Promise.all([
fetchAuthProvider(userId),
fetchUserPreferences(userId),
fetchApiTokens(userId),
]);
return {
authProvider,
userPreferences,
userApiTokens,
};
}

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiSettingsLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Ajustes | Opensheets",
title: "Ajustes | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiSettingsLine />}
title="Ajustes"
subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência."
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiSettingsLine />}
title="Ajustes"
subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência."
/>
{children}
</section>
);
}

View File

@@ -1,180 +1,148 @@
import { ApiTokensForm } from "@/components/ajustes/api-tokens-form";
import { DeleteAccountForm } from "@/components/ajustes/delete-account-form";
import { UpdateEmailForm } from "@/components/ajustes/update-email-form";
import { UpdateNameForm } from "@/components/ajustes/update-name-form";
import { UpdatePasswordForm } from "@/components/ajustes/update-password-form";
import { PreferencesForm } from "@/components/ajustes/preferences-form";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { auth } from "@/lib/auth/config";
import { db, schema } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { ApiTokensForm } from "@/components/ajustes/api-tokens-form";
import { DeleteAccountForm } from "@/components/ajustes/delete-account-form";
import { PreferencesForm } from "@/components/ajustes/preferences-form";
import { UpdateEmailForm } from "@/components/ajustes/update-email-form";
import { UpdateNameForm } from "@/components/ajustes/update-name-form";
import { UpdatePasswordForm } from "@/components/ajustes/update-password-form";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { auth } from "@/lib/auth/config";
import { fetchAjustesPageData } from "./data";
export default async function Page() {
const session = await auth.api.getSession({
headers: await headers(),
});
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
redirect("/");
}
if (!session?.user) {
redirect("/");
}
const userName = session.user.name || "";
const userEmail = session.user.email || "";
const userName = session.user.name || "";
const userEmail = session.user.email || "";
// Detectar método de autenticação (Google OAuth vs E-mail/Senha)
const userAccount = await db.query.account.findFirst({
where: eq(schema.account.userId, session.user.id),
});
const { authProvider, userPreferences, userApiTokens } =
await fetchAjustesPageData(session.user.id);
// Buscar preferências do usuário
const userPreferencesResult = await db
.select({
disableMagnetlines: schema.userPreferences.disableMagnetlines,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, session.user.id))
.limit(1);
return (
<div className="w-full">
<Tabs defaultValue="preferencias" className="w-full">
<TabsList>
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
<TabsTrigger value="dispositivos">Dispositivos</TabsTrigger>
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive">
Deletar conta
</TabsTrigger>
</TabsList>
const userPreferences = userPreferencesResult[0] || null;
<TabsContent value="preferencias" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground mb-4">
Personalize sua experiência no Opensheets ajustando as
configurações de acordo com suas necessidades.
</p>
</div>
<PreferencesForm
disableMagnetlines={
userPreferences?.disableMagnetlines ?? false
}
/>
</div>
</Card>
</TabsContent>
// Se o providerId for "google", o usuário usa Google OAuth
const authProvider = userAccount?.providerId || "credential";
<TabsContent value="dispositivos" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">OpenSheets Companion</h2>
<p className="text-sm text-muted-foreground mb-4">
Conecte o app Android OpenSheets Companion para capturar
automaticamente notificações de transações financeiras e
enviá-las para sua caixa de entrada.
</p>
</div>
<ApiTokensForm tokens={userApiTokens} />
</div>
</Card>
</TabsContent>
// Buscar tokens de API do usuário
const userApiTokens = await db
.select({
id: apiTokens.id,
name: apiTokens.name,
tokenPrefix: apiTokens.tokenPrefix,
lastUsedAt: apiTokens.lastUsedAt,
lastUsedIp: apiTokens.lastUsedIp,
createdAt: apiTokens.createdAt,
expiresAt: apiTokens.expiresAt,
revokedAt: apiTokens.revokedAt,
})
.from(apiTokens)
.where(eq(apiTokens.userId, session.user.id))
.orderBy(desc(apiTokens.createdAt));
<TabsContent value="nome" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground mb-4">
Atualize como seu nome aparece no Opensheets. Esse nome pode
ser exibido em diferentes seções do app e em comunicações.
</p>
</div>
<UpdateNameForm currentName={userName} />
</div>
</Card>
</TabsContent>
return (
<div className="w-full">
<Tabs defaultValue="preferencias" className="w-full">
<TabsList>
<TabsTrigger value="preferencias">Preferências</TabsTrigger>
<TabsTrigger value="dispositivos">Dispositivos</TabsTrigger>
<TabsTrigger value="nome">Alterar nome</TabsTrigger>
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
<TabsTrigger value="deletar" className="text-destructive">
Deletar conta
</TabsTrigger>
</TabsList>
<TabsContent value="senha" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground mb-4">
Defina uma nova senha para sua conta. Guarde-a em local
seguro.
</p>
</div>
<UpdatePasswordForm authProvider={authProvider} />
</div>
</Card>
</TabsContent>
<TabsContent value="preferencias" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground mb-4">
Personalize sua experiência no Opensheets ajustando as
configurações de acordo com suas necessidades.
</p>
</div>
<PreferencesForm
disableMagnetlines={
userPreferences?.disableMagnetlines ?? false
}
/>
</div>
</Card>
</TabsContent>
<TabsContent value="email" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground mb-4">
Atualize o e-mail associado à sua conta. Você precisará
confirmar os links enviados para o novo e também para o e-mail
atual (quando aplicável) para concluir a alteração.
</p>
</div>
<UpdateEmailForm
currentEmail={userEmail}
authProvider={authProvider}
/>
</div>
</Card>
</TabsContent>
<TabsContent value="dispositivos" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">OpenSheets Companion</h2>
<p className="text-sm text-muted-foreground mb-4">
Conecte o app Android OpenSheets Companion para capturar
automaticamente notificações de transações financeiras e
enviá-las para sua caixa de entrada.
</p>
</div>
<ApiTokensForm tokens={userApiTokens} />
</div>
</Card>
</TabsContent>
<TabsContent value="nome" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground mb-4">
Atualize como seu nome aparece no Opensheets. Esse nome pode
ser exibido em diferentes seções do app e em comunicações.
</p>
</div>
<UpdateNameForm currentName={userName} />
</div>
</Card>
</TabsContent>
<TabsContent value="senha" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground mb-4">
Defina uma nova senha para sua conta. Guarde-a em local
seguro.
</p>
</div>
<UpdatePasswordForm authProvider={authProvider} />
</div>
</Card>
</TabsContent>
<TabsContent value="email" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground mb-4">
Atualize o e-mail associado à sua conta. Você precisará
confirmar os links enviados para o novo e também para o e-mail
atual (quando aplicável) para concluir a alteração.
</p>
</div>
<UpdateEmailForm
currentEmail={userEmail}
authProvider={authProvider}
/>
</div>
</Card>
</TabsContent>
<TabsContent value="deletar" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1 text-destructive">
Deletar conta
</h2>
<p className="text-sm text-muted-foreground mb-4">
Ao prosseguir, sua conta e todos os dados associados serão
excluídos de forma irreversível.
</p>
</div>
<DeleteAccountForm />
</div>
</Card>
</TabsContent>
</Tabs>
</div>
);
<TabsContent value="deletar" className="mt-4">
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold mb-1 text-destructive">
Deletar conta
</h2>
<p className="text-sm text-muted-foreground mb-4">
Ao prosseguir, sua conta e todos os dados associados serão
excluídos de forma irreversível.
</p>
</div>
<DeleteAccountForm />
</div>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,59 +1,64 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { anotacoes } from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { uuidSchema } from "@/lib/schemas/common";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { uuidSchema } from "@/lib/schemas/common";
const taskSchema = z.object({
id: z.string(),
text: z.string().min(1, "O texto da tarefa não pode estar vazio."),
completed: z.boolean(),
id: z.string(),
text: z.string().min(1, "O texto da tarefa não pode estar vazio."),
completed: z.boolean(),
});
const noteBaseSchema = z.object({
title: z
.string({ message: "Informe o título da anotação." })
.trim()
.min(1, "Informe o título da anotação.")
.max(30, "O título deve ter no máximo 30 caracteres."),
description: z
.string({ message: "Informe o conteúdo da anotação." })
.trim()
.max(350, "O conteúdo deve ter no máximo 350 caracteres.")
.optional()
.default(""),
type: z.enum(["nota", "tarefa"], {
message: "O tipo deve ser 'nota' ou 'tarefa'.",
}),
tasks: z.array(taskSchema).optional().default([]),
}).refine(
(data) => {
// Se for nota, a descrição é obrigatória
if (data.type === "nota") {
return data.description.trim().length > 0;
}
// Se for tarefa, deve ter pelo menos uma tarefa
if (data.type === "tarefa") {
return data.tasks && data.tasks.length > 0;
}
return true;
},
{
message: "Notas precisam de descrição e Tarefas precisam de ao menos uma tarefa.",
}
);
const noteBaseSchema = z
.object({
title: z
.string({ message: "Informe o título da anotação." })
.trim()
.min(1, "Informe o título da anotação.")
.max(30, "O título deve ter no máximo 30 caracteres."),
description: z
.string({ message: "Informe o conteúdo da anotação." })
.trim()
.max(350, "O conteúdo deve ter no máximo 350 caracteres.")
.optional()
.default(""),
type: z.enum(["nota", "tarefa"], {
message: "O tipo deve ser 'nota' ou 'tarefa'.",
}),
tasks: z.array(taskSchema).optional().default([]),
})
.refine(
(data) => {
// Se for nota, a descrição é obrigatória
if (data.type === "nota") {
return data.description.trim().length > 0;
}
// Se for tarefa, deve ter pelo menos uma tarefa
if (data.type === "tarefa") {
return data.tasks && data.tasks.length > 0;
}
return true;
},
{
message:
"Notas precisam de descrição e Tarefas precisam de ao menos uma tarefa.",
},
);
const createNoteSchema = noteBaseSchema;
const updateNoteSchema = noteBaseSchema.and(z.object({
id: uuidSchema("Anotação"),
}));
const updateNoteSchema = noteBaseSchema.and(
z.object({
id: uuidSchema("Anotação"),
}),
);
const deleteNoteSchema = z.object({
id: uuidSchema("Anotação"),
id: uuidSchema("Anotação"),
});
type NoteCreateInput = z.infer<typeof createNoteSchema>;
@@ -61,126 +66,130 @@ type NoteUpdateInput = z.infer<typeof updateNoteSchema>;
type NoteDeleteInput = z.infer<typeof deleteNoteSchema>;
export async function createNoteAction(
input: NoteCreateInput
input: NoteCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createNoteSchema.parse(input);
try {
const user = await getUser();
const data = createNoteSchema.parse(input);
await db.insert(anotacoes).values({
title: data.title,
description: data.description,
type: data.type,
tasks: data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null,
userId: user.id,
});
await db.insert(anotacoes).values({
title: data.title,
description: data.description,
type: data.type,
tasks:
data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null,
userId: user.id,
});
revalidateForEntity("anotacoes");
revalidateForEntity("anotacoes");
return { success: true, message: "Anotação criada com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Anotação criada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateNoteAction(
input: NoteUpdateInput
input: NoteUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateNoteSchema.parse(input);
try {
const user = await getUser();
const data = updateNoteSchema.parse(input);
const [updated] = await db
.update(anotacoes)
.set({
title: data.title,
description: data.description,
type: data.type,
tasks: data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null,
})
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
const [updated] = await db
.update(anotacoes)
.set({
title: data.title,
description: data.description,
type: data.type,
tasks:
data.tasks && data.tasks.length > 0
? JSON.stringify(data.tasks)
: null,
})
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
if (!updated) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
if (!updated) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
revalidateForEntity("anotacoes");
revalidateForEntity("anotacoes");
return { success: true, message: "Anotação atualizada com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Anotação atualizada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteNoteAction(
input: NoteDeleteInput
input: NoteDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteNoteSchema.parse(input);
try {
const user = await getUser();
const data = deleteNoteSchema.parse(input);
const [deleted] = await db
.delete(anotacoes)
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
const [deleted] = await db
.delete(anotacoes)
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
if (!deleted) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
if (!deleted) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
revalidateForEntity("anotacoes");
revalidateForEntity("anotacoes");
return { success: true, message: "Anotação removida com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Anotação removida com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
const arquivarNoteSchema = z.object({
id: uuidSchema("Anotação"),
arquivada: z.boolean(),
id: uuidSchema("Anotação"),
arquivada: z.boolean(),
});
type NoteArquivarInput = z.infer<typeof arquivarNoteSchema>;
export async function arquivarAnotacaoAction(
input: NoteArquivarInput
input: NoteArquivarInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = arquivarNoteSchema.parse(input);
try {
const user = await getUser();
const data = arquivarNoteSchema.parse(input);
const [updated] = await db
.update(anotacoes)
.set({
arquivada: data.arquivada,
})
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
const [updated] = await db
.update(anotacoes)
.set({
arquivada: data.arquivada,
})
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
if (!updated) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
if (!updated) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
revalidateForEntity("anotacoes");
revalidateForEntity("anotacoes");
return {
success: true,
message: data.arquivada
? "Anotação arquivada com sucesso."
: "Anotação desarquivada com sucesso."
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
message: data.arquivada
? "Anotação arquivada com sucesso."
: "Anotação desarquivada com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -3,12 +3,12 @@ import { getUserId } from "@/lib/auth/server";
import { fetchArquivadasForUser } from "../data";
export default async function ArquivadasPage() {
const userId = await getUserId();
const notes = await fetchArquivadasForUser(userId);
const userId = await getUserId();
const notes = await fetchArquivadasForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<NotesPage notes={notes} isArquivadas={true} />
</main>
);
return (
<main className="flex flex-col items-start gap-6">
<NotesPage notes={notes} isArquivadas={true} />
</main>
);
}

View File

@@ -1,81 +1,89 @@
import { anotacoes, type Anotacao } from "@/db/schema";
import { db } from "@/lib/db";
import { and, eq } from "drizzle-orm";
import { type Anotacao, anotacoes } from "@/db/schema";
import { db } from "@/lib/db";
export type Task = {
id: string;
text: string;
completed: boolean;
id: string;
text: string;
completed: boolean;
};
export type NoteData = {
id: string;
title: string;
description: string;
type: "nota" | "tarefa";
tasks?: Task[];
arquivada: boolean;
createdAt: string;
id: string;
title: string;
description: string;
type: "nota" | "tarefa";
tasks?: Task[];
arquivada: boolean;
createdAt: string;
};
export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)],
});
const noteRows = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
orderBy: (
note: typeof anotacoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(note.createdAt)],
});
return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined;
return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined;
// Parse tasks if they exist
if (note.tasks) {
try {
tasks = JSON.parse(note.tasks);
} catch (error) {
console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined;
}
}
// Parse tasks if they exist
if (note.tasks) {
try {
tasks = JSON.parse(note.tasks);
} catch (error) {
console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined;
}
}
return {
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
};
});
return {
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
};
});
}
export async function fetchArquivadasForUser(userId: string): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, true)),
orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)],
});
export async function fetchArquivadasForUser(
userId: string,
): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, true)),
orderBy: (
note: typeof anotacoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(note.createdAt)],
});
return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined;
return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined;
// Parse tasks if they exist
if (note.tasks) {
try {
tasks = JSON.parse(note.tasks);
} catch (error) {
console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined;
}
}
// Parse tasks if they exist
if (note.tasks) {
try {
tasks = JSON.parse(note.tasks);
} catch (error) {
console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined;
}
}
return {
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
};
});
return {
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(),
};
});
}

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiTodoLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Anotações | Opensheets",
title: "Anotações | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiTodoLine />}
title="Notas"
subtitle="Gerencie suas anotações e mantenha o controle sobre suas ideias e tarefas."
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiTodoLine />}
title="Notas"
subtitle="Gerencie suas anotações e mantenha o controle sobre suas ideias e tarefas."
/>
{children}
</section>
);
}

View File

@@ -5,47 +5,44 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: Header com botão + Grid de cards de notas
*/
export default function AnotacoesLoading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Grid de cards de notas */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border p-4 space-y-3"
>
{/* Título */}
<Skeleton className="h-6 w-3/4 rounded-2xl bg-foreground/10" />
{/* Grid de cards de notas */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-4 space-y-3">
{/* Título */}
<Skeleton className="h-6 w-3/4 rounded-2xl bg-foreground/10" />
{/* Conteúdo (3-4 linhas) */}
<div className="space-y-2">
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-2/3 rounded-2xl bg-foreground/10" />
{i % 2 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
</div>
{/* Conteúdo (3-4 linhas) */}
<div className="space-y-2">
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-2/3 rounded-2xl bg-foreground/10" />
{i % 2 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
</div>
{/* Footer com data e ações */}
<div className="flex items-center justify-between pt-2 border-t">
<Skeleton className="h-3 w-24 rounded-2xl bg-foreground/10" />
<div className="flex gap-1">
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
))}
</div>
</div>
</main>
);
{/* Footer com data e ações */}
<div className="flex items-center justify-between pt-2 border-t">
<Skeleton className="h-3 w-24 rounded-2xl bg-foreground/10" />
<div className="flex gap-1">
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -3,12 +3,12 @@ import { getUserId } from "@/lib/auth/server";
import { fetchNotesForUser } from "./data";
export default async function Page() {
const userId = await getUserId();
const notes = await fetchNotesForUser(userId);
const userId = await getUserId();
const notes = await fetchNotesForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<NotesPage notes={notes} />
</main>
);
return (
<main className="flex flex-col items-start gap-6">
<NotesPage notes={notes} />
</main>
);
}

View File

@@ -1,19 +1,18 @@
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import type {
CalendarData,
CalendarEvent,
} from "@/components/calendario/types";
import { cartoes, lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
import {
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
mapLancamentosData,
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
mapLancamentosData,
} from "@/lib/lancamentos/page-helpers";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
import type {
CalendarData,
CalendarEvent,
} from "@/components/calendario/types";
const PAYMENT_METHOD_BOLETO = "Boleto";
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
@@ -21,200 +20,199 @@ const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
const toDateKey = (date: Date) => date.toISOString().slice(0, 10);
const parsePeriod = (period: string) => {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
if (Number.isNaN(year) || Number.isNaN(month) || month < 1 || month > 12) {
throw new Error(`Período inválido: ${period}`);
}
if (Number.isNaN(year) || Number.isNaN(month) || month < 1 || month > 12) {
throw new Error(`Período inválido: ${period}`);
}
return { year, monthIndex: month - 1 };
return { year, monthIndex: month - 1 };
};
const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
if (day < 1) return 1;
if (day > lastDay) return lastDay;
return day;
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
if (day < 1) return 1;
if (day > lastDay) return lastDay;
return day;
};
const isWithinRange = (value: string | null, start: string, end: string) => {
if (!value) return false;
return value >= start && value <= end;
if (!value) return false;
return value >= start && value <= end;
};
type FetchCalendarDataParams = {
userId: string;
period: string;
userId: string;
period: string;
};
export const fetchCalendarData = async ({
userId,
period,
userId,
period,
}: FetchCalendarDataParams): Promise<CalendarData> => {
const { year, monthIndex } = parsePeriod(period);
const rangeStart = new Date(Date.UTC(year, monthIndex, 1));
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0));
const rangeStartKey = toDateKey(rangeStart);
const rangeEndKey = toDateKey(rangeEnd);
const { year, monthIndex } = parsePeriod(period);
const rangeStart = new Date(Date.UTC(year, monthIndex, 1));
const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0));
const rangeStartKey = toDateKey(rangeStart);
const rangeEndKey = toDateKey(rangeEnd);
const [lancamentoRows, cardRows, filterSources] =
await Promise.all([
db.query.lancamentos.findMany({
where: and(
eq(lancamentos.userId, userId),
ne(lancamentos.transactionType, TRANSACTION_TYPE_TRANSFERENCIA),
or(
// Lançamentos cuja data de compra esteja no período do calendário
and(
gte(lancamentos.purchaseDate, rangeStart),
lte(lancamentos.purchaseDate, rangeEnd)
),
// Boletos cuja data de vencimento esteja no período do calendário
and(
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
gte(lancamentos.dueDate, rangeStart),
lte(lancamentos.dueDate, rangeEnd)
),
// Lançamentos de cartão do período (para calcular totais de vencimento)
and(
eq(lancamentos.period, period),
ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO)
)
)
),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
}),
db.query.cartoes.findMany({
where: eq(cartoes.userId, userId),
}),
fetchLancamentoFilterSources(userId),
]);
const [lancamentoRows, cardRows, filterSources] = await Promise.all([
db.query.lancamentos.findMany({
where: and(
eq(lancamentos.userId, userId),
ne(lancamentos.transactionType, TRANSACTION_TYPE_TRANSFERENCIA),
or(
// Lançamentos cuja data de compra esteja no período do calendário
and(
gte(lancamentos.purchaseDate, rangeStart),
lte(lancamentos.purchaseDate, rangeEnd),
),
// Boletos cuja data de vencimento esteja no período do calendário
and(
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
gte(lancamentos.dueDate, rangeStart),
lte(lancamentos.dueDate, rangeEnd),
),
// Lançamentos de cartão do período (para calcular totais de vencimento)
and(
eq(lancamentos.period, period),
ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
),
),
),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
}),
db.query.cartoes.findMany({
where: eq(cartoes.userId, userId),
}),
fetchLancamentoFilterSources(userId),
]);
const lancamentosData = mapLancamentosData(lancamentoRows);
const events: CalendarEvent[] = [];
const lancamentosData = mapLancamentosData(lancamentoRows);
const events: CalendarEvent[] = [];
const cardTotals = new Map<string, number>();
for (const item of lancamentosData) {
if (
!item.cartaoId ||
item.period !== period ||
item.pagadorRole !== PAGADOR_ROLE_ADMIN
) {
continue;
}
const amount = Math.abs(item.amount ?? 0);
cardTotals.set(
item.cartaoId,
(cardTotals.get(item.cartaoId) ?? 0) + amount
);
}
const cardTotals = new Map<string, number>();
for (const item of lancamentosData) {
if (
!item.cartaoId ||
item.period !== period ||
item.pagadorRole !== PAGADOR_ROLE_ADMIN
) {
continue;
}
const amount = Math.abs(item.amount ?? 0);
cardTotals.set(
item.cartaoId,
(cardTotals.get(item.cartaoId) ?? 0) + amount,
);
}
for (const item of lancamentosData) {
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
const isAdminPagador = item.pagadorRole === PAGADOR_ROLE_ADMIN;
for (const item of lancamentosData) {
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
const isAdminPagador = item.pagadorRole === PAGADOR_ROLE_ADMIN;
// Para boletos, exibir apenas na data de vencimento e apenas se for pagador admin
if (isBoleto) {
if (
isAdminPagador &&
item.dueDate &&
isWithinRange(item.dueDate, rangeStartKey, rangeEndKey)
) {
events.push({
id: `${item.id}:boleto`,
type: "boleto",
date: item.dueDate,
lancamento: item,
});
}
} else {
// Para outros tipos de lançamento, exibir na data de compra
if (!isAdminPagador) {
continue;
}
const purchaseDateKey = item.purchaseDate.slice(0, 10);
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
events.push({
id: item.id,
type: "lancamento",
date: purchaseDateKey,
lancamento: item,
});
}
}
}
// Para boletos, exibir apenas na data de vencimento e apenas se for pagador admin
if (isBoleto) {
if (
isAdminPagador &&
item.dueDate &&
isWithinRange(item.dueDate, rangeStartKey, rangeEndKey)
) {
events.push({
id: `${item.id}:boleto`,
type: "boleto",
date: item.dueDate,
lancamento: item,
});
}
} else {
// Para outros tipos de lançamento, exibir na data de compra
if (!isAdminPagador) {
continue;
}
const purchaseDateKey = item.purchaseDate.slice(0, 10);
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
events.push({
id: item.id,
type: "lancamento",
date: purchaseDateKey,
lancamento: item,
});
}
}
}
// Exibir vencimentos apenas de cartões com lançamentos do pagador admin
for (const card of cardRows) {
if (!cardTotals.has(card.id)) {
continue;
}
// Exibir vencimentos apenas de cartões com lançamentos do pagador admin
for (const card of cardRows) {
if (!cardTotals.has(card.id)) {
continue;
}
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
if (Number.isNaN(dueDayNumber)) {
continue;
}
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
if (Number.isNaN(dueDayNumber)) {
continue;
}
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
const dueDateKey = toDateKey(
new Date(Date.UTC(year, monthIndex, normalizedDay))
);
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
const dueDateKey = toDateKey(
new Date(Date.UTC(year, monthIndex, normalizedDay)),
);
events.push({
id: `${card.id}:cartao`,
type: "cartao",
date: dueDateKey,
card: {
id: card.id,
name: card.name,
dueDay: card.dueDay,
closingDay: card.closingDay,
brand: card.brand ?? null,
status: card.status,
logo: card.logo ?? null,
totalDue: cardTotals.get(card.id) ?? null,
},
});
}
events.push({
id: `${card.id}:cartao`,
type: "cartao",
date: dueDateKey,
card: {
id: card.id,
name: card.name,
dueDay: card.dueDay,
closingDay: card.closingDay,
brand: card.brand ?? null,
status: card.status,
logo: card.logo ?? null,
totalDue: cardTotals.get(card.id) ?? null,
},
});
}
const typePriority: Record<CalendarEvent["type"], number> = {
lancamento: 0,
boleto: 1,
cartao: 2,
};
const typePriority: Record<CalendarEvent["type"], number> = {
lancamento: 0,
boleto: 1,
cartao: 2,
};
events.sort((a, b) => {
if (a.date === b.date) {
return typePriority[a.type] - typePriority[b.type];
}
return a.date.localeCompare(b.date);
});
events.sort((a, b) => {
if (a.date === b.date) {
return typePriority[a.type] - typePriority[b.type];
}
return a.date.localeCompare(b.date);
});
const sluggedFilters = buildSluggedFilters(filterSources);
const optionSets = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
const sluggedFilters = buildSluggedFilters(filterSources);
const optionSets = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
const estabelecimentos = await getRecentEstablishmentsAction();
const estabelecimentos = await getRecentEstablishmentsAction();
return {
events,
formOptions: {
pagadorOptions: optionSets.pagadorOptions,
splitPagadorOptions: optionSets.splitPagadorOptions,
defaultPagadorId: optionSets.defaultPagadorId,
contaOptions: optionSets.contaOptions,
cartaoOptions: optionSets.cartaoOptions,
categoriaOptions: optionSets.categoriaOptions,
estabelecimentos,
},
};
return {
events,
formOptions: {
pagadorOptions: optionSets.pagadorOptions,
splitPagadorOptions: optionSets.splitPagadorOptions,
defaultPagadorId: optionSets.defaultPagadorId,
contaOptions: optionSets.contaOptions,
cartaoOptions: optionSets.cartaoOptions,
categoriaOptions: optionSets.categoriaOptions,
estabelecimentos,
},
};
};

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiCalendarEventLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Calendário | Opensheets",
title: "Calendário | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiCalendarEventLine />}
title="Calendário"
subtitle="Visualize lançamentos, vencimentos de cartões e boletos em um só lugar. Clique em uma data para detalhar ou criar um novo lançamento."
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiCalendarEventLine />}
title="Calendário"
subtitle="Visualize lançamentos, vencimentos de cartões e boletos em um só lugar. Clique em uma data para detalhar ou criar um novo lançamento."
/>
{children}
</section>
);
}

View File

@@ -5,55 +5,55 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: MonthPicker + Grade mensal 7x5/6 com dias e eventos
*/
export default function CalendarioLoading() {
return (
<main className="flex flex-col gap-3">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
return (
<main className="flex flex-col gap-3">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Calendar Container */}
<div className="rounded-2xl border p-4 space-y-4">
{/* Cabeçalho com dias da semana */}
<div className="grid grid-cols-7 gap-2 mb-4">
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
<div key={day} className="text-center">
<Skeleton className="h-4 w-12 mx-auto rounded-2xl bg-foreground/10" />
</div>
))}
</div>
{/* Calendar Container */}
<div className="rounded-2xl border p-4 space-y-4">
{/* Cabeçalho com dias da semana */}
<div className="grid grid-cols-7 gap-2 mb-4">
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
<div key={day} className="text-center">
<Skeleton className="h-4 w-12 mx-auto rounded-2xl bg-foreground/10" />
</div>
))}
</div>
{/* Grade de dias (6 semanas) */}
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: 42 }).map((_, i) => (
<div
key={i}
className="min-h-[100px] rounded-2xl border p-2 space-y-2"
>
{/* Número do dia */}
<Skeleton className="h-5 w-6 rounded-2xl bg-foreground/10" />
{/* Grade de dias (6 semanas) */}
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: 42 }).map((_, i) => (
<div
key={i}
className="min-h-[100px] rounded-2xl border p-2 space-y-2"
>
{/* Número do dia */}
<Skeleton className="h-5 w-6 rounded-2xl bg-foreground/10" />
{/* Indicadores de eventos (aleatório entre 0-3) */}
{i % 3 === 0 && (
<div className="space-y-1">
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
{i % 5 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
</div>
)}
</div>
))}
</div>
{/* Indicadores de eventos (aleatório entre 0-3) */}
{i % 3 === 0 && (
<div className="space-y-1">
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
{i % 5 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
</div>
)}
</div>
))}
</div>
{/* Legenda */}
<div className="flex flex-wrap items-center gap-4 pt-4 border-t">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="size-3 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
</main>
);
{/* Legenda */}
<div className="flex flex-wrap items-center gap-4 pt-4 border-t">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="size-3 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -1,47 +1,46 @@
import { MonthlyCalendar } from "@/components/calendario/monthly-calendar";
import type { CalendarPeriod } from "@/components/calendario/types";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { getUserId } from "@/lib/auth/server";
import {
getSingleParam,
type ResolvedSearchParams,
getSingleParam,
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { parsePeriodParam } from "@/lib/utils/period";
import { MonthlyCalendar } from "@/components/calendario/monthly-calendar";
import { fetchCalendarData } from "./data";
import type { CalendarPeriod } from "@/components/calendario/types";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
searchParams?: PageSearchParams;
searchParams?: PageSearchParams;
};
export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId();
const resolvedParams = searchParams ? await searchParams : undefined;
const userId = await getUserId();
const resolvedParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedParams, "periodo");
const { period, monthName, year } = parsePeriodParam(periodoParam);
const periodoParam = getSingleParam(resolvedParams, "periodo");
const { period, monthName, year } = parsePeriodParam(periodoParam);
const calendarData = await fetchCalendarData({
userId,
period,
});
const calendarData = await fetchCalendarData({
userId,
period,
});
const calendarPeriod: CalendarPeriod = {
period,
monthName,
year,
};
const calendarPeriod: CalendarPeriod = {
period,
monthName,
year,
};
return (
<main className="flex flex-col gap-3">
<MonthNavigation />
<MonthlyCalendar
period={calendarPeriod}
events={calendarData.events}
formOptions={calendarData.formOptions}
/>
</main>
);
return (
<main className="flex flex-col gap-3">
<MonthNavigation />
<MonthlyCalendar
period={calendarPeriod}
events={calendarData.events}
formOptions={calendarData.formOptions}
/>
</main>
);
}

View File

@@ -1,119 +1,117 @@
"use server";
import {
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
} from "@/db/schema";
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
PERIOD_FORMAT_REGEX,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { parseLocalDateString } from "@/lib/utils/date";
import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
} from "@/db/schema";
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
type InvoicePaymentStatus,
PERIOD_FORMAT_REGEX,
} from "@/lib/faturas";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { parseLocalDateString } from "@/lib/utils/date";
const updateInvoicePaymentStatusSchema = z.object({
cartaoId: z
.string({ message: "Cartão inválido." })
.uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
status: z.enum(
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]]
),
paymentDate: z.string().optional(),
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
status: z.enum(
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
),
paymentDate: z.string().optional(),
});
type UpdateInvoicePaymentStatusInput = z.infer<
typeof updateInvoicePaymentStatusSchema
typeof updateInvoicePaymentStatusSchema
>;
type ActionResult =
| { success: true; message: string }
| { success: false; error: string };
| { success: true; message: string }
| { success: false; error: string };
const successMessageByStatus: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.",
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
[INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.",
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
};
const formatDecimal = (value: number) =>
(Math.round(value * 100) / 100).toFixed(2);
(Math.round(value * 100) / 100).toFixed(2);
export async function updateInvoicePaymentStatusAction(
input: UpdateInvoicePaymentStatusInput
input: UpdateInvoicePaymentStatusInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateInvoicePaymentStatusSchema.parse(input);
try {
const user = await getUser();
const data = updateInvoicePaymentStatusSchema.parse(input);
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({
columns: { id: true, contaId: true, name: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
});
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({
columns: { id: true, contaId: true, name: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
});
if (!card) {
throw new Error("Cartão não encontrado.");
}
if (!card) {
throw new Error("Cartão não encontrado.");
}
const existingInvoice = await tx.query.faturas.findFirst({
columns: {
id: true,
},
where: and(
eq(faturas.cartaoId, data.cartaoId),
eq(faturas.userId, user.id),
eq(faturas.period, data.period)
),
});
const existingInvoice = await tx.query.faturas.findFirst({
columns: {
id: true,
},
where: and(
eq(faturas.cartaoId, data.cartaoId),
eq(faturas.userId, user.id),
eq(faturas.period, data.period),
),
});
if (existingInvoice) {
await tx
.update(faturas)
.set({
paymentStatus: data.status,
})
.where(eq(faturas.id, existingInvoice.id));
} else {
await tx.insert(faturas).values({
cartaoId: data.cartaoId,
period: data.period,
paymentStatus: data.status,
userId: user.id,
});
}
if (existingInvoice) {
await tx
.update(faturas)
.set({
paymentStatus: data.status,
})
.where(eq(faturas.id, existingInvoice.id));
} else {
await tx.insert(faturas).values({
cartaoId: data.cartaoId,
period: data.period,
paymentStatus: data.status,
userId: user.id,
});
}
const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID;
const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID;
await tx
.update(lancamentos)
.set({ isSettled: shouldMarkAsPaid })
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period)
)
);
await tx
.update(lancamentos)
.set({ isSettled: shouldMarkAsPaid })
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period),
),
);
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
if (shouldMarkAsPaid) {
const [adminShareRow] = await tx
.select({
total: sql<number>`
if (shouldMarkAsPaid) {
const [adminShareRow] = await tx
.select({
total: sql<number>`
coalesce(
sum(
case
@@ -124,177 +122,175 @@ export async function updateInvoicePaymentStatusAction(
0
)
`,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
)
);
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
);
const adminShare = Math.abs(Number(adminShareRow?.total ?? 0));
const adminShare = Math.abs(Number(adminShareRow?.total ?? 0));
if (adminShare > 0 && card.contaId) {
const adminPagador = await tx.query.pagadores.findFirst({
columns: { id: true },
where: and(
eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
),
});
if (adminShare > 0 && card.contaId) {
const adminPagador = await tx.query.pagadores.findFirst({
columns: { id: true },
where: and(
eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
});
const paymentCategory = await tx.query.categorias.findFirst({
columns: { id: true },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, "Pagamentos")
),
});
const paymentCategory = await tx.query.categorias.findFirst({
columns: { id: true },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, "Pagamentos"),
),
});
if (adminPagador) {
// Usar a data customizada ou a data atual como data de pagamento
const invoiceDate = data.paymentDate
? parseLocalDateString(data.paymentDate)
: new Date();
if (adminPagador) {
// Usar a data customizada ou a data atual como data de pagamento
const invoiceDate = data.paymentDate
? parseLocalDateString(data.paymentDate)
: new Date();
const amount = `-${formatDecimal(adminShare)}`;
const payload = {
condition: "À vista",
name: `Pagamento fatura - ${card.name}`,
paymentMethod: "Pix",
note: invoiceNote,
amount,
purchaseDate: invoiceDate,
transactionType: "Despesa" as const,
period: data.period,
isSettled: true,
userId: user.id,
contaId: card.contaId,
categoriaId: paymentCategory?.id ?? null,
pagadorId: adminPagador.id,
};
const amount = `-${formatDecimal(adminShare)}`;
const payload = {
condition: "À vista",
name: `Pagamento fatura - ${card.name}`,
paymentMethod: "Pix",
note: invoiceNote,
amount,
purchaseDate: invoiceDate,
transactionType: "Despesa" as const,
period: data.period,
isSettled: true,
userId: user.id,
contaId: card.contaId,
categoriaId: paymentCategory?.id ?? null,
pagadorId: adminPagador.id,
};
const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true },
where: and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote)
),
});
const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true },
where: and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote),
),
});
if (existingPayment) {
await tx
.update(lancamentos)
.set(payload)
.where(eq(lancamentos.id, existingPayment.id));
} else {
await tx.insert(lancamentos).values(payload);
}
}
}
} else {
await tx
.delete(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote)
)
);
}
});
if (existingPayment) {
await tx
.update(lancamentos)
.set(payload)
.where(eq(lancamentos.id, existingPayment.id));
} else {
await tx.insert(lancamentos).values(payload);
}
}
}
} else {
await tx
.delete(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote),
),
);
}
});
revalidatePath(`/cartoes/${data.cartaoId}/fatura`);
revalidatePath("/cartoes");
revalidatePath("/contas");
revalidatePath(`/cartoes/${data.cartaoId}/fatura`);
revalidatePath("/cartoes");
revalidatePath("/contas");
return { success: true, message: successMessageByStatus[data.status] };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return { success: true, message: successMessageByStatus[data.status] };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}
const updatePaymentDateSchema = z.object({
cartaoId: z
.string({ message: "Cartão inválido." })
.uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
paymentDate: z.string({ message: "Data de pagamento inválida." }),
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
paymentDate: z.string({ message: "Data de pagamento inválida." }),
});
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;
export async function updatePaymentDateAction(
input: UpdatePaymentDateInput
input: UpdatePaymentDateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updatePaymentDateSchema.parse(input);
try {
const user = await getUser();
const data = updatePaymentDateSchema.parse(input);
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({
columns: { id: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
});
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({
columns: { id: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
});
if (!card) {
throw new Error("Cartão não encontrado.");
}
if (!card) {
throw new Error("Cartão não encontrado.");
}
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true },
where: and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote)
),
});
const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true },
where: and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote),
),
});
if (!existingPayment) {
throw new Error("Pagamento não encontrado.");
}
if (!existingPayment) {
throw new Error("Pagamento não encontrado.");
}
await tx
.update(lancamentos)
.set({
purchaseDate: parseLocalDateString(data.paymentDate),
})
.where(eq(lancamentos.id, existingPayment.id));
});
await tx
.update(lancamentos)
.set({
purchaseDate: parseLocalDateString(data.paymentDate),
})
.where(eq(lancamentos.id, existingPayment.id));
});
revalidatePath(`/cartoes/${data.cartaoId}/fatura`);
revalidatePath("/cartoes");
revalidatePath("/contas");
revalidatePath(`/cartoes/${data.cartaoId}/fatura`);
revalidatePath("/cartoes");
revalidatePath("/contas");
return { success: true, message: "Data de pagamento atualizada." };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return { success: true, message: "Data de pagamento atualizada." };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}

View File

@@ -1,104 +1,117 @@
import { and, desc, eq, type SQL, sum } from "drizzle-orm";
import { cartoes, faturas, lancamentos } from "@/db/schema";
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,
type InvoicePaymentStatus,
INVOICE_PAYMENT_STATUS,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { and, eq, sum } from "drizzle-orm";
const toNumber = (value: string | number | null | undefined) => {
if (typeof value === "number") {
return value;
}
if (value === null || value === undefined) {
return 0;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed;
if (typeof value === "number") {
return value;
}
if (value === null || value === undefined) {
return 0;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed;
};
export async function fetchCardData(userId: string, cartaoId: string) {
const card = await db.query.cartoes.findFirst({
columns: {
id: true,
name: true,
brand: true,
closingDay: true,
dueDay: true,
logo: true,
limit: true,
status: true,
note: true,
contaId: true,
},
where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)),
});
const card = await db.query.cartoes.findFirst({
columns: {
id: true,
name: true,
brand: true,
closingDay: true,
dueDay: true,
logo: true,
limit: true,
status: true,
note: true,
contaId: true,
},
where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)),
});
return card;
return card;
}
export async function fetchInvoiceData(
userId: string,
cartaoId: string,
selectedPeriod: string
userId: string,
cartaoId: string,
selectedPeriod: string,
): Promise<{
totalAmount: number;
invoiceStatus: InvoicePaymentStatus;
paymentDate: Date | null;
totalAmount: number;
invoiceStatus: InvoicePaymentStatus;
paymentDate: Date | null;
}> {
const [invoiceRow, totalRow] = await Promise.all([
db.query.faturas.findFirst({
columns: {
id: true,
period: true,
paymentStatus: true,
},
where: and(
eq(faturas.cartaoId, cartaoId),
eq(faturas.userId, userId),
eq(faturas.period, selectedPeriod)
),
}),
db
.select({ totalAmount: sum(lancamentos.amount) })
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cartaoId),
eq(lancamentos.period, selectedPeriod)
)
),
]);
const [invoiceRow, totalRow] = await Promise.all([
db.query.faturas.findFirst({
columns: {
id: true,
period: true,
paymentStatus: true,
},
where: and(
eq(faturas.cartaoId, cartaoId),
eq(faturas.userId, userId),
eq(faturas.period, selectedPeriod),
),
}),
db
.select({ totalAmount: sum(lancamentos.amount) })
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cartaoId),
eq(lancamentos.period, selectedPeriod),
),
),
]);
const totalAmount = toNumber(totalRow[0]?.totalAmount);
const isInvoiceStatus = (
value: string | null | undefined
): value is InvoicePaymentStatus =>
!!value && ["pendente", "pago"].includes(value);
const totalAmount = toNumber(totalRow[0]?.totalAmount);
const isInvoiceStatus = (
value: string | null | undefined,
): value is InvoicePaymentStatus =>
!!value && ["pendente", "pago"].includes(value);
const invoiceStatus = isInvoiceStatus(invoiceRow?.paymentStatus)
? invoiceRow?.paymentStatus
: INVOICE_PAYMENT_STATUS.PENDING;
const invoiceStatus = isInvoiceStatus(invoiceRow?.paymentStatus)
? invoiceRow?.paymentStatus
: INVOICE_PAYMENT_STATUS.PENDING;
// Buscar data do pagamento se a fatura estiver paga
let paymentDate: Date | null = null;
if (invoiceStatus === INVOICE_PAYMENT_STATUS.PAID) {
const invoiceNote = buildInvoicePaymentNote(cartaoId, selectedPeriod);
const paymentLancamento = await db.query.lancamentos.findFirst({
columns: {
purchaseDate: true,
},
where: and(
eq(lancamentos.userId, userId),
eq(lancamentos.note, invoiceNote)
),
});
paymentDate = paymentLancamento?.purchaseDate
? new Date(paymentLancamento.purchaseDate)
: null;
}
// Buscar data do pagamento se a fatura estiver paga
let paymentDate: Date | null = null;
if (invoiceStatus === INVOICE_PAYMENT_STATUS.PAID) {
const invoiceNote = buildInvoicePaymentNote(cartaoId, selectedPeriod);
const paymentLancamento = await db.query.lancamentos.findFirst({
columns: {
purchaseDate: true,
},
where: and(
eq(lancamentos.userId, userId),
eq(lancamentos.note, invoiceNote),
),
});
paymentDate = paymentLancamento?.purchaseDate
? new Date(paymentLancamento.purchaseDate)
: null;
}
return { totalAmount, invoiceStatus, paymentDate };
return { totalAmount, invoiceStatus, paymentDate };
}
export async function fetchCardLancamentos(filters: SQL[]) {
return db.query.lancamentos.findMany({
where: and(...filters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
}

View File

@@ -1,7 +1,7 @@
import {
FilterSkeleton,
InvoiceSummaryCardSkeleton,
TransactionsTableSkeleton,
FilterSkeleton,
InvoiceSummaryCardSkeleton,
TransactionsTableSkeleton,
} from "@/components/skeletons";
import { Skeleton } from "@/components/ui/skeleton";
@@ -10,32 +10,32 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: MonthPicker + InvoiceSummaryCard + Filtros + Tabela de lançamentos
*/
export default function FaturaLoading() {
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Invoice Summary Card */}
<section className="flex flex-col gap-4">
<InvoiceSummaryCardSkeleton />
</section>
{/* Invoice Summary Card */}
<section className="flex flex-col gap-4">
<InvoiceSummaryCardSkeleton />
</section>
{/* Seção de lançamentos */}
<section className="flex flex-col gap-4">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Seção de lançamentos */}
<section className="flex flex-col gap-4">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Filtros */}
<FilterSkeleton />
{/* Filtros */}
<FilterSkeleton />
{/* Tabela */}
<TransactionsTableSkeleton />
</div>
</section>
</main>
);
{/* Tabela */}
<TransactionsTableSkeleton />
</div>
</section>
</main>
);
}

View File

@@ -1,3 +1,5 @@
import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { CardDialog } from "@/components/cartoes/card-dialog";
import type { Card } from "@/components/cartoes/types";
@@ -5,204 +7,187 @@ import { InvoiceSummaryCard } from "@/components/faturas/invoice-summary-card";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { Button } from "@/components/ui/button";
import { lancamentos, type Conta } from "@/db/schema";
import { db } from "@/lib/db";
import type { Conta } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { loadLogoOptions } from "@/lib/logo/options";
import { parsePeriodParam } from "@/lib/utils/period";
import { RiPencilLine } from "@remixicon/react";
import { and, desc } from "drizzle-orm";
import { notFound } from "next/navigation";
import { fetchCardData, fetchInvoiceData } from "./data";
import { fetchCardData, fetchCardLancamentos, fetchInvoiceData } from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
params: Promise<{ cartaoId: string }>;
searchParams?: PageSearchParams;
params: Promise<{ cartaoId: string }>;
searchParams?: PageSearchParams;
};
export default async function Page({ params, searchParams }: PageProps) {
const { cartaoId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const { cartaoId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName,
year,
} = parsePeriodParam(periodoParamRaw);
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName,
year,
} = parsePeriodParam(periodoParamRaw);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const card = await fetchCardData(userId, cartaoId);
const card = await fetchCardData(userId, cartaoId);
if (!card) {
notFound();
}
if (!card) {
notFound();
}
const [
filterSources,
logoOptions,
invoiceData,
estabelecimentos,
] = await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchInvoiceData(userId, cartaoId, selectedPeriod),
getRecentEstablishmentsAction(),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const [filterSources, logoOptions, invoiceData, estabelecimentos] =
await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchInvoiceData(userId, cartaoId, selectedPeriod),
getRecentEstablishmentsAction(),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
cardId: card.id,
});
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
cardId: card.id,
});
const lancamentoRows = await db.query.lancamentos.findMany({
where: and(...filters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
const lancamentoRows = await fetchCardLancamentos(filters);
const lancamentosData = mapLancamentosData(lancamentoRows);
const lancamentosData = mapLancamentosData(lancamentoRows);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
limitCartaoId: card.id,
});
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
limitCartaoId: card.id,
});
const accountOptions = filterSources.contaRows.map((conta: Conta) => ({
id: conta.id,
name: conta.name ?? "Conta",
}));
const accountOptions = filterSources.contaRows.map((conta: Conta) => ({
id: conta.id,
name: conta.name ?? "Conta",
}));
const contaName =
filterSources.contaRows.find((conta: Conta) => conta.id === card.contaId)
?.name ?? "Conta";
const contaName =
filterSources.contaRows.find((conta: Conta) => conta.id === card.contaId)
?.name ?? "Conta";
const cardDialogData: Card = {
id: card.id,
name: card.name,
brand: card.brand ?? "",
status: card.status ?? "",
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note ?? null,
logo: card.logo,
limit:
card.limit !== null && card.limit !== undefined
? Number(card.limit)
: null,
contaId: card.contaId,
contaName,
limitInUse: null,
limitAvailable: null,
};
const cardDialogData: Card = {
id: card.id,
name: card.name,
brand: card.brand ?? "",
status: card.status ?? "",
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note ?? null,
logo: card.logo,
limit:
card.limit !== null && card.limit !== undefined
? Number(card.limit)
: null,
contaId: card.contaId,
contaName,
limitInUse: null,
limitAvailable: null,
};
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
const limitAmount =
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
const limitAmount =
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
1
)} de ${year}`;
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
1,
)} de ${year}`;
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<section className="flex flex-col gap-4">
<InvoiceSummaryCard
cartaoId={card.id}
period={selectedPeriod}
cardName={card.name}
cardBrand={card.brand ?? null}
cardStatus={card.status ?? null}
closingDay={card.closingDay}
dueDay={card.dueDay}
periodLabel={periodLabel}
totalAmount={totalAmount}
limitAmount={limitAmount}
invoiceStatus={invoiceStatus}
paymentDate={paymentDate}
logo={card.logo}
actions={
<CardDialog
mode="update"
card={cardDialogData}
logoOptions={logoOptions}
accounts={accountOptions}
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Editar cartão"
>
<RiPencilLine className="size-4" />
</Button>
}
/>
}
/>
</section>
<section className="flex flex-col gap-4">
<InvoiceSummaryCard
cartaoId={card.id}
period={selectedPeriod}
cardName={card.name}
cardBrand={card.brand ?? null}
cardStatus={card.status ?? null}
closingDay={card.closingDay}
dueDay={card.dueDay}
periodLabel={periodLabel}
totalAmount={totalAmount}
limitAmount={limitAmount}
invoiceStatus={invoiceStatus}
paymentDate={paymentDate}
logo={card.logo}
actions={
<CardDialog
mode="update"
card={cardDialogData}
logoOptions={logoOptions}
accounts={accountOptions}
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Editar cartão"
>
<RiPencilLine className="size-4" />
</Button>
}
/>
}
/>
</section>
<section className="flex flex-col gap-4">
<LancamentosSection
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate
defaultCartaoId={card.id}
defaultPaymentMethod="Cartão de crédito"
lockCartaoSelection
lockPaymentMethod
/>
</section>
</main>
);
<section className="flex flex-col gap-4">
<LancamentosSection
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate
defaultCartaoId={card.id}
defaultPaymentMethod="Cartão de crédito"
lockCartaoSelection
lockPaymentMethod
/>
</section>
</main>
);
}

View File

@@ -1,51 +1,54 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { cartoes, contas } from "@/db/schema";
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
import { revalidateForEntity } from "@/lib/actions/helpers";
import {
dayOfMonthSchema,
noteSchema,
optionalDecimalSchema,
uuidSchema,
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
dayOfMonthSchema,
noteSchema,
optionalDecimalSchema,
uuidSchema,
} from "@/lib/schemas/common";
import { formatDecimalForDb } from "@/lib/utils/currency";
import { normalizeFilePath } from "@/lib/utils/string";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
const cardBaseSchema = z.object({
name: z
.string({ message: "Informe o nome do cartão." })
.trim()
.min(1, "Informe o nome do cartão."),
brand: z
.string({ message: "Informe a bandeira." })
.trim()
.min(1, "Informe a bandeira."),
status: z
.string({ message: "Informe o status do cartão." })
.trim()
.min(1, "Informe o status do cartão."),
closingDay: dayOfMonthSchema,
dueDay: dayOfMonthSchema,
note: noteSchema,
limit: optionalDecimalSchema,
logo: z
.string({ message: "Selecione um logo." })
.trim()
.min(1, "Selecione um logo."),
contaId: uuidSchema("Conta"),
name: z
.string({ message: "Informe o nome do cartão." })
.trim()
.min(1, "Informe o nome do cartão."),
brand: z
.string({ message: "Informe a bandeira." })
.trim()
.min(1, "Informe a bandeira."),
status: z
.string({ message: "Informe o status do cartão." })
.trim()
.min(1, "Informe o status do cartão."),
closingDay: dayOfMonthSchema,
dueDay: dayOfMonthSchema,
note: noteSchema,
limit: optionalDecimalSchema,
logo: z
.string({ message: "Selecione um logo." })
.trim()
.min(1, "Selecione um logo."),
contaId: uuidSchema("Conta"),
});
const createCardSchema = cardBaseSchema;
const updateCardSchema = cardBaseSchema.extend({
id: uuidSchema("Cartão"),
id: uuidSchema("Cartão"),
});
const deleteCardSchema = z.object({
id: uuidSchema("Cartão"),
id: uuidSchema("Cartão"),
});
type CardCreateInput = z.infer<typeof createCardSchema>;
@@ -53,113 +56,113 @@ type CardUpdateInput = z.infer<typeof updateCardSchema>;
type CardDeleteInput = z.infer<typeof deleteCardSchema>;
async function assertAccountOwnership(userId: string, contaId: string) {
const account = await db.query.contas.findFirst({
columns: { id: true },
where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
});
const account = await db.query.contas.findFirst({
columns: { id: true },
where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
});
if (!account) {
throw new Error("Conta vinculada não encontrada.");
}
if (!account) {
throw new Error("Conta vinculada não encontrada.");
}
}
export async function createCardAction(
input: CardCreateInput
input: CardCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createCardSchema.parse(input);
try {
const user = await getUser();
const data = createCardSchema.parse(input);
await assertAccountOwnership(user.id, data.contaId);
await assertAccountOwnership(user.id, data.contaId);
const logoFile = normalizeFilePath(data.logo);
const logoFile = normalizeFilePath(data.logo);
await db.insert(cartoes).values({
name: data.name,
brand: data.brand,
status: data.status,
closingDay: data.closingDay,
dueDay: data.dueDay,
note: data.note ?? null,
limit: formatDecimalForDb(data.limit),
logo: logoFile,
contaId: data.contaId,
userId: user.id,
});
await db.insert(cartoes).values({
name: data.name,
brand: data.brand,
status: data.status,
closingDay: data.closingDay,
dueDay: data.dueDay,
note: data.note ?? null,
limit: formatDecimalForDb(data.limit),
logo: logoFile,
contaId: data.contaId,
userId: user.id,
});
revalidateForEntity("cartoes");
revalidateForEntity("cartoes");
return { success: true, message: "Cartão criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Cartão criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateCardAction(
input: CardUpdateInput
input: CardUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateCardSchema.parse(input);
try {
const user = await getUser();
const data = updateCardSchema.parse(input);
await assertAccountOwnership(user.id, data.contaId);
await assertAccountOwnership(user.id, data.contaId);
const logoFile = normalizeFilePath(data.logo);
const logoFile = normalizeFilePath(data.logo);
const [updated] = await db
.update(cartoes)
.set({
name: data.name,
brand: data.brand,
status: data.status,
closingDay: data.closingDay,
dueDay: data.dueDay,
note: data.note ?? null,
limit: formatDecimalForDb(data.limit),
logo: logoFile,
contaId: data.contaId,
})
.where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id)))
.returning();
const [updated] = await db
.update(cartoes)
.set({
name: data.name,
brand: data.brand,
status: data.status,
closingDay: data.closingDay,
dueDay: data.dueDay,
note: data.note ?? null,
limit: formatDecimalForDb(data.limit),
logo: logoFile,
contaId: data.contaId,
})
.where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id)))
.returning();
if (!updated) {
return {
success: false,
error: "Cartão não encontrado.",
};
}
if (!updated) {
return {
success: false,
error: "Cartão não encontrado.",
};
}
revalidateForEntity("cartoes");
revalidateForEntity("cartoes");
return { success: true, message: "Cartão atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Cartão atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteCardAction(
input: CardDeleteInput
input: CardDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteCardSchema.parse(input);
try {
const user = await getUser();
const data = deleteCardSchema.parse(input);
const [deleted] = await db
.delete(cartoes)
.where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id)))
.returning({ id: cartoes.id });
const [deleted] = await db
.delete(cartoes)
.where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id)))
.returning({ id: cartoes.id });
if (!deleted) {
return {
success: false,
error: "Cartão não encontrado.",
};
}
if (!deleted) {
return {
success: false,
error: "Cartão não encontrado.",
};
}
revalidateForEntity("cartoes");
revalidateForEntity("cartoes");
return { success: true, message: "Cartão removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Cartão removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -4,191 +4,210 @@ import { loadLogoOptions } from "@/lib/logo/options";
import { and, eq, ilike, isNull, not, or, sql } from "drizzle-orm";
export type CardData = {
id: string;
name: string;
brand: string | null;
status: string | null;
closingDay: number;
dueDay: number;
note: string | null;
logo: string | null;
limit: number | null;
limitInUse: number;
limitAvailable: number | null;
contaId: string;
contaName: string;
id: string;
name: string;
brand: string | null;
status: string | null;
closingDay: number;
dueDay: number;
note: string | null;
logo: string | null;
limit: number | null;
limitInUse: number;
limitAvailable: number | null;
contaId: string;
contaName: string;
};
export type AccountSimple = {
id: string;
name: string;
logo: string | null;
id: string;
name: string;
logo: string | null;
};
export async function fetchCardsForUser(userId: string): Promise<{
cards: CardData[];
accounts: AccountSimple[];
logoOptions: LogoOption[];
cards: CardData[];
accounts: AccountSimple[];
logoOptions: LogoOption[];
}> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({
orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)],
where: and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))),
with: {
conta: {
columns: {
id: true,
name: true,
},
},
},
}),
db.query.contas.findMany({
orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)],
where: eq(contas.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cartaoId: lancamentos.cartaoId,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false))
)
)
.groupBy(lancamentos.cartaoId),
]);
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({
orderBy: (
card: typeof cartoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(card.name)],
where: and(
eq(cartoes.userId, userId),
not(ilike(cartoes.status, "inativo")),
),
with: {
conta: {
columns: {
id: true,
name: true,
},
},
},
}),
db.query.contas.findMany({
orderBy: (
account: typeof contas.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(account.name)],
where: eq(contas.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cartaoId: lancamentos.cartaoId,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
),
)
.groupBy(lancamentos.cartaoId),
]);
const usageMap = new Map<string, number>();
usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0));
});
const usageMap = new Map<string, number>();
usageRows.forEach(
(row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0));
},
);
const cards = cardRows.map((card) => ({
id: card.id,
name: card.name,
brand: card.brand,
status: card.status,
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note,
logo: card.logo,
limit: card.limit ? Number(card.limit) : null,
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
})(),
limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0);
})(),
contaId: card.contaId,
contaName: card.conta?.name ?? "Conta não encontrada",
}));
const cards = cardRows.map((card) => ({
id: card.id,
name: card.name,
brand: card.brand,
status: card.status,
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note,
logo: card.logo,
limit: card.limit ? Number(card.limit) : null,
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
})(),
limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0);
})(),
contaId: card.contaId,
contaName: card.conta?.name ?? "Conta não encontrada",
}));
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
logo: account.logo,
}));
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
logo: account.logo,
}));
return { cards, accounts, logoOptions };
return { cards, accounts, logoOptions };
}
export async function fetchInativosForUser(userId: string): Promise<{
cards: CardData[];
accounts: AccountSimple[];
logoOptions: LogoOption[];
cards: CardData[];
accounts: AccountSimple[];
logoOptions: LogoOption[];
}> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({
orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)],
where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")),
with: {
conta: {
columns: {
id: true,
name: true,
},
},
},
}),
db.query.contas.findMany({
orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)],
where: eq(contas.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cartaoId: lancamentos.cartaoId,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false))
)
)
.groupBy(lancamentos.cartaoId),
]);
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({
orderBy: (
card: typeof cartoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(card.name)],
where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")),
with: {
conta: {
columns: {
id: true,
name: true,
},
},
},
}),
db.query.contas.findMany({
orderBy: (
account: typeof contas.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(account.name)],
where: eq(contas.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cartaoId: lancamentos.cartaoId,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
),
)
.groupBy(lancamentos.cartaoId),
]);
const usageMap = new Map<string, number>();
usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0));
});
const usageMap = new Map<string, number>();
usageRows.forEach(
(row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0));
},
);
const cards = cardRows.map((card) => ({
id: card.id,
name: card.name,
brand: card.brand,
status: card.status,
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note,
logo: card.logo,
limit: card.limit ? Number(card.limit) : null,
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
})(),
limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0);
})(),
contaId: card.contaId,
contaName: card.conta?.name ?? "Conta não encontrada",
}));
const cards = cardRows.map((card) => ({
id: card.id,
name: card.name,
brand: card.brand,
status: card.status,
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note,
logo: card.logo,
limit: card.limit ? Number(card.limit) : null,
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
})(),
limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0);
})(),
contaId: card.contaId,
contaName: card.conta?.name ?? "Conta não encontrada",
}));
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
logo: account.logo,
}));
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
logo: account.logo,
}));
return { cards, accounts, logoOptions };
return { cards, accounts, logoOptions };
}

View File

@@ -3,17 +3,17 @@ import { getUserId } from "@/lib/auth/server";
import { fetchInativosForUser } from "../data";
export default async function InativosPage() {
const userId = await getUserId();
const { cards, accounts, logoOptions } = await fetchInativosForUser(userId);
const userId = await getUserId();
const { cards, accounts, logoOptions } = await fetchInativosForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<CardsPage
cards={cards}
accounts={accounts}
logoOptions={logoOptions}
isInativos={true}
/>
</main>
);
return (
<main className="flex flex-col items-start gap-6">
<CardsPage
cards={cards}
accounts={accounts}
logoOptions={logoOptions}
isInativos={true}
/>
</main>
);
}

View File

@@ -1,25 +1,25 @@
import PageDescription from "@/components/page-description";
import { RiBankCard2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Cartões | Opensheets",
title: "Cartões | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiBankCard2Line />}
title="Cartões"
subtitle="Acompanhe todas os cartões do mês selecionado incluindo faturas, limites
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiBankCard2Line />}
title="Cartões"
subtitle="Acompanhe todas os cartões do mês selecionado incluindo faturas, limites
e transações previstas. Use o seletor abaixo para navegar pelos meses e
visualizar as movimentações correspondentes."
/>
{children}
</section>
);
/>
{children}
</section>
);
}

View File

@@ -1,33 +1,30 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state para a página de cartões
*/
export default function CartoesLoading() {
return (
<main className="flex flex-col gap-6">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
return (
<main className="flex flex-col gap-6">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Grid de cartões */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
</main>
);
{/* Grid de cartões */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -3,12 +3,12 @@ import { getUserId } from "@/lib/auth/server";
import { fetchCardsForUser } from "./data";
export default async function Page() {
const userId = await getUserId();
const { cards, accounts, logoOptions } = await fetchCardsForUser(userId);
const userId = await getUserId();
const { cards, accounts, logoOptions } = await fetchCardsForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<CardsPage cards={cards} accounts={accounts} logoOptions={logoOptions} />
</main>
);
return (
<main className="flex flex-col items-start gap-6">
<CardsPage cards={cards} accounts={accounts} logoOptions={logoOptions} />
</main>
);
}

View File

@@ -1,99 +1,98 @@
import { notFound } from "next/navigation";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { CategoryDetailHeader } from "@/components/categorias/category-detail-header";
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details";
import { getUserId } from "@/lib/auth/server";
import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details";
import {
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
} from "@/lib/lancamentos/page-helpers";
import { displayPeriod, parsePeriodParam } from "@/lib/utils/period";
import { notFound } from "next/navigation";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
params: Promise<{ categoryId: string }>;
searchParams?: PageSearchParams;
params: Promise<{ categoryId: string }>;
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? value[0] ?? null : value;
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ params, searchParams }: PageProps) {
const { categoryId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const { categoryId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [detail, filterSources, estabelecimentos] =
await Promise.all([
fetchCategoryDetails(userId, categoryId, selectedPeriod),
fetchLancamentoFilterSources(userId),
getRecentEstablishmentsAction(),
]);
const [detail, filterSources, estabelecimentos] = await Promise.all([
fetchCategoryDetails(userId, categoryId, selectedPeriod),
fetchLancamentoFilterSources(userId),
getRecentEstablishmentsAction(),
]);
if (!detail) {
notFound();
}
if (!detail) {
notFound();
}
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
const currentPeriodLabel = displayPeriod(detail.period);
const previousPeriodLabel = displayPeriod(detail.previousPeriod);
const currentPeriodLabel = displayPeriod(detail.period);
const previousPeriodLabel = displayPeriod(detail.previousPeriod);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<CategoryDetailHeader
category={detail.category}
currentPeriodLabel={currentPeriodLabel}
previousPeriodLabel={previousPeriodLabel}
currentTotal={detail.currentTotal}
previousTotal={detail.previousTotal}
percentageChange={detail.percentageChange}
transactionCount={detail.transactions.length}
/>
<LancamentosPage
currentUserId={userId}
lancamentos={detail.transactions}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={detail.period}
estabelecimentos={estabelecimentos}
allowCreate={true}
/>
</main>
);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<CategoryDetailHeader
category={detail.category}
currentPeriodLabel={currentPeriodLabel}
previousPeriodLabel={previousPeriodLabel}
currentTotal={detail.currentTotal}
previousTotal={detail.previousTotal}
percentageChange={detail.percentageChange}
transactionCount={detail.transactions.length}
/>
<LancamentosPage
currentUserId={userId}
lancamentos={detail.transactions}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={detail.period}
estabelecimentos={estabelecimentos}
allowCreate={true}
/>
</main>
);
}

View File

@@ -1,41 +1,41 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { categorias } from "@/db/schema";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { CATEGORY_TYPES } from "@/lib/categorias/constants";
import { db } from "@/lib/db";
import { uuidSchema } from "@/lib/schemas/common";
import { normalizeIconInput } from "@/lib/utils/string";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
const categoryBaseSchema = z.object({
name: z
.string({ message: "Informe o nome da categoria." })
.trim()
.min(1, "Informe o nome da categoria."),
type: z.enum(CATEGORY_TYPES, {
message: "Tipo de categoria inválido.",
}),
icon: z
.string()
.trim()
.max(100, "O ícone deve ter no máximo 100 caracteres.")
.nullish()
.transform((value) => normalizeIconInput(value)),
name: z
.string({ message: "Informe o nome da categoria." })
.trim()
.min(1, "Informe o nome da categoria."),
type: z.enum(CATEGORY_TYPES, {
message: "Tipo de categoria inválido.",
}),
icon: z
.string()
.trim()
.max(100, "O ícone deve ter no máximo 100 caracteres.")
.nullish()
.transform((value) => normalizeIconInput(value)),
});
const createCategorySchema = categoryBaseSchema;
const updateCategorySchema = categoryBaseSchema.extend({
id: uuidSchema("Categoria"),
id: uuidSchema("Categoria"),
});
const deleteCategorySchema = z.object({
id: uuidSchema("Categoria"),
id: uuidSchema("Categoria"),
});
type CategoryCreateInput = z.infer<typeof createCategorySchema>;
@@ -43,134 +43,134 @@ type CategoryUpdateInput = z.infer<typeof updateCategorySchema>;
type CategoryDeleteInput = z.infer<typeof deleteCategorySchema>;
export async function createCategoryAction(
input: CategoryCreateInput
input: CategoryCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createCategorySchema.parse(input);
try {
const user = await getUser();
const data = createCategorySchema.parse(input);
await db.insert(categorias).values({
name: data.name,
type: data.type,
icon: data.icon,
userId: user.id,
});
await db.insert(categorias).values({
name: data.name,
type: data.type,
icon: data.icon,
userId: user.id,
});
revalidateForEntity("categorias");
revalidateForEntity("categorias");
return { success: true, message: "Categoria criada com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Categoria criada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateCategoryAction(
input: CategoryUpdateInput
input: CategoryUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateCategorySchema.parse(input);
try {
const user = await getUser();
const data = updateCategorySchema.parse(input);
// Buscar categoria antes de atualizar para verificar restrições
const categoria = await db.query.categorias.findFirst({
columns: { id: true, name: true },
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
});
// Buscar categoria antes de atualizar para verificar restrições
const categoria = await db.query.categorias.findFirst({
columns: { id: true, name: true },
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
});
if (!categoria) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
if (!categoria) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
// Bloquear edição das categorias protegidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
if (categoriasProtegidas.includes(categoria.name)) {
return {
success: false,
error: `A categoria '${categoria.name}' é protegida e não pode ser editada.`,
};
}
// Bloquear edição das categorias protegidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
if (categoriasProtegidas.includes(categoria.name)) {
return {
success: false,
error: `A categoria '${categoria.name}' é protegida e não pode ser editada.`,
};
}
const [updated] = await db
.update(categorias)
.set({
name: data.name,
type: data.type,
icon: data.icon,
})
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
.returning();
const [updated] = await db
.update(categorias)
.set({
name: data.name,
type: data.type,
icon: data.icon,
})
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
.returning();
if (!updated) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
if (!updated) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
revalidateForEntity("categorias");
revalidateForEntity("categorias");
return { success: true, message: "Categoria atualizada com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Categoria atualizada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteCategoryAction(
input: CategoryDeleteInput
input: CategoryDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteCategorySchema.parse(input);
try {
const user = await getUser();
const data = deleteCategorySchema.parse(input);
// Buscar categoria antes de deletar para verificar restrições
const categoria = await db.query.categorias.findFirst({
columns: { id: true, name: true },
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
});
// Buscar categoria antes de deletar para verificar restrições
const categoria = await db.query.categorias.findFirst({
columns: { id: true, name: true },
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
});
if (!categoria) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
if (!categoria) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
// Bloquear remoção das categorias protegidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
if (categoriasProtegidas.includes(categoria.name)) {
return {
success: false,
error: `A categoria '${categoria.name}' é protegida e não pode ser removida.`,
};
}
// Bloquear remoção das categorias protegidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
if (categoriasProtegidas.includes(categoria.name)) {
return {
success: false,
error: `A categoria '${categoria.name}' é protegida e não pode ser removida.`,
};
}
const [deleted] = await db
.delete(categorias)
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
.returning({ id: categorias.id });
const [deleted] = await db
.delete(categorias)
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
.returning({ id: categorias.id });
if (!deleted) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
if (!deleted) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
revalidateForEntity("categorias");
revalidateForEntity("categorias");
return { success: true, message: "Categoria removida com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Categoria removida com sucesso." };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -1,26 +1,26 @@
import type { CategoryType } from "@/components/categorias/types";
import { categorias, type Categoria } from "@/db/schema";
import { db } from "@/lib/db";
import { eq } from "drizzle-orm";
import type { CategoryType } from "@/components/categorias/types";
import { type Categoria, categorias } from "@/db/schema";
import { db } from "@/lib/db";
export type CategoryData = {
id: string;
name: string;
type: CategoryType;
icon: string | null;
id: string;
name: string;
type: CategoryType;
icon: string | null;
};
export async function fetchCategoriesForUser(
userId: string
userId: string,
): Promise<CategoryData[]> {
const categoryRows = await db.query.categorias.findMany({
where: eq(categorias.userId, userId),
});
const categoryRows = await db.query.categorias.findMany({
where: eq(categorias.userId, userId),
});
return categoryRows.map((category: Categoria) => ({
id: category.id,
name: category.name,
type: category.type as CategoryType,
icon: category.icon,
}));
return categoryRows.map((category: Categoria) => ({
id: category.id,
name: category.name,
type: category.type as CategoryType,
icon: category.icon,
}));
}

View File

@@ -1,33 +1,33 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-6 px-6">
<Card className="h-auto">
<CardContent className="space-y-2.5">
<div className="space-y-2">
{/* Selected categories and counter */}
<div className="flex items-start justify-between gap-4">
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-32 rounded-md" />
<Skeleton className="h-8 w-40 rounded-md" />
<Skeleton className="h-8 w-36 rounded-md" />
</div>
<div className="flex items-center gap-2 shrink-0 pt-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-6 w-14" />
</div>
</div>
return (
<main className="flex flex-col gap-6 px-6">
<Card className="h-auto">
<CardContent className="space-y-2.5">
<div className="space-y-2">
{/* Selected categories and counter */}
<div className="flex items-start justify-between gap-4">
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-32 rounded-md" />
<Skeleton className="h-8 w-40 rounded-md" />
<Skeleton className="h-8 w-36 rounded-md" />
</div>
<div className="flex items-center gap-2 shrink-0 pt-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-6 w-14" />
</div>
</div>
{/* Category selector button */}
<Skeleton className="h-9 w-full rounded-md" />
</div>
{/* Category selector button */}
<Skeleton className="h-9 w-full rounded-md" />
</div>
{/* Chart */}
<Skeleton className="h-[450px] w-full rounded-lg" />
</CardContent>
</Card>
</main>
);
{/* Chart */}
<Skeleton className="h-[450px] w-full rounded-lg" />
</CardContent>
</Card>
</main>
);
}

View File

@@ -4,14 +4,14 @@ import { fetchCategoryHistory } from "@/lib/dashboard/categories/category-histor
import { getCurrentPeriod } from "@/lib/utils/period";
export default async function HistoricoCategoriasPage() {
const user = await getUser();
const currentPeriod = getCurrentPeriod();
const user = await getUser();
const currentPeriod = getCurrentPeriod();
const data = await fetchCategoryHistory(user.id, currentPeriod);
const data = await fetchCategoryHistory(user.id, currentPeriod);
return (
<main>
<CategoryHistoryWidget data={data} />
</main>
);
return (
<main>
<CategoryHistoryWidget data={data} />
</main>
);
}

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiPriceTag3Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Categorias | Opensheets",
title: "Categorias | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiPriceTag3Line />}
title="Categorias"
subtitle="Gerencie suas categorias de despesas e receitas acompanhando o histórico de desempenho dos últimos 9 meses, permitindo ajustes financeiros precisos conforme necessário."
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiPriceTag3Line />}
title="Categorias"
subtitle="Gerencie suas categorias de despesas e receitas acompanhando o histórico de desempenho dos últimos 9 meses, permitindo ajustes financeiros precisos conforme necessário."
/>
{children}
</section>
);
}

View File

@@ -5,57 +5,54 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: Header + Tabs + Grid de cards
*/
export default function CategoriasLoading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Tabs */}
<div className="space-y-4">
<div className="flex gap-2 border-b">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton
key={i}
className="h-10 w-32 rounded-t-2xl bg-foreground/10"
/>
))}
</div>
{/* Tabs */}
<div className="space-y-4">
<div className="flex gap-2 border-b">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton
key={i}
className="h-10 w-32 rounded-t-2xl bg-foreground/10"
/>
))}
</div>
{/* Grid de cards de categorias */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border p-6 space-y-4"
>
{/* Ícone + Nome */}
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-2xl bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Grid de cards de categorias */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
{/* Ícone + Nome */}
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-2xl bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Descrição */}
{i % 3 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
{/* Descrição */}
{i % 3 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
{/* Botões de ação */}
<div className="flex gap-2 pt-2 border-t">
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</div>
</main>
);
{/* Botões de ação */}
<div className="flex gap-2 pt-2 border-t">
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</div>
</main>
);
}

View File

@@ -3,12 +3,12 @@ import { getUserId } from "@/lib/auth/server";
import { fetchCategoriesForUser } from "./data";
export default async function Page() {
const userId = await getUserId();
const categories = await fetchCategoriesForUser(userId);
const userId = await getUserId();
const categories = await fetchCategoriesForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<CategoriesPage categories={categories} />
</main>
);
return (
<main className="flex flex-col items-start gap-6">
<CategoriesPage categories={categories} />
</main>
);
}

View File

@@ -1,41 +1,41 @@
import { and, desc, eq, lt, type SQL, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, lt, sql } from "drizzle-orm";
export type AccountSummaryData = {
openingBalance: number;
currentBalance: number;
totalIncomes: number;
totalExpenses: number;
openingBalance: number;
currentBalance: number;
totalIncomes: number;
totalExpenses: number;
};
export async function fetchAccountData(userId: string, contaId: string) {
const account = await db.query.contas.findFirst({
columns: {
id: true,
name: true,
accountType: true,
status: true,
initialBalance: true,
logo: true,
note: true,
},
where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
});
const account = await db.query.contas.findFirst({
columns: {
id: true,
name: true,
accountType: true,
status: true,
initialBalance: true,
logo: true,
note: true,
},
where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
});
return account;
return account;
}
export async function fetchAccountSummary(
userId: string,
contaId: string,
selectedPeriod: string
userId: string,
contaId: string,
selectedPeriod: string,
): Promise<AccountSummaryData> {
const [periodSummary] = await db
.select({
netAmount: sql<number>`
const [periodSummary] = await db
.select({
netAmount: sql<number>`
coalesce(
sum(
case
@@ -46,7 +46,7 @@ export async function fetchAccountSummary(
0
)
`,
incomes: sql<number>`
incomes: sql<number>`
coalesce(
sum(
case
@@ -58,7 +58,7 @@ export async function fetchAccountSummary(
0
)
`,
expenses: sql<number>`
expenses: sql<number>`
coalesce(
sum(
case
@@ -70,22 +70,22 @@ export async function fetchAccountSummary(
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.contaId, contaId),
eq(lancamentos.period, selectedPeriod),
eq(lancamentos.isSettled, true),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
)
);
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.contaId, contaId),
eq(lancamentos.period, selectedPeriod),
eq(lancamentos.isSettled, true),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
);
const [previousRow] = await db
.select({
previousMovements: sql<number>`
const [previousRow] = await db
.select({
previousMovements: sql<number>`
coalesce(
sum(
case
@@ -96,36 +96,56 @@ export async function fetchAccountSummary(
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.contaId, contaId),
lt(lancamentos.period, selectedPeriod),
eq(lancamentos.isSettled, true),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
)
);
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.contaId, contaId),
lt(lancamentos.period, selectedPeriod),
eq(lancamentos.isSettled, true),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
);
const account = await fetchAccountData(userId, contaId);
if (!account) {
throw new Error("Account not found");
}
const account = await fetchAccountData(userId, contaId);
if (!account) {
throw new Error("Account not found");
}
const initialBalance = Number(account.initialBalance ?? 0);
const previousMovements = Number(previousRow?.previousMovements ?? 0);
const openingBalance = initialBalance + previousMovements;
const netAmount = Number(periodSummary?.netAmount ?? 0);
const totalIncomes = Number(periodSummary?.incomes ?? 0);
const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0));
const currentBalance = openingBalance + netAmount;
const initialBalance = Number(account.initialBalance ?? 0);
const previousMovements = Number(previousRow?.previousMovements ?? 0);
const openingBalance = initialBalance + previousMovements;
const netAmount = Number(periodSummary?.netAmount ?? 0);
const totalIncomes = Number(periodSummary?.incomes ?? 0);
const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0));
const currentBalance = openingBalance + netAmount;
return {
openingBalance,
currentBalance,
totalIncomes,
totalExpenses,
};
return {
openingBalance,
currentBalance,
totalIncomes,
totalExpenses,
};
}
export async function fetchAccountLancamentos(
filters: SQL[],
settledOnly = true,
) {
const allFilters = settledOnly
? [...filters, eq(lancamentos.isSettled, true)]
: filters;
return db.query.lancamentos.findMany({
where: and(...allFilters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
}

View File

@@ -1,7 +1,7 @@
import {
AccountStatementCardSkeleton,
FilterSkeleton,
TransactionsTableSkeleton,
AccountStatementCardSkeleton,
FilterSkeleton,
TransactionsTableSkeleton,
} from "@/components/skeletons";
import { Skeleton } from "@/components/ui/skeleton";
@@ -10,29 +10,29 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: MonthPicker + AccountStatementCard + Filtros + Tabela de lançamentos
*/
export default function ExtratoLoading() {
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Account Statement Card */}
<AccountStatementCardSkeleton />
{/* Account Statement Card */}
<AccountStatementCardSkeleton />
{/* Seção de lançamentos */}
<section className="flex flex-col gap-4">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
</div>
{/* Seção de lançamentos */}
<section className="flex flex-col gap-4">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
</div>
{/* Filtros */}
<FilterSkeleton />
{/* Filtros */}
<FilterSkeleton />
{/* Tabela */}
<TransactionsTableSkeleton />
</div>
</section>
</main>
);
{/* Tabela */}
<TransactionsTableSkeleton />
</div>
</section>
</main>
);
}

View File

@@ -1,3 +1,5 @@
import { RiPencilLine } from "@remixicon/react";
import { notFound } from "next/navigation";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { AccountDialog } from "@/components/contas/account-dialog";
import { AccountStatementCard } from "@/components/contas/account-statement-card";
@@ -5,178 +7,162 @@ import type { Account } from "@/components/contas/types";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { Button } from "@/components/ui/button";
import { lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { loadLogoOptions } from "@/lib/logo/options";
import { parsePeriodParam } from "@/lib/utils/period";
import { RiPencilLine } from "@remixicon/react";
import { and, desc, eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { fetchAccountData, fetchAccountSummary } from "./data";
import {
fetchAccountData,
fetchAccountLancamentos,
fetchAccountSummary,
} from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
params: Promise<{ contaId: string }>;
searchParams?: PageSearchParams;
params: Promise<{ contaId: string }>;
searchParams?: PageSearchParams;
};
const capitalize = (value: string) =>
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
export default async function Page({ params, searchParams }: PageProps) {
const { contaId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const { contaId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName,
year,
} = parsePeriodParam(periodoParamRaw);
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName,
year,
} = parsePeriodParam(periodoParamRaw);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const account = await fetchAccountData(userId, contaId);
const account = await fetchAccountData(userId, contaId);
if (!account) {
notFound();
}
if (!account) {
notFound();
}
const [
filterSources,
logoOptions,
accountSummary,
estabelecimentos,
] = await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchAccountSummary(userId, contaId, selectedPeriod),
getRecentEstablishmentsAction(),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const [filterSources, logoOptions, accountSummary, estabelecimentos] =
await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchAccountSummary(userId, contaId, selectedPeriod),
getRecentEstablishmentsAction(),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
accountId: account.id,
});
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
accountId: account.id,
});
filters.push(eq(lancamentos.isSettled, true));
const lancamentoRows = await fetchAccountLancamentos(filters);
const lancamentoRows = await db.query.lancamentos.findMany({
where: and(...filters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
const lancamentosData = mapLancamentosData(lancamentoRows);
const lancamentosData = mapLancamentosData(lancamentoRows);
const { openingBalance, currentBalance, totalIncomes, totalExpenses } =
accountSummary;
const { openingBalance, currentBalance, totalIncomes, totalExpenses } =
accountSummary;
const periodLabel = `${capitalize(monthName)} de ${year}`;
const periodLabel = `${capitalize(monthName)} de ${year}`;
const accountDialogData: Account = {
id: account.id,
name: account.name,
accountType: account.accountType,
status: account.status,
note: account.note,
logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0),
balance: currentBalance,
};
const accountDialogData: Account = {
id: account.id,
name: account.name,
accountType: account.accountType,
status: account.status,
note: account.note,
logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0),
balance: currentBalance,
};
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
limitContaId: account.id,
});
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
limitContaId: account.id,
});
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<AccountStatementCard
accountName={account.name}
accountType={account.accountType}
status={account.status}
periodLabel={periodLabel}
openingBalance={openingBalance}
currentBalance={currentBalance}
totalIncomes={totalIncomes}
totalExpenses={totalExpenses}
logo={account.logo}
actions={
<AccountDialog
mode="update"
account={accountDialogData}
logoOptions={logoOptions}
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Editar conta"
>
<RiPencilLine className="size-4" />
</Button>
}
/>
}
/>
<AccountStatementCard
accountName={account.name}
accountType={account.accountType}
status={account.status}
periodLabel={periodLabel}
openingBalance={openingBalance}
currentBalance={currentBalance}
totalIncomes={totalIncomes}
totalExpenses={totalExpenses}
logo={account.logo}
actions={
<AccountDialog
mode="update"
account={accountDialogData}
logoOptions={logoOptions}
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Editar conta"
>
<RiPencilLine className="size-4" />
</Button>
}
/>
}
/>
<section className="flex flex-col gap-4">
<LancamentosSection
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate={false}
/>
</section>
</main>
);
<section className="flex flex-col gap-4">
<LancamentosSection
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate={false}
/>
</section>
</main>
);
}

View File

@@ -1,72 +1,75 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
import {
INITIAL_BALANCE_CATEGORY_NAME,
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
INITIAL_BALANCE_PAYMENT_METHOD,
INITIAL_BALANCE_TRANSACTION_TYPE,
INITIAL_BALANCE_CATEGORY_NAME,
INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE,
INITIAL_BALANCE_PAYMENT_METHOD,
INITIAL_BALANCE_TRANSACTION_TYPE,
} from "@/lib/accounts/constants";
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
import { revalidateForEntity } from "@/lib/actions/helpers";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
import {
TRANSFER_CATEGORY_NAME,
TRANSFER_CONDITION,
TRANSFER_ESTABLISHMENT,
TRANSFER_PAYMENT_METHOD,
} from "@/lib/transferencias/constants";
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { getTodayInfo } from "@/lib/utils/date";
import { normalizeFilePath } from "@/lib/utils/string";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import {
TRANSFER_CATEGORY_NAME,
TRANSFER_CONDITION,
TRANSFER_ESTABLISHMENT,
TRANSFER_PAYMENT_METHOD,
} from "@/lib/transferencias/constants";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
const accountBaseSchema = z.object({
name: z
.string({ message: "Informe o nome da conta." })
.trim()
.min(1, "Informe o nome da conta."),
accountType: z
.string({ message: "Informe o tipo da conta." })
.trim()
.min(1, "Informe o tipo da conta."),
status: z
.string({ message: "Informe o status da conta." })
.trim()
.min(1, "Informe o status da conta."),
note: noteSchema,
logo: z
.string({ message: "Selecione um logo." })
.trim()
.min(1, "Selecione um logo."),
initialBalance: z
.string()
.trim()
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um saldo inicial válido."
)
.transform((value) => Number.parseFloat(value)),
excludeFromBalance: z
.union([z.boolean(), z.string()])
.transform((value) => value === true || value === "true"),
excludeInitialBalanceFromIncome: z
.union([z.boolean(), z.string()])
.transform((value) => value === true || value === "true"),
name: z
.string({ message: "Informe o nome da conta." })
.trim()
.min(1, "Informe o nome da conta."),
accountType: z
.string({ message: "Informe o tipo da conta." })
.trim()
.min(1, "Informe o tipo da conta."),
status: z
.string({ message: "Informe o status da conta." })
.trim()
.min(1, "Informe o status da conta."),
note: noteSchema,
logo: z
.string({ message: "Selecione um logo." })
.trim()
.min(1, "Selecione um logo."),
initialBalance: z
.string()
.trim()
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um saldo inicial válido.",
)
.transform((value) => Number.parseFloat(value)),
excludeFromBalance: z
.union([z.boolean(), z.string()])
.transform((value) => value === true || value === "true"),
excludeInitialBalanceFromIncome: z
.union([z.boolean(), z.string()])
.transform((value) => value === true || value === "true"),
});
const createAccountSchema = accountBaseSchema;
const updateAccountSchema = accountBaseSchema.extend({
id: uuidSchema("Conta"),
id: uuidSchema("Conta"),
});
const deleteAccountSchema = z.object({
id: uuidSchema("Conta"),
id: uuidSchema("Conta"),
});
type AccountCreateInput = z.infer<typeof createAccountSchema>;
@@ -74,315 +77,315 @@ type AccountUpdateInput = z.infer<typeof updateAccountSchema>;
type AccountDeleteInput = z.infer<typeof deleteAccountSchema>;
export async function createAccountAction(
input: AccountCreateInput
input: AccountCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createAccountSchema.parse(input);
try {
const user = await getUser();
const data = createAccountSchema.parse(input);
const logoFile = normalizeFilePath(data.logo);
const logoFile = normalizeFilePath(data.logo);
const normalizedInitialBalance = Math.abs(data.initialBalance);
const hasInitialBalance = normalizedInitialBalance > 0;
const normalizedInitialBalance = Math.abs(data.initialBalance);
const hasInitialBalance = normalizedInitialBalance > 0;
await db.transaction(async (tx: typeof db) => {
const [createdAccount] = await tx
.insert(contas)
.values({
name: data.name,
accountType: data.accountType,
status: data.status,
note: data.note ?? null,
logo: logoFile,
initialBalance: formatDecimalForDbRequired(data.initialBalance),
excludeFromBalance: data.excludeFromBalance,
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
userId: user.id,
})
.returning({ id: contas.id, name: contas.name });
await db.transaction(async (tx: typeof db) => {
const [createdAccount] = await tx
.insert(contas)
.values({
name: data.name,
accountType: data.accountType,
status: data.status,
note: data.note ?? null,
logo: logoFile,
initialBalance: formatDecimalForDbRequired(data.initialBalance),
excludeFromBalance: data.excludeFromBalance,
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
userId: user.id,
})
.returning({ id: contas.id, name: contas.name });
if (!createdAccount) {
throw new Error("Não foi possível criar a conta.");
}
if (!createdAccount) {
throw new Error("Não foi possível criar a conta.");
}
if (!hasInitialBalance) {
return;
}
if (!hasInitialBalance) {
return;
}
const [category, adminPagador] = await Promise.all([
tx.query.categorias.findFirst({
columns: { id: true },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME)
),
}),
tx.query.pagadores.findFirst({
columns: { id: true },
where: and(
eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
),
}),
]);
const [category, adminPagador] = await Promise.all([
tx.query.categorias.findFirst({
columns: { id: true },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME),
),
}),
tx.query.pagadores.findFirst({
columns: { id: true },
where: and(
eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
}),
]);
if (!category) {
throw new Error(
'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.'
);
}
if (!category) {
throw new Error(
'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.',
);
}
if (!adminPagador) {
throw new Error(
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial."
);
}
if (!adminPagador) {
throw new Error(
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
);
}
const { date, period } = getTodayInfo();
const { date, period } = getTodayInfo();
await tx.insert(lancamentos).values({
condition: INITIAL_BALANCE_CONDITION,
name: `Saldo inicial - ${createdAccount.name}`,
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
note: INITIAL_BALANCE_NOTE,
amount: formatDecimalForDbRequired(normalizedInitialBalance),
purchaseDate: date,
transactionType: INITIAL_BALANCE_TRANSACTION_TYPE,
period,
isSettled: true,
userId: user.id,
contaId: createdAccount.id,
categoriaId: category.id,
pagadorId: adminPagador.id,
});
});
await tx.insert(lancamentos).values({
condition: INITIAL_BALANCE_CONDITION,
name: `Saldo inicial - ${createdAccount.name}`,
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
note: INITIAL_BALANCE_NOTE,
amount: formatDecimalForDbRequired(normalizedInitialBalance),
purchaseDate: date,
transactionType: INITIAL_BALANCE_TRANSACTION_TYPE,
period,
isSettled: true,
userId: user.id,
contaId: createdAccount.id,
categoriaId: category.id,
pagadorId: adminPagador.id,
});
});
revalidateForEntity("contas");
revalidateForEntity("contas");
return {
success: true,
message: "Conta criada com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
message: "Conta criada com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
}
export async function updateAccountAction(
input: AccountUpdateInput
input: AccountUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateAccountSchema.parse(input);
try {
const user = await getUser();
const data = updateAccountSchema.parse(input);
const logoFile = normalizeFilePath(data.logo);
const logoFile = normalizeFilePath(data.logo);
const [updated] = await db
.update(contas)
.set({
name: data.name,
accountType: data.accountType,
status: data.status,
note: data.note ?? null,
logo: logoFile,
initialBalance: formatDecimalForDbRequired(data.initialBalance),
excludeFromBalance: data.excludeFromBalance,
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
})
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
.returning();
const [updated] = await db
.update(contas)
.set({
name: data.name,
accountType: data.accountType,
status: data.status,
note: data.note ?? null,
logo: logoFile,
initialBalance: formatDecimalForDbRequired(data.initialBalance),
excludeFromBalance: data.excludeFromBalance,
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
})
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
.returning();
if (!updated) {
return {
success: false,
error: "Conta não encontrada.",
};
}
if (!updated) {
return {
success: false,
error: "Conta não encontrada.",
};
}
revalidateForEntity("contas");
revalidateForEntity("contas");
return {
success: true,
message: "Conta atualizada com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
message: "Conta atualizada com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
}
export async function deleteAccountAction(
input: AccountDeleteInput
input: AccountDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteAccountSchema.parse(input);
try {
const user = await getUser();
const data = deleteAccountSchema.parse(input);
const [deleted] = await db
.delete(contas)
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
.returning({ id: contas.id });
const [deleted] = await db
.delete(contas)
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
.returning({ id: contas.id });
if (!deleted) {
return {
success: false,
error: "Conta não encontrada.",
};
}
if (!deleted) {
return {
success: false,
error: "Conta não encontrada.",
};
}
revalidateForEntity("contas");
revalidateForEntity("contas");
return {
success: true,
message: "Conta removida com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
message: "Conta removida com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
}
// Transfer between accounts
const transferSchema = z.object({
fromAccountId: uuidSchema("Conta de origem"),
toAccountId: uuidSchema("Conta de destino"),
amount: z
.string()
.trim()
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um valor válido."
)
.transform((value) => Number.parseFloat(value))
.refine((value) => value > 0, "O valor deve ser maior que zero."),
date: z.coerce.date({ message: "Informe uma data válida." }),
period: z
.string({ message: "Informe o período." })
.trim()
.min(1, "Informe o período."),
fromAccountId: uuidSchema("Conta de origem"),
toAccountId: uuidSchema("Conta de destino"),
amount: z
.string()
.trim()
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um valor válido.",
)
.transform((value) => Number.parseFloat(value))
.refine((value) => value > 0, "O valor deve ser maior que zero."),
date: z.coerce.date({ message: "Informe uma data válida." }),
period: z
.string({ message: "Informe o período." })
.trim()
.min(1, "Informe o período."),
});
type TransferInput = z.infer<typeof transferSchema>;
export async function transferBetweenAccountsAction(
input: TransferInput
input: TransferInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = transferSchema.parse(input);
try {
const user = await getUser();
const data = transferSchema.parse(input);
// Validate that accounts are different
if (data.fromAccountId === data.toAccountId) {
return {
success: false,
error: "A conta de origem e destino devem ser diferentes.",
};
}
// Validate that accounts are different
if (data.fromAccountId === data.toAccountId) {
return {
success: false,
error: "A conta de origem e destino devem ser diferentes.",
};
}
// Generate a unique transfer ID to link both transactions
const transferId = crypto.randomUUID();
// Generate a unique transfer ID to link both transactions
const transferId = crypto.randomUUID();
await db.transaction(async (tx: typeof db) => {
// Verify both accounts exist and belong to the user
const [fromAccount, toAccount] = await Promise.all([
tx.query.contas.findFirst({
columns: { id: true, name: true },
where: and(
eq(contas.id, data.fromAccountId),
eq(contas.userId, user.id)
),
}),
tx.query.contas.findFirst({
columns: { id: true, name: true },
where: and(
eq(contas.id, data.toAccountId),
eq(contas.userId, user.id)
),
}),
]);
await db.transaction(async (tx: typeof db) => {
// Verify both accounts exist and belong to the user
const [fromAccount, toAccount] = await Promise.all([
tx.query.contas.findFirst({
columns: { id: true, name: true },
where: and(
eq(contas.id, data.fromAccountId),
eq(contas.userId, user.id),
),
}),
tx.query.contas.findFirst({
columns: { id: true, name: true },
where: and(
eq(contas.id, data.toAccountId),
eq(contas.userId, user.id),
),
}),
]);
if (!fromAccount) {
throw new Error("Conta de origem não encontrada.");
}
if (!fromAccount) {
throw new Error("Conta de origem não encontrada.");
}
if (!toAccount) {
throw new Error("Conta de destino não encontrada.");
}
if (!toAccount) {
throw new Error("Conta de destino não encontrada.");
}
// Get the transfer category
const transferCategory = await tx.query.categorias.findFirst({
columns: { id: true },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, TRANSFER_CATEGORY_NAME)
),
});
// Get the transfer category
const transferCategory = await tx.query.categorias.findFirst({
columns: { id: true },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, TRANSFER_CATEGORY_NAME),
),
});
if (!transferCategory) {
throw new Error(
`Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`
);
}
if (!transferCategory) {
throw new Error(
`Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`,
);
}
// Get the admin payer
const adminPagador = await tx.query.pagadores.findFirst({
columns: { id: true },
where: and(
eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
),
});
// Get the admin payer
const adminPagador = await tx.query.pagadores.findFirst({
columns: { id: true },
where: and(
eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
});
if (!adminPagador) {
throw new Error(
"Pagador administrador não encontrado. Por favor, crie um pagador admin."
);
}
if (!adminPagador) {
throw new Error(
"Pagador administrador não encontrado. Por favor, crie um pagador admin.",
);
}
// Create outgoing transaction (transfer from source account)
await tx.insert(lancamentos).values({
condition: TRANSFER_CONDITION,
name: `${TRANSFER_ESTABLISHMENT}${toAccount.name}`,
paymentMethod: TRANSFER_PAYMENT_METHOD,
note: `Transferência para ${toAccount.name}`,
amount: formatDecimalForDbRequired(-Math.abs(data.amount)),
purchaseDate: data.date,
transactionType: "Transferência",
period: data.period,
isSettled: true,
userId: user.id,
contaId: fromAccount.id,
categoriaId: transferCategory.id,
pagadorId: adminPagador.id,
transferId,
});
// Create outgoing transaction (transfer from source account)
await tx.insert(lancamentos).values({
condition: TRANSFER_CONDITION,
name: `${TRANSFER_ESTABLISHMENT}${toAccount.name}`,
paymentMethod: TRANSFER_PAYMENT_METHOD,
note: `Transferência para ${toAccount.name}`,
amount: formatDecimalForDbRequired(-Math.abs(data.amount)),
purchaseDate: data.date,
transactionType: "Transferência",
period: data.period,
isSettled: true,
userId: user.id,
contaId: fromAccount.id,
categoriaId: transferCategory.id,
pagadorId: adminPagador.id,
transferId,
});
// Create incoming transaction (transfer to destination account)
await tx.insert(lancamentos).values({
condition: TRANSFER_CONDITION,
name: `${TRANSFER_ESTABLISHMENT}${fromAccount.name}`,
paymentMethod: TRANSFER_PAYMENT_METHOD,
note: `Transferência de ${fromAccount.name}`,
amount: formatDecimalForDbRequired(Math.abs(data.amount)),
purchaseDate: data.date,
transactionType: "Transferência",
period: data.period,
isSettled: true,
userId: user.id,
contaId: toAccount.id,
categoriaId: transferCategory.id,
pagadorId: adminPagador.id,
transferId,
});
});
// Create incoming transaction (transfer to destination account)
await tx.insert(lancamentos).values({
condition: TRANSFER_CONDITION,
name: `${TRANSFER_ESTABLISHMENT}${fromAccount.name}`,
paymentMethod: TRANSFER_PAYMENT_METHOD,
note: `Transferência de ${fromAccount.name}`,
amount: formatDecimalForDbRequired(Math.abs(data.amount)),
purchaseDate: data.date,
transactionType: "Transferência",
period: data.period,
isSettled: true,
userId: user.id,
contaId: toAccount.id,
categoriaId: transferCategory.id,
pagadorId: adminPagador.id,
transferId,
});
});
revalidateForEntity("contas");
revalidateForEntity("lancamentos");
revalidateForEntity("contas");
revalidateForEntity("lancamentos");
return {
success: true,
message: "Transferência registrada com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
message: "Transferência registrada com sucesso.",
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -1,39 +1,39 @@
import { and, eq, ilike, not, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { loadLogoOptions } from "@/lib/logo/options";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, ilike, not, sql } from "drizzle-orm";
export type AccountData = {
id: string;
name: string;
accountType: string;
status: string;
note: string | null;
logo: string | null;
initialBalance: number;
balance: number;
excludeFromBalance: boolean;
excludeInitialBalanceFromIncome: boolean;
id: string;
name: string;
accountType: string;
status: string;
note: string | null;
logo: string | null;
initialBalance: number;
balance: number;
excludeFromBalance: boolean;
excludeInitialBalanceFromIncome: boolean;
};
export async function fetchAccountsForUser(
userId: string
userId: string,
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
const [accountRows, logoOptions] = await Promise.all([
db
.select({
id: contas.id,
name: contas.name,
accountType: contas.accountType,
status: contas.status,
note: contas.note,
logo: contas.logo,
initialBalance: contas.initialBalance,
excludeFromBalance: contas.excludeFromBalance,
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
balanceMovements: sql<number>`
const [accountRows, logoOptions] = await Promise.all([
db
.select({
id: contas.id,
name: contas.name,
accountType: contas.accountType,
status: contas.status,
note: contas.note,
logo: contas.logo,
initialBalance: contas.initialBalance,
excludeFromBalance: contas.excludeFromBalance,
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
balanceMovements: sql<number>`
coalesce(
sum(
case
@@ -44,72 +44,72 @@ export async function fetchAccountsForUser(
0
)
`,
})
.from(contas)
.leftJoin(
lancamentos,
and(
eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true)
)
)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(contas.userId, userId),
not(ilike(contas.status, "inativa")),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
)
)
.groupBy(
contas.id,
contas.name,
contas.accountType,
contas.status,
contas.note,
contas.logo,
contas.initialBalance,
contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome
),
loadLogoOptions(),
]);
})
.from(contas)
.leftJoin(
lancamentos,
and(
eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true),
),
)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(contas.userId, userId),
not(ilike(contas.status, "inativa")),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
),
)
.groupBy(
contas.id,
contas.name,
contas.accountType,
contas.status,
contas.note,
contas.logo,
contas.initialBalance,
contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome,
),
loadLogoOptions(),
]);
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
accountType: account.accountType,
status: account.status,
note: account.note,
logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0),
balance:
Number(account.initialBalance ?? 0) +
Number(account.balanceMovements ?? 0),
excludeFromBalance: account.excludeFromBalance,
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
}));
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
accountType: account.accountType,
status: account.status,
note: account.note,
logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0),
balance:
Number(account.initialBalance ?? 0) +
Number(account.balanceMovements ?? 0),
excludeFromBalance: account.excludeFromBalance,
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
}));
return { accounts, logoOptions };
return { accounts, logoOptions };
}
export async function fetchInativosForUser(
userId: string
userId: string,
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
const [accountRows, logoOptions] = await Promise.all([
db
.select({
id: contas.id,
name: contas.name,
accountType: contas.accountType,
status: contas.status,
note: contas.note,
logo: contas.logo,
initialBalance: contas.initialBalance,
excludeFromBalance: contas.excludeFromBalance,
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
balanceMovements: sql<number>`
const [accountRows, logoOptions] = await Promise.all([
db
.select({
id: contas.id,
name: contas.name,
accountType: contas.accountType,
status: contas.status,
note: contas.note,
logo: contas.logo,
initialBalance: contas.initialBalance,
excludeFromBalance: contas.excludeFromBalance,
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
balanceMovements: sql<number>`
coalesce(
sum(
case
@@ -120,52 +120,52 @@ export async function fetchInativosForUser(
0
)
`,
})
.from(contas)
.leftJoin(
lancamentos,
and(
eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true)
)
)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(contas.userId, userId),
ilike(contas.status, "inativa"),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
)
)
.groupBy(
contas.id,
contas.name,
contas.accountType,
contas.status,
contas.note,
contas.logo,
contas.initialBalance,
contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome
),
loadLogoOptions(),
]);
})
.from(contas)
.leftJoin(
lancamentos,
and(
eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true),
),
)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(contas.userId, userId),
ilike(contas.status, "inativa"),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
),
)
.groupBy(
contas.id,
contas.name,
contas.accountType,
contas.status,
contas.note,
contas.logo,
contas.initialBalance,
contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome,
),
loadLogoOptions(),
]);
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
accountType: account.accountType,
status: account.status,
note: account.note,
logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0),
balance:
Number(account.initialBalance ?? 0) +
Number(account.balanceMovements ?? 0),
excludeFromBalance: account.excludeFromBalance,
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
}));
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
accountType: account.accountType,
status: account.status,
note: account.note,
logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0),
balance:
Number(account.initialBalance ?? 0) +
Number(account.balanceMovements ?? 0),
excludeFromBalance: account.excludeFromBalance,
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
}));
return { accounts, logoOptions };
return { accounts, logoOptions };
}

View File

@@ -3,12 +3,16 @@ import { getUserId } from "@/lib/auth/server";
import { fetchInativosForUser } from "../data";
export default async function InativosPage() {
const userId = await getUserId();
const { accounts, logoOptions } = await fetchInativosForUser(userId);
const userId = await getUserId();
const { accounts, logoOptions } = await fetchInativosForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<AccountsPage accounts={accounts} logoOptions={logoOptions} isInativos={true} />
</main>
);
return (
<main className="flex flex-col items-start gap-6">
<AccountsPage
accounts={accounts}
logoOptions={logoOptions}
isInativos={true}
/>
</main>
);
}

View File

@@ -1,25 +1,25 @@
import PageDescription from "@/components/page-description";
import { RiBankLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Contas | Opensheets",
title: "Contas | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiBankLine />}
title="Contas"
subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas,
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiBankLine />}
title="Contas"
subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas,
despesas e transações previstas. Use o seletor abaixo para navegar pelos
meses e visualizar as movimentações correspondentes."
/>
{children}
</section>
);
/>
{children}
</section>
);
}

View File

@@ -4,33 +4,33 @@ import { Skeleton } from "@/components/ui/skeleton";
* Loading state para a página de contas
*/
export default function ContasLoading() {
return (
<main className="flex flex-col gap-6">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
return (
<main className="flex flex-col gap-6">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Grid de contas */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<div className="flex gap-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
{/* Grid de contas */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<div className="flex gap-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -3,14 +3,12 @@ import { getUserId } from "@/lib/auth/server";
import { fetchAccountsForUser } from "./data";
export default async function Page() {
const userId = await getUserId();
const now = new Date();
const userId = await getUserId();
const { accounts, logoOptions } = await fetchAccountsForUser(userId);
const { accounts, logoOptions } = await fetchAccountsForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<AccountsPage accounts={accounts} logoOptions={logoOptions} />
</main>
);
return (
<main className="flex flex-col items-start gap-6">
<AccountsPage accounts={accounts} logoOptions={logoOptions} />
</main>
);
}

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiSecurePaymentLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Análise de Parcelas | Opensheets",
title: "Análise de Parcelas | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiSecurePaymentLine />}
title="Análise de Parcelas"
subtitle="Quanto você gastaria pagando suas despesas parceladas à vista?"
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiSecurePaymentLine />}
title="Análise de Parcelas"
subtitle="Quanto você gastaria pagando suas despesas parceladas à vista?"
/>
{children}
</section>
);
}

View File

@@ -3,12 +3,12 @@ import { getUser } from "@/lib/auth/server";
import { fetchInstallmentAnalysis } from "@/lib/dashboard/expenses/installment-analysis";
export default async function Page() {
const user = await getUser();
const data = await fetchInstallmentAnalysis(user.id);
const user = await getUser();
const data = await fetchInstallmentAnalysis(user.id);
return (
<main className="flex flex-col gap-4 pb-8">
<InstallmentAnalysisPage data={data} />
</main>
);
return (
<main className="flex flex-col gap-4 pb-8">
<InstallmentAnalysisPage data={data} />
</main>
);
}

View File

@@ -0,0 +1,25 @@
import { eq } from "drizzle-orm";
import { db, schema } from "@/lib/db";
export interface UserDashboardPreferences {
disableMagnetlines: boolean;
dashboardWidgets: string | null;
}
export async function fetchUserDashboardPreferences(
userId: string,
): Promise<UserDashboardPreferences> {
const result = await db
.select({
disableMagnetlines: schema.userPreferences.disableMagnetlines,
dashboardWidgets: schema.userPreferences.dashboardWidgets,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId))
.limit(1);
return {
disableMagnetlines: result[0]?.disableMagnetlines ?? false,
dashboardWidgets: result[0]?.dashboardWidgets ?? null,
};
}

View File

@@ -5,13 +5,13 @@ import { DashboardGridSkeleton } from "@/components/skeletons";
* Usa skeleton fiel ao layout final para evitar layout shift
*/
export default function DashboardLoading() {
return (
<main className="flex flex-col gap-6 px-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
return (
<main className="flex flex-col gap-6 px-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Dashboard content skeleton */}
<DashboardGridSkeleton />
</main>
);
{/* Dashboard content skeleton */}
<DashboardGridSkeleton />
</main>
);
}

View File

@@ -4,59 +4,50 @@ 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 { db, schema } from "@/lib/db";
import { parsePeriodParam } from "@/lib/utils/period";
import { eq } from "drizzle-orm";
import { fetchUserDashboardPreferences } from "./data";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [data, preferencesResult] = await Promise.all([
fetchDashboardData(user.id, selectedPeriod),
db
.select({
disableMagnetlines: schema.userPreferences.disableMagnetlines,
dashboardWidgets: schema.userPreferences.dashboardWidgets,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, user.id))
.limit(1),
]);
const [data, preferences] = await Promise.all([
fetchDashboardData(user.id, selectedPeriod),
fetchUserDashboardPreferences(user.id),
]);
const disableMagnetlines = preferencesResult[0]?.disableMagnetlines ?? false;
const dashboardWidgets = preferencesResult[0]?.dashboardWidgets ?? null;
const { disableMagnetlines, dashboardWidgets } = preferences;
return (
<main className="flex flex-col gap-4 px-6">
<DashboardWelcome
name={user.name}
disableMagnetlines={disableMagnetlines}
/>
<MonthNavigation />
<SectionCards metrics={data.metrics} />
<DashboardGridEditable
data={data}
period={selectedPeriod}
initialPreferences={dashboardWidgets}
/>
</main>
);
return (
<main className="flex flex-col gap-4 px-6">
<DashboardWelcome
name={user.name}
disableMagnetlines={disableMagnetlines}
/>
<MonthNavigation />
<SectionCards metrics={data.metrics} />
<DashboardGridEditable
data={data}
period={selectedPeriod}
initialPreferences={dashboardWidgets}
/>
</main>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,84 +7,84 @@ export type AIProvider = "openai" | "anthropic" | "google" | "openrouter";
* Metadados dos providers
*/
export const PROVIDERS = {
openai: {
id: "openai" as const,
name: "ChatGPT",
icon: "RiOpenaiLine",
},
anthropic: {
id: "anthropic" as const,
name: "Claude AI",
icon: "RiRobot2Line",
},
google: {
id: "google" as const,
name: "Gemini",
icon: "RiGoogleLine",
},
openrouter: {
id: "openrouter" as const,
name: "OpenRouter",
icon: "RiRouterLine",
},
openai: {
id: "openai" as const,
name: "ChatGPT",
icon: "RiOpenaiLine",
},
anthropic: {
id: "anthropic" as const,
name: "Claude AI",
icon: "RiRobot2Line",
},
google: {
id: "google" as const,
name: "Gemini",
icon: "RiGoogleLine",
},
openrouter: {
id: "openrouter" as const,
name: "OpenRouter",
icon: "RiRouterLine",
},
} as const;
/**
* Lista de modelos de IA disponíveis para análise de insights
*/
export const AVAILABLE_MODELS = [
// OpenAI Models - GPT-5.2 Family (Latest)
{ id: "gpt-5.2", name: "GPT-5.2", provider: "openai" as const },
{
id: "gpt-5.2-instant",
name: "GPT-5.2 Instant",
provider: "openai" as const,
},
{
id: "gpt-5.2-thinking",
name: "GPT-5.2 Thinking",
provider: "openai" as const,
},
// OpenAI Models - GPT-5.2 Family (Latest)
{ id: "gpt-5.2", name: "GPT-5.2", provider: "openai" as const },
{
id: "gpt-5.2-instant",
name: "GPT-5.2 Instant",
provider: "openai" as const,
},
{
id: "gpt-5.2-thinking",
name: "GPT-5.2 Thinking",
provider: "openai" as const,
},
// OpenAI Models - GPT-5 Family
{ id: "gpt-5", name: "GPT-5", provider: "openai" as const },
{ id: "gpt-5-instant", name: "GPT-5 Instant", provider: "openai" as const },
// OpenAI Models - GPT-5 Family
{ id: "gpt-5", name: "GPT-5", provider: "openai" as const },
{ id: "gpt-5-instant", name: "GPT-5 Instant", provider: "openai" as const },
// Anthropic Models - Claude 4.5
{
id: "claude-4.5-haiku",
name: "Claude 4.5 Haiku",
provider: "anthropic" as const,
},
{
id: "claude-4.5-sonnet",
name: "Claude 4.5 Sonnet",
provider: "anthropic" as const,
},
{
id: "claude-opus-4.1",
name: "Claude 4.1 Opus",
provider: "anthropic" as const,
},
// Anthropic Models - Claude 4.5
{
id: "claude-4.5-haiku",
name: "Claude 4.5 Haiku",
provider: "anthropic" as const,
},
{
id: "claude-4.5-sonnet",
name: "Claude 4.5 Sonnet",
provider: "anthropic" as const,
},
{
id: "claude-opus-4.1",
name: "Claude 4.1 Opus",
provider: "anthropic" as const,
},
// Google Models - Gemini 3 (Latest)
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash",
provider: "google" as const,
},
{
id: "gemini-3-pro-preview",
name: "Gemini 3 Pro",
provider: "google" as const,
},
// Google Models - Gemini 3 (Latest)
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash",
provider: "google" as const,
},
{
id: "gemini-3-pro-preview",
name: "Gemini 3 Pro",
provider: "google" as const,
},
// Google Models - Gemini 2.0
{
id: "gemini-2.0-flash",
name: "Gemini 2.0 Flash",
provider: "google" as const,
},
// Google Models - Gemini 2.0
{
id: "gemini-2.0-flash",
name: "Gemini 2.0 Flash",
provider: "google" as const,
},
] as const;
export const DEFAULT_MODEL = "gpt-5.2";

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiSparklingLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Insights | Opensheets",
title: "Insights | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiSparklingLine />}
title="Insights"
subtitle="Análise inteligente dos seus dados financeiros para identificar padrões, comportamentos e oportunidades de melhoria."
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiSparklingLine />}
title="Insights"
subtitle="Análise inteligente dos seus dados financeiros para identificar padrões, comportamentos e oportunidades de melhoria."
/>
{children}
</section>
);
}

View File

@@ -4,39 +4,36 @@ import { Skeleton } from "@/components/ui/skeleton";
* Loading state para a página de insights com IA
*/
export default function InsightsLoading() {
return (
<main className="flex flex-col gap-6">
<div className="space-y-6">
{/* Header */}
<div className="space-y-2">
<Skeleton className="h-10 w-64 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-96 rounded-2xl bg-foreground/10" />
</div>
return (
<main className="flex flex-col gap-6">
<div className="space-y-6">
{/* Header */}
<div className="space-y-2">
<Skeleton className="h-10 w-64 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-96 rounded-2xl bg-foreground/10" />
</div>
{/* Grid de insights */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border p-6 space-y-4"
>
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-3/4 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="size-8 rounded-full bg-foreground/10" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-3 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-3 w-2/3 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
{/* Grid de insights */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-3/4 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="size-8 rounded-full bg-foreground/10" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-3 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-3 w-2/3 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -5,27 +5,27 @@ import { parsePeriodParam } from "@/lib/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? value[0] ?? null : value;
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<InsightsPage period={selectedPeriod} />
</main>
);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<InsightsPage period={selectedPeriod} />
</main>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,336 +1,344 @@
"use server";
import {
categorias,
installmentAnticipations,
lancamentos,
pagadores,
type InstallmentAnticipation,
type Lancamento,
} from "@/db/schema";
import { handleActionError } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import {
generateAnticipationDescription,
generateAnticipationNote,
} from "@/lib/installments/anticipation-helpers";
import type {
CancelAnticipationInput,
CreateAnticipationInput,
EligibleInstallment,
InstallmentAnticipationWithRelations,
} from "@/lib/installments/anticipation-types";
import { uuidSchema } from "@/lib/schemas/common";
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
categorias,
installmentAnticipations,
lancamentos,
pagadores,
} from "@/db/schema";
import { handleActionError } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
generateAnticipationDescription,
generateAnticipationNote,
} from "@/lib/installments/anticipation-helpers";
import type {
CancelAnticipationInput,
CreateAnticipationInput,
EligibleInstallment,
InstallmentAnticipationWithRelations,
} from "@/lib/installments/anticipation-types";
import { uuidSchema } from "@/lib/schemas/common";
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
/**
* Schema de validação para criar antecipação
*/
const createAnticipationSchema = z.object({
seriesId: uuidSchema("Série"),
installmentIds: z
.array(uuidSchema("Parcela"))
.min(1, "Selecione pelo menos uma parcela para antecipar."),
anticipationPeriod: z
.string()
.trim()
.regex(/^(\d{4})-(\d{2})$/, {
message: "Selecione um período válido.",
}),
discount: z.coerce
.number()
.min(0, "Informe um desconto maior ou igual a zero.")
.optional()
.default(0),
pagadorId: uuidSchema("Pagador").optional(),
categoriaId: uuidSchema("Categoria").optional(),
note: z.string().trim().optional(),
seriesId: uuidSchema("Série"),
installmentIds: z
.array(uuidSchema("Parcela"))
.min(1, "Selecione pelo menos uma parcela para antecipar."),
anticipationPeriod: z
.string()
.trim()
.regex(/^(\d{4})-(\d{2})$/, {
message: "Selecione um período válido.",
}),
discount: z.coerce
.number()
.min(0, "Informe um desconto maior ou igual a zero.")
.optional()
.default(0),
pagadorId: uuidSchema("Pagador").optional(),
categoriaId: uuidSchema("Categoria").optional(),
note: z.string().trim().optional(),
});
/**
* Schema de validação para cancelar antecipação
*/
const cancelAnticipationSchema = z.object({
anticipationId: uuidSchema("Antecipação"),
anticipationId: uuidSchema("Antecipação"),
});
/**
* Busca parcelas elegíveis para antecipação de uma série
*/
export async function getEligibleInstallmentsAction(
seriesId: string
seriesId: string,
): Promise<ActionResult<EligibleInstallment[]>> {
try {
const user = await getUser();
try {
const user = await getUser();
// Validar seriesId
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
// Validar seriesId
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
// Buscar todas as parcelas da série que estão elegíveis
const rows = await db.query.lancamentos.findMany({
where: and(
eq(lancamentos.seriesId, validatedSeriesId),
eq(lancamentos.userId, user.id),
eq(lancamentos.condition, "Parcelado"),
// Apenas parcelas não pagas e não antecipadas
or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)),
eq(lancamentos.isAnticipated, false)
),
orderBy: [asc(lancamentos.currentInstallment)],
columns: {
id: true,
name: true,
amount: true,
period: true,
purchaseDate: true,
dueDate: true,
currentInstallment: true,
installmentCount: true,
paymentMethod: true,
categoriaId: true,
pagadorId: true,
},
});
// Buscar todas as parcelas da série que estão elegíveis
const rows = await db.query.lancamentos.findMany({
where: and(
eq(lancamentos.seriesId, validatedSeriesId),
eq(lancamentos.userId, user.id),
eq(lancamentos.condition, "Parcelado"),
// Apenas parcelas não pagas e não antecipadas
or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)),
eq(lancamentos.isAnticipated, false),
),
orderBy: [asc(lancamentos.currentInstallment)],
columns: {
id: true,
name: true,
amount: true,
period: true,
purchaseDate: true,
dueDate: true,
currentInstallment: true,
installmentCount: true,
paymentMethod: true,
categoriaId: true,
pagadorId: true,
},
});
const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({
id: row.id,
name: row.name,
amount: row.amount,
period: row.period,
purchaseDate: row.purchaseDate,
dueDate: row.dueDate,
currentInstallment: row.currentInstallment,
installmentCount: row.installmentCount,
paymentMethod: row.paymentMethod,
categoriaId: row.categoriaId,
pagadorId: row.pagadorId,
}));
const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({
id: row.id,
name: row.name,
amount: row.amount,
period: row.period,
purchaseDate: row.purchaseDate,
dueDate: row.dueDate,
currentInstallment: row.currentInstallment,
installmentCount: row.installmentCount,
paymentMethod: row.paymentMethod,
categoriaId: row.categoriaId,
pagadorId: row.pagadorId,
}));
return {
success: true,
data: eligibleInstallments,
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
data: eligibleInstallments,
};
} catch (error) {
return handleActionError(error);
}
}
/**
* Cria uma antecipação de parcelas
*/
export async function createInstallmentAnticipationAction(
input: CreateAnticipationInput
input: CreateAnticipationInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createAnticipationSchema.parse(input);
try {
const user = await getUser();
const data = createAnticipationSchema.parse(input);
// 1. Validar parcelas selecionadas
const installments = await db.query.lancamentos.findMany({
where: and(
inArray(lancamentos.id, data.installmentIds),
eq(lancamentos.userId, user.id),
eq(lancamentos.seriesId, data.seriesId),
or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)),
eq(lancamentos.isAnticipated, false)
),
});
// 1. Validar parcelas selecionadas
const installments = await db.query.lancamentos.findMany({
where: and(
inArray(lancamentos.id, data.installmentIds),
eq(lancamentos.userId, user.id),
eq(lancamentos.seriesId, data.seriesId),
or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)),
eq(lancamentos.isAnticipated, false),
),
});
if (installments.length !== data.installmentIds.length) {
return {
success: false,
error: "Algumas parcelas não estão elegíveis para antecipação.",
};
}
if (installments.length !== data.installmentIds.length) {
return {
success: false,
error: "Algumas parcelas não estão elegíveis para antecipação.",
};
}
if (installments.length === 0) {
return {
success: false,
error: "Nenhuma parcela selecionada para antecipação.",
};
}
if (installments.length === 0) {
return {
success: false,
error: "Nenhuma parcela selecionada para antecipação.",
};
}
// 2. Calcular valor total
const totalAmountCents = installments.reduce(
(sum, inst) => sum + Number(inst.amount) * 100,
0
);
const totalAmount = totalAmountCents / 100;
const totalAmountAbs = Math.abs(totalAmount);
// 2. Calcular valor total
const totalAmountCents = installments.reduce(
(sum, inst) => sum + Number(inst.amount) * 100,
0,
);
const totalAmount = totalAmountCents / 100;
const totalAmountAbs = Math.abs(totalAmount);
// 2.1. Aplicar desconto
const discount = data.discount || 0;
// 2.1. Aplicar desconto
const discount = data.discount || 0;
// 2.2. Validar que o desconto não é maior que o valor absoluto total
if (discount > totalAmountAbs) {
return {
success: false,
error: "O desconto não pode ser maior que o valor total das parcelas.",
};
}
// 2.2. Validar que o desconto não é maior que o valor absoluto total
if (discount > totalAmountAbs) {
return {
success: false,
error: "O desconto não pode ser maior que o valor total das parcelas.",
};
}
// 2.3. Calcular valor final (se negativo, soma o desconto para reduzir a despesa)
const finalAmount = totalAmount < 0
? totalAmount + discount // Despesa: -1000 + 20 = -980
: totalAmount - discount; // Receita: 1000 - 20 = 980
// 2.3. Calcular valor final (se negativo, soma o desconto para reduzir a despesa)
const finalAmount =
totalAmount < 0
? totalAmount + discount // Despesa: -1000 + 20 = -980
: totalAmount - discount; // Receita: 1000 - 20 = 980
// 3. Pegar dados da primeira parcela para referência
const firstInstallment = installments[0]!;
// 3. Pegar dados da primeira parcela para referência
const firstInstallment = installments[0]!;
// 4. Criar lançamento e antecipação em transação
await db.transaction(async (tx) => {
// 4.1. Criar o lançamento de antecipação (com desconto aplicado)
const [newLancamento] = await tx
.insert(lancamentos)
.values({
name: generateAnticipationDescription(
firstInstallment.name,
installments.length
),
condition: "À vista",
transactionType: firstInstallment.transactionType,
paymentMethod: firstInstallment.paymentMethod,
amount: formatDecimalForDbRequired(finalAmount),
purchaseDate: new Date(),
period: data.anticipationPeriod,
dueDate: null,
isSettled: false,
pagadorId: data.pagadorId ?? firstInstallment.pagadorId,
categoriaId: data.categoriaId ?? firstInstallment.categoriaId,
cartaoId: firstInstallment.cartaoId,
contaId: firstInstallment.contaId,
note:
data.note ||
generateAnticipationNote(
installments.map((inst) => ({
id: inst.id,
name: inst.name,
amount: inst.amount,
period: inst.period,
purchaseDate: inst.purchaseDate,
dueDate: inst.dueDate,
currentInstallment: inst.currentInstallment,
installmentCount: inst.installmentCount,
paymentMethod: inst.paymentMethod,
categoriaId: inst.categoriaId,
pagadorId: inst.pagadorId,
}))
),
userId: user.id,
installmentCount: null,
currentInstallment: null,
recurrenceCount: null,
isAnticipated: false,
isDivided: false,
seriesId: null,
transferId: null,
anticipationId: null,
boletoPaymentDate: null,
})
.returning();
// 4. Criar lançamento e antecipação em transação
await db.transaction(async (tx) => {
// 4.1. Criar o lançamento de antecipação (com desconto aplicado)
const [newLancamento] = await tx
.insert(lancamentos)
.values({
name: generateAnticipationDescription(
firstInstallment.name,
installments.length,
),
condition: "À vista",
transactionType: firstInstallment.transactionType,
paymentMethod: firstInstallment.paymentMethod,
amount: formatDecimalForDbRequired(finalAmount),
purchaseDate: new Date(),
period: data.anticipationPeriod,
dueDate: null,
isSettled: false,
pagadorId: data.pagadorId ?? firstInstallment.pagadorId,
categoriaId: data.categoriaId ?? firstInstallment.categoriaId,
cartaoId: firstInstallment.cartaoId,
contaId: firstInstallment.contaId,
note:
data.note ||
generateAnticipationNote(
installments.map((inst) => ({
id: inst.id,
name: inst.name,
amount: inst.amount,
period: inst.period,
purchaseDate: inst.purchaseDate,
dueDate: inst.dueDate,
currentInstallment: inst.currentInstallment,
installmentCount: inst.installmentCount,
paymentMethod: inst.paymentMethod,
categoriaId: inst.categoriaId,
pagadorId: inst.pagadorId,
})),
),
userId: user.id,
installmentCount: null,
currentInstallment: null,
recurrenceCount: null,
isAnticipated: false,
isDivided: false,
seriesId: null,
transferId: null,
anticipationId: null,
boletoPaymentDate: null,
})
.returning();
// 4.2. Criar registro de antecipação
const [anticipation] = await tx
.insert(installmentAnticipations)
.values({
seriesId: data.seriesId,
anticipationPeriod: data.anticipationPeriod,
anticipationDate: new Date(),
anticipatedInstallmentIds: data.installmentIds,
totalAmount: formatDecimalForDbRequired(totalAmount),
installmentCount: installments.length,
discount: formatDecimalForDbRequired(discount),
lancamentoId: newLancamento.id,
pagadorId: data.pagadorId ?? firstInstallment.pagadorId,
categoriaId: data.categoriaId ?? firstInstallment.categoriaId,
note: data.note || null,
userId: user.id,
})
.returning();
// 4.2. Criar registro de antecipação
const [anticipation] = await tx
.insert(installmentAnticipations)
.values({
seriesId: data.seriesId,
anticipationPeriod: data.anticipationPeriod,
anticipationDate: new Date(),
anticipatedInstallmentIds: data.installmentIds,
totalAmount: formatDecimalForDbRequired(totalAmount),
installmentCount: installments.length,
discount: formatDecimalForDbRequired(discount),
lancamentoId: newLancamento.id,
pagadorId: data.pagadorId ?? firstInstallment.pagadorId,
categoriaId: data.categoriaId ?? firstInstallment.categoriaId,
note: data.note || null,
userId: user.id,
})
.returning();
// 4.3. Marcar parcelas como antecipadas e zerar seus valores
await tx
.update(lancamentos)
.set({
isAnticipated: true,
anticipationId: anticipation.id,
amount: "0", // Zera o valor para não contar em dobro
})
.where(inArray(lancamentos.id, data.installmentIds));
});
// 4.3. Marcar parcelas como antecipadas e zerar seus valores
await tx
.update(lancamentos)
.set({
isAnticipated: true,
anticipationId: anticipation.id,
amount: "0", // Zera o valor para não contar em dobro
})
.where(inArray(lancamentos.id, data.installmentIds));
});
revalidatePath("/lancamentos");
revalidatePath("/dashboard");
revalidatePath("/lancamentos");
revalidatePath("/dashboard");
return {
success: true,
message: `${installments.length} ${
installments.length === 1 ? "parcela antecipada" : "parcelas antecipadas"
} com sucesso!`,
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
message: `${installments.length} ${
installments.length === 1
? "parcela antecipada"
: "parcelas antecipadas"
} com sucesso!`,
};
} catch (error) {
return handleActionError(error);
}
}
/**
* Busca histórico de antecipações de uma série
*/
export async function getInstallmentAnticipationsAction(
seriesId: string
seriesId: string,
): Promise<ActionResult<InstallmentAnticipationWithRelations[]>> {
try {
const user = await getUser();
try {
const user = await getUser();
// Validar seriesId
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
// Validar seriesId
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
// Usar query builder ao invés de db.query para evitar problemas de tipagem
const anticipations = await db
.select({
id: installmentAnticipations.id,
seriesId: installmentAnticipations.seriesId,
anticipationPeriod: installmentAnticipations.anticipationPeriod,
anticipationDate: installmentAnticipations.anticipationDate,
anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds,
totalAmount: installmentAnticipations.totalAmount,
installmentCount: installmentAnticipations.installmentCount,
discount: installmentAnticipations.discount,
lancamentoId: installmentAnticipations.lancamentoId,
pagadorId: installmentAnticipations.pagadorId,
categoriaId: installmentAnticipations.categoriaId,
note: installmentAnticipations.note,
userId: installmentAnticipations.userId,
createdAt: installmentAnticipations.createdAt,
// Joins
lancamento: lancamentos,
pagador: pagadores,
categoria: categorias,
})
.from(installmentAnticipations)
.leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id))
.leftJoin(pagadores, eq(installmentAnticipations.pagadorId, pagadores.id))
.leftJoin(categorias, eq(installmentAnticipations.categoriaId, categorias.id))
.where(
and(
eq(installmentAnticipations.seriesId, validatedSeriesId),
eq(installmentAnticipations.userId, user.id)
)
)
.orderBy(desc(installmentAnticipations.createdAt));
// Usar query builder ao invés de db.query para evitar problemas de tipagem
const anticipations = await db
.select({
id: installmentAnticipations.id,
seriesId: installmentAnticipations.seriesId,
anticipationPeriod: installmentAnticipations.anticipationPeriod,
anticipationDate: installmentAnticipations.anticipationDate,
anticipatedInstallmentIds:
installmentAnticipations.anticipatedInstallmentIds,
totalAmount: installmentAnticipations.totalAmount,
installmentCount: installmentAnticipations.installmentCount,
discount: installmentAnticipations.discount,
lancamentoId: installmentAnticipations.lancamentoId,
pagadorId: installmentAnticipations.pagadorId,
categoriaId: installmentAnticipations.categoriaId,
note: installmentAnticipations.note,
userId: installmentAnticipations.userId,
createdAt: installmentAnticipations.createdAt,
// Joins
lancamento: lancamentos,
pagador: pagadores,
categoria: categorias,
})
.from(installmentAnticipations)
.leftJoin(
lancamentos,
eq(installmentAnticipations.lancamentoId, lancamentos.id),
)
.leftJoin(pagadores, eq(installmentAnticipations.pagadorId, pagadores.id))
.leftJoin(
categorias,
eq(installmentAnticipations.categoriaId, categorias.id),
)
.where(
and(
eq(installmentAnticipations.seriesId, validatedSeriesId),
eq(installmentAnticipations.userId, user.id),
),
)
.orderBy(desc(installmentAnticipations.createdAt));
return {
success: true,
data: anticipations,
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
data: anticipations,
};
} catch (error) {
return handleActionError(error);
}
}
/**
@@ -338,134 +346,138 @@ export async function getInstallmentAnticipationsAction(
* Remove o lançamento de antecipação e restaura as parcelas originais
*/
export async function cancelInstallmentAnticipationAction(
input: CancelAnticipationInput
input: CancelAnticipationInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = cancelAnticipationSchema.parse(input);
try {
const user = await getUser();
const data = cancelAnticipationSchema.parse(input);
await db.transaction(async (tx) => {
// 1. Buscar antecipação usando query builder
const anticipationRows = await tx
.select({
id: installmentAnticipations.id,
seriesId: installmentAnticipations.seriesId,
anticipationPeriod: installmentAnticipations.anticipationPeriod,
anticipationDate: installmentAnticipations.anticipationDate,
anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds,
totalAmount: installmentAnticipations.totalAmount,
installmentCount: installmentAnticipations.installmentCount,
discount: installmentAnticipations.discount,
lancamentoId: installmentAnticipations.lancamentoId,
pagadorId: installmentAnticipations.pagadorId,
categoriaId: installmentAnticipations.categoriaId,
note: installmentAnticipations.note,
userId: installmentAnticipations.userId,
createdAt: installmentAnticipations.createdAt,
lancamento: lancamentos,
})
.from(installmentAnticipations)
.leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id))
.where(
and(
eq(installmentAnticipations.id, data.anticipationId),
eq(installmentAnticipations.userId, user.id)
)
)
.limit(1);
await db.transaction(async (tx) => {
// 1. Buscar antecipação usando query builder
const anticipationRows = await tx
.select({
id: installmentAnticipations.id,
seriesId: installmentAnticipations.seriesId,
anticipationPeriod: installmentAnticipations.anticipationPeriod,
anticipationDate: installmentAnticipations.anticipationDate,
anticipatedInstallmentIds:
installmentAnticipations.anticipatedInstallmentIds,
totalAmount: installmentAnticipations.totalAmount,
installmentCount: installmentAnticipations.installmentCount,
discount: installmentAnticipations.discount,
lancamentoId: installmentAnticipations.lancamentoId,
pagadorId: installmentAnticipations.pagadorId,
categoriaId: installmentAnticipations.categoriaId,
note: installmentAnticipations.note,
userId: installmentAnticipations.userId,
createdAt: installmentAnticipations.createdAt,
lancamento: lancamentos,
})
.from(installmentAnticipations)
.leftJoin(
lancamentos,
eq(installmentAnticipations.lancamentoId, lancamentos.id),
)
.where(
and(
eq(installmentAnticipations.id, data.anticipationId),
eq(installmentAnticipations.userId, user.id),
),
)
.limit(1);
const anticipation = anticipationRows[0];
const anticipation = anticipationRows[0];
if (!anticipation) {
throw new Error("Antecipação não encontrada.");
}
if (!anticipation) {
throw new Error("Antecipação não encontrada.");
}
// 2. Verificar se o lançamento já foi pago
if (anticipation.lancamento?.isSettled === true) {
throw new Error(
"Não é possível cancelar uma antecipação já paga. Remova o pagamento primeiro."
);
}
// 2. Verificar se o lançamento já foi pago
if (anticipation.lancamento?.isSettled === true) {
throw new Error(
"Não é possível cancelar uma antecipação já paga. Remova o pagamento primeiro.",
);
}
// 3. Calcular valor original por parcela (totalAmount sem desconto / quantidade)
const originalTotalAmount = Number(anticipation.totalAmount);
const originalValuePerInstallment =
originalTotalAmount / anticipation.installmentCount;
// 3. Calcular valor original por parcela (totalAmount sem desconto / quantidade)
const originalTotalAmount = Number(anticipation.totalAmount);
const originalValuePerInstallment =
originalTotalAmount / anticipation.installmentCount;
// 4. Remover flag de antecipação e restaurar valores das parcelas
await tx
.update(lancamentos)
.set({
isAnticipated: false,
anticipationId: null,
amount: formatDecimalForDbRequired(originalValuePerInstallment),
})
.where(
inArray(
lancamentos.id,
anticipation.anticipatedInstallmentIds as string[]
)
);
// 4. Remover flag de antecipação e restaurar valores das parcelas
await tx
.update(lancamentos)
.set({
isAnticipated: false,
anticipationId: null,
amount: formatDecimalForDbRequired(originalValuePerInstallment),
})
.where(
inArray(
lancamentos.id,
anticipation.anticipatedInstallmentIds as string[],
),
);
// 5. Deletar lançamento de antecipação
await tx
.delete(lancamentos)
.where(eq(lancamentos.id, anticipation.lancamentoId));
// 5. Deletar lançamento de antecipação
await tx
.delete(lancamentos)
.where(eq(lancamentos.id, anticipation.lancamentoId));
// 6. Deletar registro de antecipação
await tx
.delete(installmentAnticipations)
.where(eq(installmentAnticipations.id, data.anticipationId));
});
// 6. Deletar registro de antecipação
await tx
.delete(installmentAnticipations)
.where(eq(installmentAnticipations.id, data.anticipationId));
});
revalidatePath("/lancamentos");
revalidatePath("/dashboard");
revalidatePath("/lancamentos");
revalidatePath("/dashboard");
return {
success: true,
message: "Antecipação cancelada com sucesso!",
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
message: "Antecipação cancelada com sucesso!",
};
} catch (error) {
return handleActionError(error);
}
}
/**
* Busca detalhes de uma antecipação específica
*/
export async function getAnticipationDetailsAction(
anticipationId: string
anticipationId: string,
): Promise<ActionResult<InstallmentAnticipationWithRelations>> {
try {
const user = await getUser();
try {
const user = await getUser();
// Validar anticipationId
const validatedId = uuidSchema("Antecipação").parse(anticipationId);
// Validar anticipationId
const validatedId = uuidSchema("Antecipação").parse(anticipationId);
const anticipation = await db.query.installmentAnticipations.findFirst({
where: and(
eq(installmentAnticipations.id, validatedId),
eq(installmentAnticipations.userId, user.id)
),
with: {
lancamento: true,
pagador: true,
categoria: true,
},
});
const anticipation = await db.query.installmentAnticipations.findFirst({
where: and(
eq(installmentAnticipations.id, validatedId),
eq(installmentAnticipations.userId, user.id),
),
with: {
lancamento: true,
pagador: true,
categoria: true,
},
});
if (!anticipation) {
return {
success: false,
error: "Antecipação não encontrada.",
};
}
if (!anticipation) {
return {
success: false,
error: "Antecipação não encontrada.",
};
}
return {
success: true,
data: anticipation,
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
data: anticipation,
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -1,41 +1,47 @@
import { lancamentos, contas, pagadores, cartoes, categorias } from "@/db/schema";
import { and, desc, eq, isNull, ne, or, type SQL } from "drizzle-orm";
import {
cartoes,
categorias,
contas,
lancamentos,
pagadores,
} from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { and, desc, eq, isNull, ne, or, type SQL } from "drizzle-orm";
export async function fetchLancamentos(filters: SQL[]) {
const lancamentoRows = await db
.select({
lancamento: lancamentos,
pagador: pagadores,
conta: contas,
cartao: cartoes,
categoria: categorias,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
...filters,
// Excluir saldos iniciais de contas que têm excludeInitialBalanceFromIncome = true
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
)
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
const lancamentoRows = await db
.select({
lancamento: lancamentos,
pagador: pagadores,
conta: contas,
cartao: cartoes,
categoria: categorias,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(
and(
...filters,
// Excluir saldos iniciais de contas que têm excludeInitialBalanceFromIncome = true
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
// Transformar resultado para o formato esperado
return lancamentoRows.map((row) => ({
...row.lancamento,
pagador: row.pagador,
conta: row.conta,
cartao: row.cartao,
categoria: row.categoria,
}));
// Transformar resultado para o formato esperado
return lancamentoRows.map((row) => ({
...row.lancamento,
pagador: row.pagador,
conta: row.conta,
cartao: row.cartao,
categoria: row.categoria,
}));
}

View File

@@ -1,25 +1,25 @@
import PageDescription from "@/components/page-description";
import { RiArrowLeftRightLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Lançamentos | Opensheets",
title: "Lançamentos | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiArrowLeftRightLine />}
title="Lançamentos"
subtitle="Acompanhe todos os lançamentos financeiros do mês selecionado incluindo
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiArrowLeftRightLine />}
title="Lançamentos"
subtitle="Acompanhe todos os lançamentos financeiros do mês selecionado incluindo
receitas, despesas e transações previstas. Use o seletor abaixo para
navegar pelos meses e visualizar as movimentações correspondentes."
/>
{children}
</section>
);
/>
{children}
</section>
);
}

View File

@@ -1,6 +1,6 @@
import {
FilterSkeleton,
TransactionsTableSkeleton,
FilterSkeleton,
TransactionsTableSkeleton,
} from "@/components/skeletons";
import { Skeleton } from "@/components/ui/skeleton";
@@ -9,24 +9,24 @@ import { Skeleton } from "@/components/ui/skeleton";
* Mantém o mesmo layout da página final
*/
export default function LancamentosLoading() {
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
<div className="space-y-6">
{/* Header com título e botão */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
<div className="space-y-6">
{/* Header com título e botão */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Filtros */}
<FilterSkeleton />
{/* Filtros */}
<FilterSkeleton />
{/* Tabela */}
<TransactionsTableSkeleton />
</div>
</main>
);
{/* Tabela */}
<TransactionsTableSkeleton />
</div>
</main>
);
}

View File

@@ -1,86 +1,86 @@
import MonthNavigation from "@/components/month-picker/month-navigation";
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { parsePeriodParam } from "@/lib/utils/period";
import { fetchLancamentos } from "./data";
import { getRecentEstablishmentsAction } from "./actions";
import { fetchLancamentos } from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
searchParams?: PageSearchParams;
searchParams?: PageSearchParams;
};
export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw);
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const filterSources = await fetchLancamentoFilterSources(userId);
const filterSources = await fetchLancamentoFilterSources(userId);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
});
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
});
const lancamentoRows = await fetchLancamentos(filters);
const lancamentosData = mapLancamentosData(lancamentoRows);
const lancamentoRows = await fetchLancamentos(filters);
const lancamentosData = mapLancamentosData(lancamentoRows);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
const estabelecimentos = await getRecentEstablishmentsAction();
const estabelecimentos = await getRecentEstablishmentsAction();
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<LancamentosPage
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
/>
</main>
);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<LancamentosPage
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
/>
</main>
);
}

View File

@@ -10,66 +10,66 @@ import { parsePeriodParam } from "@/lib/utils/period";
import { fetchPendingInboxCount } from "./pre-lancamentos/data";
export default async function DashboardLayout({
children,
searchParams,
children,
searchParams,
}: Readonly<{
children: React.ReactNode;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
children: React.ReactNode;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}>) {
const session = await getUserSession();
const pagadoresList = await fetchPagadoresWithAccess(session.user.id);
const session = await getUserSession();
const pagadoresList = await fetchPagadoresWithAccess(session.user.id);
// Encontrar o pagador admin do usuário
const adminPagador = pagadoresList.find(
(p) => p.role === PAGADOR_ROLE_ADMIN && p.userId === session.user.id,
);
// Encontrar o pagador admin do usuário
const adminPagador = pagadoresList.find(
(p) => p.role === PAGADOR_ROLE_ADMIN && p.userId === session.user.id,
);
// Buscar notificações para o período atual
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = resolvedSearchParams?.periodo;
const singlePeriodoParam =
typeof periodoParam === "string"
? periodoParam
: Array.isArray(periodoParam)
? periodoParam[0]
: null;
const { period: currentPeriod } = parsePeriodParam(
singlePeriodoParam ?? null,
);
const notificationsSnapshot = await fetchDashboardNotifications(
session.user.id,
currentPeriod,
);
// Buscar notificações para o período atual
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = resolvedSearchParams?.periodo;
const singlePeriodoParam =
typeof periodoParam === "string"
? periodoParam
: Array.isArray(periodoParam)
? periodoParam[0]
: null;
const { period: currentPeriod } = parsePeriodParam(
singlePeriodoParam ?? null,
);
const notificationsSnapshot = await fetchDashboardNotifications(
session.user.id,
currentPeriod,
);
// Buscar contagem de pré-lançamentos pendentes
const preLancamentosCount = await fetchPendingInboxCount(session.user.id);
// Buscar contagem de pré-lançamentos pendentes
const preLancamentosCount = await fetchPendingInboxCount(session.user.id);
return (
<PrivacyProvider>
<SidebarProvider>
<AppSidebar
user={{ ...session.user, image: session.user.image ?? null }}
pagadorAvatarUrl={adminPagador?.avatarUrl ?? null}
pagadores={pagadoresList.map((item) => ({
id: item.id,
name: item.name,
avatarUrl: item.avatarUrl,
canEdit: item.canEdit,
}))}
preLancamentosCount={preLancamentosCount}
variant="sidebar"
/>
<SidebarInset>
<SiteHeader notificationsSnapshot={notificationsSnapshot} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6">
{children}
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
</PrivacyProvider>
);
return (
<PrivacyProvider>
<SidebarProvider>
<AppSidebar
user={{ ...session.user, image: session.user.image ?? null }}
pagadorAvatarUrl={adminPagador?.avatarUrl ?? null}
pagadores={pagadoresList.map((item) => ({
id: item.id,
name: item.name,
avatarUrl: item.avatarUrl,
canEdit: item.canEdit,
}))}
preLancamentosCount={preLancamentosCount}
variant="sidebar"
/>
<SidebarInset>
<SiteHeader notificationsSnapshot={notificationsSnapshot} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6">
{children}
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
</PrivacyProvider>
);
}

View File

@@ -1,46 +1,46 @@
"use server";
import { categorias, orcamentos } from "@/db/schema";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { periodSchema, uuidSchema } from "@/lib/schemas/common";
import {
formatDecimalForDbRequired,
normalizeDecimalInput,
} from "@/lib/utils/currency";
import { and, eq, ne } from "drizzle-orm";
import { z } from "zod";
import { categorias, orcamentos } from "@/db/schema";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { periodSchema, uuidSchema } from "@/lib/schemas/common";
import {
formatDecimalForDbRequired,
normalizeDecimalInput,
} from "@/lib/utils/currency";
const budgetBaseSchema = z.object({
categoriaId: uuidSchema("Categoria"),
period: periodSchema,
amount: z
.string({ message: "Informe o valor limite." })
.trim()
.min(1, "Informe o valor limite.")
.transform((value) => normalizeDecimalInput(value))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um valor limite válido."
)
.transform((value) => Number.parseFloat(value))
.refine(
(value) => value >= 0,
"O valor limite deve ser maior ou igual a zero."
),
categoriaId: uuidSchema("Categoria"),
period: periodSchema,
amount: z
.string({ message: "Informe o valor limite." })
.trim()
.min(1, "Informe o valor limite.")
.transform((value) => normalizeDecimalInput(value))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um valor limite válido.",
)
.transform((value) => Number.parseFloat(value))
.refine(
(value) => value >= 0,
"O valor limite deve ser maior ou igual a zero.",
),
});
const createBudgetSchema = budgetBaseSchema;
const updateBudgetSchema = budgetBaseSchema.extend({
id: uuidSchema("Orçamento"),
id: uuidSchema("Orçamento"),
});
const deleteBudgetSchema = z.object({
id: uuidSchema("Orçamento"),
id: uuidSchema("Orçamento"),
});
type BudgetCreateInput = z.infer<typeof createBudgetSchema>;
@@ -48,229 +48,227 @@ type BudgetUpdateInput = z.infer<typeof updateBudgetSchema>;
type BudgetDeleteInput = z.infer<typeof deleteBudgetSchema>;
const ensureCategory = async (userId: string, categoriaId: string) => {
const category = await db.query.categorias.findFirst({
columns: {
id: true,
type: true,
},
where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)),
});
const category = await db.query.categorias.findFirst({
columns: {
id: true,
type: true,
},
where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)),
});
if (!category) {
throw new Error("Categoria não encontrada.");
}
if (!category) {
throw new Error("Categoria não encontrada.");
}
if (category.type !== "despesa") {
throw new Error("Selecione uma categoria de despesa.");
}
if (category.type !== "despesa") {
throw new Error("Selecione uma categoria de despesa.");
}
};
export async function createBudgetAction(
input: BudgetCreateInput
input: BudgetCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createBudgetSchema.parse(input);
try {
const user = await getUser();
const data = createBudgetSchema.parse(input);
await ensureCategory(user.id, data.categoriaId);
await ensureCategory(user.id, data.categoriaId);
const duplicateConditions = [
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
eq(orcamentos.categoriaId, data.categoriaId),
] as const;
const duplicateConditions = [
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
eq(orcamentos.categoriaId, data.categoriaId),
] as const;
const duplicate = await db.query.orcamentos.findFirst({
columns: { id: true },
where: and(...duplicateConditions),
});
const duplicate = await db.query.orcamentos.findFirst({
columns: { id: true },
where: and(...duplicateConditions),
});
if (duplicate) {
return {
success: false,
error:
"Já existe um orçamento para esta categoria no período selecionado.",
};
}
if (duplicate) {
return {
success: false,
error:
"Já existe um orçamento para esta categoria no período selecionado.",
};
}
await db.insert(orcamentos).values({
amount: formatDecimalForDbRequired(data.amount),
period: data.period,
userId: user.id,
categoriaId: data.categoriaId,
});
await db.insert(orcamentos).values({
amount: formatDecimalForDbRequired(data.amount),
period: data.period,
userId: user.id,
categoriaId: data.categoriaId,
});
revalidateForEntity("orcamentos");
revalidateForEntity("orcamentos");
return { success: true, message: "Orçamento criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Orçamento criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateBudgetAction(
input: BudgetUpdateInput
input: BudgetUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateBudgetSchema.parse(input);
try {
const user = await getUser();
const data = updateBudgetSchema.parse(input);
await ensureCategory(user.id, data.categoriaId);
await ensureCategory(user.id, data.categoriaId);
const duplicateConditions = [
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
eq(orcamentos.categoriaId, data.categoriaId),
ne(orcamentos.id, data.id),
] as const;
const duplicateConditions = [
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
eq(orcamentos.categoriaId, data.categoriaId),
ne(orcamentos.id, data.id),
] as const;
const duplicate = await db.query.orcamentos.findFirst({
columns: { id: true },
where: and(...duplicateConditions),
});
const duplicate = await db.query.orcamentos.findFirst({
columns: { id: true },
where: and(...duplicateConditions),
});
if (duplicate) {
return {
success: false,
error:
"Já existe um orçamento para esta categoria no período selecionado.",
};
}
if (duplicate) {
return {
success: false,
error:
"Já existe um orçamento para esta categoria no período selecionado.",
};
}
const [updated] = await db
.update(orcamentos)
.set({
amount: formatDecimalForDbRequired(data.amount),
period: data.period,
categoriaId: data.categoriaId,
})
.where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id)))
.returning({ id: orcamentos.id });
const [updated] = await db
.update(orcamentos)
.set({
amount: formatDecimalForDbRequired(data.amount),
period: data.period,
categoriaId: data.categoriaId,
})
.where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id)))
.returning({ id: orcamentos.id });
if (!updated) {
return {
success: false,
error: "Orçamento não encontrado.",
};
}
if (!updated) {
return {
success: false,
error: "Orçamento não encontrado.",
};
}
revalidateForEntity("orcamentos");
revalidateForEntity("orcamentos");
return { success: true, message: "Orçamento atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Orçamento atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteBudgetAction(
input: BudgetDeleteInput
input: BudgetDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteBudgetSchema.parse(input);
try {
const user = await getUser();
const data = deleteBudgetSchema.parse(input);
const [deleted] = await db
.delete(orcamentos)
.where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id)))
.returning({ id: orcamentos.id });
const [deleted] = await db
.delete(orcamentos)
.where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id)))
.returning({ id: orcamentos.id });
if (!deleted) {
return {
success: false,
error: "Orçamento não encontrado.",
};
}
if (!deleted) {
return {
success: false,
error: "Orçamento não encontrado.",
};
}
revalidateForEntity("orcamentos");
revalidateForEntity("orcamentos");
return { success: true, message: "Orçamento removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Orçamento removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
const duplicatePreviousMonthSchema = z.object({
period: periodSchema,
period: periodSchema,
});
type DuplicatePreviousMonthInput = z.infer<
typeof duplicatePreviousMonthSchema
>;
type DuplicatePreviousMonthInput = z.infer<typeof duplicatePreviousMonthSchema>;
export async function duplicatePreviousMonthBudgetsAction(
input: DuplicatePreviousMonthInput
input: DuplicatePreviousMonthInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = duplicatePreviousMonthSchema.parse(input);
try {
const user = await getUser();
const data = duplicatePreviousMonthSchema.parse(input);
// Calcular mês anterior
const [year, month] = data.period.split("-").map(Number);
const currentDate = new Date(year, month - 1, 1);
const previousDate = new Date(currentDate);
previousDate.setMonth(previousDate.getMonth() - 1);
// Calcular mês anterior
const [year, month] = data.period.split("-").map(Number);
const currentDate = new Date(year, month - 1, 1);
const previousDate = new Date(currentDate);
previousDate.setMonth(previousDate.getMonth() - 1);
const prevYear = previousDate.getFullYear();
const prevMonth = String(previousDate.getMonth() + 1).padStart(2, "0");
const previousPeriod = `${prevYear}-${prevMonth}`;
const prevYear = previousDate.getFullYear();
const prevMonth = String(previousDate.getMonth() + 1).padStart(2, "0");
const previousPeriod = `${prevYear}-${prevMonth}`;
// Buscar orçamentos do mês anterior
const previousBudgets = await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, previousPeriod)
),
});
// Buscar orçamentos do mês anterior
const previousBudgets = await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, previousPeriod),
),
});
if (previousBudgets.length === 0) {
return {
success: false,
error: "Não foram encontrados orçamentos no mês anterior.",
};
}
if (previousBudgets.length === 0) {
return {
success: false,
error: "Não foram encontrados orçamentos no mês anterior.",
};
}
// Buscar orçamentos existentes do mês atual
const currentBudgets = await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period)
),
});
// Buscar orçamentos existentes do mês atual
const currentBudgets = await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
),
});
// Filtrar para evitar duplicatas
const existingCategoryIds = new Set(
currentBudgets.map((b) => b.categoriaId)
);
// Filtrar para evitar duplicatas
const existingCategoryIds = new Set(
currentBudgets.map((b) => b.categoriaId),
);
const budgetsToCopy = previousBudgets.filter(
(b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId)
);
const budgetsToCopy = previousBudgets.filter(
(b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId),
);
if (budgetsToCopy.length === 0) {
return {
success: false,
error:
"Todas as categorias do mês anterior já possuem orçamento neste mês.",
};
}
if (budgetsToCopy.length === 0) {
return {
success: false,
error:
"Todas as categorias do mês anterior já possuem orçamento neste mês.",
};
}
// Inserir novos orçamentos
await db.insert(orcamentos).values(
budgetsToCopy.map((b) => ({
amount: b.amount,
period: data.period,
userId: user.id,
categoriaId: b.categoriaId!,
}))
);
// Inserir novos orçamentos
await db.insert(orcamentos).values(
budgetsToCopy.map((b) => ({
amount: b.amount,
period: data.period,
userId: user.id,
categoriaId: b.categoriaId!,
})),
);
revalidateForEntity("orcamentos");
revalidateForEntity("orcamentos");
return {
success: true,
message: `${budgetsToCopy.length} orçamento${budgetsToCopy.length > 1 ? "s" : ""} duplicado${budgetsToCopy.length > 1 ? "s" : ""} com sucesso.`,
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
message: `${budgetsToCopy.length} orçamento${budgetsToCopy.length > 1 ? "s" : ""} duplicado${budgetsToCopy.length > 1 ? "s" : ""} com sucesso.`,
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -1,125 +1,127 @@
import { and, asc, eq, inArray, sum } from "drizzle-orm";
import {
categorias,
lancamentos,
orcamentos,
type Orcamento,
categorias,
lancamentos,
type Orcamento,
orcamentos,
} from "@/db/schema";
import { db } from "@/lib/db";
import { and, asc, eq, inArray, sum } from "drizzle-orm";
const toNumber = (value: string | number | null | undefined) => {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
};
export type BudgetData = {
id: string;
amount: number;
spent: number;
period: string;
createdAt: string;
category: {
id: string;
name: string;
icon: string | null;
} | null;
id: string;
amount: number;
spent: number;
period: string;
createdAt: string;
category: {
id: string;
name: string;
icon: string | null;
} | null;
};
export type CategoryOption = {
id: string;
name: string;
icon: string | null;
id: string;
name: string;
icon: string | null;
};
export async function fetchBudgetsForUser(
userId: string,
selectedPeriod: string
userId: string,
selectedPeriod: string,
): Promise<{
budgets: BudgetData[];
categoriesOptions: CategoryOption[];
budgets: BudgetData[];
categoriesOptions: CategoryOption[];
}> {
const [budgetRows, categoryRows] = await Promise.all([
db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, userId),
eq(orcamentos.period, selectedPeriod)
),
with: {
categoria: true,
},
}),
db.query.categorias.findMany({
columns: {
id: true,
name: true,
icon: true,
},
where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")),
orderBy: asc(categorias.name),
}),
]);
const [budgetRows, categoryRows] = await Promise.all([
db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, userId),
eq(orcamentos.period, selectedPeriod),
),
with: {
categoria: true,
},
}),
db.query.categorias.findMany({
columns: {
id: true,
name: true,
icon: true,
},
where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")),
orderBy: asc(categorias.name),
}),
]);
const categoryIds = budgetRows
.map((budget: Orcamento) => budget.categoriaId)
.filter((id: string | null): id is string => Boolean(id));
const categoryIds = budgetRows
.map((budget: Orcamento) => budget.categoriaId)
.filter((id: string | null): id is string => Boolean(id));
let totalsByCategory = new Map<string, number>();
let totalsByCategory = new Map<string, number>();
if (categoryIds.length > 0) {
const totals = await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("totalAmount"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, selectedPeriod),
eq(lancamentos.transactionType, "Despesa"),
inArray(lancamentos.categoriaId, categoryIds)
)
)
.groupBy(lancamentos.categoriaId);
if (categoryIds.length > 0) {
const totals = await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("totalAmount"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, selectedPeriod),
eq(lancamentos.transactionType, "Despesa"),
inArray(lancamentos.categoriaId, categoryIds),
),
)
.groupBy(lancamentos.categoriaId);
totalsByCategory = new Map(
totals.map((row: { categoriaId: string | null; totalAmount: string | null }) => [
row.categoriaId ?? "",
Math.abs(toNumber(row.totalAmount)),
])
);
}
totalsByCategory = new Map(
totals.map(
(row: { categoriaId: string | null; totalAmount: string | null }) => [
row.categoriaId ?? "",
Math.abs(toNumber(row.totalAmount)),
],
),
);
}
const budgets = budgetRows
.map((budget: Orcamento) => ({
id: budget.id,
amount: toNumber(budget.amount),
spent: totalsByCategory.get(budget.categoriaId ?? "") ?? 0,
period: budget.period,
createdAt: budget.createdAt.toISOString(),
category: budget.categoria
? {
id: budget.categoria.id,
name: budget.categoria.name,
icon: budget.categoria.icon,
}
: null,
}))
.sort((a, b) =>
(a.category?.name ?? "").localeCompare(b.category?.name ?? "", "pt-BR", {
sensitivity: "base",
})
);
const budgets = budgetRows
.map((budget: Orcamento) => ({
id: budget.id,
amount: toNumber(budget.amount),
spent: totalsByCategory.get(budget.categoriaId ?? "") ?? 0,
period: budget.period,
createdAt: budget.createdAt.toISOString(),
category: budget.categoria
? {
id: budget.categoria.id,
name: budget.categoria.name,
icon: budget.categoria.icon,
}
: null,
}))
.sort((a, b) =>
(a.category?.name ?? "").localeCompare(b.category?.name ?? "", "pt-BR", {
sensitivity: "base",
}),
);
const categoriesOptions = categoryRows.map((category) => ({
id: category.id,
name: category.name,
icon: category.icon,
}));
const categoriesOptions = categoryRows.map((category) => ({
id: category.id,
name: category.name,
icon: category.icon,
}));
return { budgets, categoriesOptions };
return { budgets, categoriesOptions };
}

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiFundsLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Anotações | Opensheets",
title: "Anotações | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiFundsLine />}
title="Orçamentos"
subtitle="Gerencie seus orçamentos mensais por categorias. Acompanhe o progresso do seu orçamento e faça ajustes conforme necessário."
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiFundsLine />}
title="Orçamentos"
subtitle="Gerencie seus orçamentos mensais por categorias. Acompanhe o progresso do seu orçamento e faça ajustes conforme necessário."
/>
{children}
</section>
);
}

View File

@@ -5,64 +5,61 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: MonthPicker + Header + Grid de cards de orçamento
*/
export default function OrcamentosLoading() {
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Grid de cards de orçamentos */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border p-6 space-y-4"
>
{/* Categoria com ícone */}
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Grid de cards de orçamentos */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
{/* Categoria com ícone */}
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Valor orçado */}
<div className="space-y-2 pt-4 border-t">
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
<Skeleton className="h-7 w-32 rounded-2xl bg-foreground/10" />
</div>
{/* Valor orçado */}
<div className="space-y-2 pt-4 border-t">
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
<Skeleton className="h-7 w-32 rounded-2xl bg-foreground/10" />
</div>
{/* Valor gasto */}
<div className="space-y-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-28 rounded-2xl bg-foreground/10" />
</div>
{/* Valor gasto */}
<div className="space-y-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-28 rounded-2xl bg-foreground/10" />
</div>
{/* Barra de progresso */}
<div className="space-y-2">
<Skeleton className="h-2 w-full rounded-full bg-foreground/10" />
<Skeleton className="h-3 w-16 rounded-2xl bg-foreground/10" />
</div>
{/* Barra de progresso */}
<div className="space-y-2">
<Skeleton className="h-2 w-full rounded-full bg-foreground/10" />
<Skeleton className="h-3 w-16 rounded-2xl bg-foreground/10" />
</div>
{/* Botões de ação */}
<div className="flex gap-2 pt-2">
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
{/* Botões de ação */}
<div className="flex gap-2 pt-2">
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -7,45 +7,48 @@ import { fetchBudgetsForUser } from "./data";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? value[0] ?? null : value;
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
const capitalize = (value: string) =>
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName: rawMonthName,
year,
} = parsePeriodParam(periodoParam);
const {
period: selectedPeriod,
monthName: rawMonthName,
year,
} = parsePeriodParam(periodoParam);
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
const { budgets, categoriesOptions } = await fetchBudgetsForUser(userId, selectedPeriod);
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
userId,
selectedPeriod,
);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<BudgetsPage
budgets={budgets}
categories={categoriesOptions}
selectedPeriod={selectedPeriod}
periodLabel={periodLabel}
/>
</main>
);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<BudgetsPage
budgets={budgets}
categories={categoriesOptions}
selectedPeriod={selectedPeriod}
periodLabel={periodLabel}
/>
</main>
);
}

View File

@@ -1,234 +1,234 @@
"use server";
import { lancamentos, pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import {
fetchPagadorBoletoStats,
fetchPagadorCardUsage,
fetchPagadorHistory,
fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details";
import { displayPeriod } from "@/lib/utils/period";
import { and, desc, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { Resend } from "resend";
import { z } from "zod";
import { lancamentos, pagadores } from "@/db/schema";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
fetchPagadorBoletoStats,
fetchPagadorCardUsage,
fetchPagadorHistory,
fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details";
import { displayPeriod } from "@/lib/utils/period";
const inputSchema = z.object({
pagadorId: z.string().uuid("Pagador inválido."),
period: z
.string()
.regex(/^\d{4}-\d{2}$/, "Período inválido. Informe no formato AAAA-MM."),
pagadorId: z.string().uuid("Pagador inválido."),
period: z
.string()
.regex(/^\d{4}-\d{2}$/, "Período inválido. Informe no formato AAAA-MM."),
});
type ActionResult =
| { success: true; message: string }
| { success: false; error: string };
| { success: true; message: string }
| { success: false; error: string };
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
const formatDate = (value: Date | null | undefined) => {
if (!value) return "—";
return value.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
});
if (!value) return "—";
return value.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
// Escapa HTML para prevenir XSS
const escapeHtml = (text: string | null | undefined): string => {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
type LancamentoRow = {
id: string;
name: string | null;
paymentMethod: string | null;
condition: string | null;
amount: number;
transactionType: string | null;
purchaseDate: Date | null;
id: string;
name: string | null;
paymentMethod: string | null;
condition: string | null;
amount: number;
transactionType: string | null;
purchaseDate: Date | null;
};
type BoletoItem = {
name: string;
amount: number;
dueDate: Date | null;
name: string;
amount: number;
dueDate: Date | null;
};
type ParceladoItem = {
name: string;
totalAmount: number;
installmentCount: number;
currentInstallment: number;
installmentAmount: number;
purchaseDate: Date | null;
name: string;
totalAmount: number;
installmentCount: number;
currentInstallment: number;
installmentAmount: number;
purchaseDate: Date | null;
};
type SummaryPayload = {
pagadorName: string;
periodLabel: string;
monthlyBreakdown: Awaited<ReturnType<typeof fetchPagadorMonthlyBreakdown>>;
historyData: Awaited<ReturnType<typeof fetchPagadorHistory>>;
cardUsage: Awaited<ReturnType<typeof fetchPagadorCardUsage>>;
boletoStats: Awaited<ReturnType<typeof fetchPagadorBoletoStats>>;
boletos: BoletoItem[];
lancamentos: LancamentoRow[];
parcelados: ParceladoItem[];
pagadorName: string;
periodLabel: string;
monthlyBreakdown: Awaited<ReturnType<typeof fetchPagadorMonthlyBreakdown>>;
historyData: Awaited<ReturnType<typeof fetchPagadorHistory>>;
cardUsage: Awaited<ReturnType<typeof fetchPagadorCardUsage>>;
boletoStats: Awaited<ReturnType<typeof fetchPagadorBoletoStats>>;
boletos: BoletoItem[];
lancamentos: LancamentoRow[];
parcelados: ParceladoItem[];
};
const buildSectionHeading = (label: string) =>
`<h3 style="font-size:16px;margin:24px 0 8px 0;color:#0f172a;">${label}</h3>`;
`<h3 style="font-size:16px;margin:24px 0 8px 0;color:#0f172a;">${label}</h3>`;
const buildSummaryHtml = ({
pagadorName,
periodLabel,
monthlyBreakdown,
historyData,
cardUsage,
boletoStats,
boletos,
lancamentos,
parcelados,
pagadorName,
periodLabel,
monthlyBreakdown,
historyData,
cardUsage,
boletoStats,
boletos,
lancamentos,
parcelados,
}: SummaryPayload) => {
// Calcular máximo de despesas para barras de progresso
const maxDespesas = Math.max(...historyData.map((p) => p.despesas), 1);
// Calcular máximo de despesas para barras de progresso
const maxDespesas = Math.max(...historyData.map((p) => p.despesas), 1);
const historyRows =
historyData.length > 0
? historyData
.map((point) => {
const percentage = (point.despesas / maxDespesas) * 100;
const barColor =
point.despesas > maxDespesas * 0.8
? "#ef4444"
: point.despesas > maxDespesas * 0.5
? "#f59e0b"
: "#10b981";
const historyRows =
historyData.length > 0
? historyData
.map((point) => {
const percentage = (point.despesas / maxDespesas) * 100;
const barColor =
point.despesas > maxDespesas * 0.8
? "#ef4444"
: point.despesas > maxDespesas * 0.5
? "#f59e0b"
: "#10b981";
return `
return `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
point.label
)}</td>
point.label,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">
<div style="display:flex;align-items:center;gap:12px;">
<div style="flex:1;background:#f1f5f9;border-radius:6px;height:24px;overflow:hidden;">
<div style="background:${barColor};height:100%;width:${percentage}%;transition:width 0.3s;"></div>
</div>
<span style="font-weight:600;min-width:100px;text-align:right;">${formatCurrency(
point.despesas
)}</span>
point.despesas,
)}</span>
</div>
</td>
</tr>`;
})
.join("")
: `<tr><td colspan="2" style="padding:16px;text-align:center;color:#94a3b8;">Sem histórico suficiente.</td></tr>`;
})
.join("")
: `<tr><td colspan="2" style="padding:16px;text-align:center;color:#94a3b8;">Sem histórico suficiente.</td></tr>`;
const cardUsageRows =
cardUsage.length > 0
? cardUsage
.map(
(item) => `
const cardUsageRows =
cardUsage.length > 0
? cardUsage
.map(
(item) => `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
item.name
)}</td>
item.name,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.amount
)}</td>
</tr>`
)
.join("")
: `<tr><td colspan="2" style="padding:16px;text-align:center;color:#94a3b8;">Sem gastos com cartão neste período.</td></tr>`;
item.amount,
)}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="2" style="padding:16px;text-align:center;color:#94a3b8;">Sem gastos com cartão neste período.</td></tr>`;
const boletoRows =
boletos.length > 0
? boletos
.map(
(item) => `
const boletoRows =
boletos.length > 0
? boletos
.map(
(item) => `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
item.name
)}</td>
item.name,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
item.dueDate ? formatDate(item.dueDate) : "—"
}</td>
item.dueDate ? formatDate(item.dueDate) : "—"
}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.amount
)}</td>
</tr>`
)
.join("")
: `<tr><td colspan="3" style="padding:16px;text-align:center;color:#94a3b8;">Sem boletos neste período.</td></tr>`;
item.amount,
)}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="3" style="padding:16px;text-align:center;color:#94a3b8;">Sem boletos neste período.</td></tr>`;
const lancamentoRows =
lancamentos.length > 0
? lancamentos
.map(
(item) => `
const lancamentoRows =
lancamentos.length > 0
? lancamentos
.map(
(item) => `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate(
item.purchaseDate
)}</td>
item.purchaseDate,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
escapeHtml(item.name) || "Sem descrição"
}</td>
escapeHtml(item.name) || "Sem descrição"
}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
escapeHtml(item.condition) || "—"
}</td>
escapeHtml(item.condition) || "—"
}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
escapeHtml(item.paymentMethod) || "—"
}</td>
escapeHtml(item.paymentMethod) || "—"
}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.amount
)}</td>
</tr>`
)
.join("")
: `<tr><td colspan="5" style="padding:16px;text-align:center;color:#94a3b8;">Nenhum lançamento registrado no período.</td></tr>`;
item.amount,
)}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="5" style="padding:16px;text-align:center;color:#94a3b8;">Nenhum lançamento registrado no período.</td></tr>`;
const parceladoRows =
parcelados.length > 0
? parcelados
.map(
(item) => `
const parceladoRows =
parcelados.length > 0
? parcelados
.map(
(item) => `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate(
item.purchaseDate
)}</td>
item.purchaseDate,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
escapeHtml(item.name) || "Sem descrição"
}</td>
escapeHtml(item.name) || "Sem descrição"
}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:center;">${
item.currentInstallment
}/${item.installmentCount}</td>
item.currentInstallment
}/${item.installmentCount}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.installmentAmount
)}</td>
item.installmentAmount,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;color:#64748b;">${formatCurrency(
item.totalAmount
)}</td>
</tr>`
)
.join("")
: `<tr><td colspan="5" style="padding:16px;text-align:center;color:#94a3b8;">Nenhum lançamento parcelado neste período.</td></tr>`;
item.totalAmount,
)}</td>
</tr>`,
)
.join("")
: `<tr><td colspan="5" style="padding:16px;text-align:center;color:#94a3b8;">Nenhum lançamento parcelado neste período.</td></tr>`;
return `
return `
<div style="margin:0 auto;max-width:800px;background:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Inter',Arial,sans-serif;color:#0f172a;line-height:1.6;">
<!-- Preheader invisível (melhora a prévia no cliente de e-mail) -->
<span style="display:none;visibility:hidden;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">Resumo mensal e detalhes de gastos por cartão, boletos e lançamentos.</span>
@@ -237,8 +237,8 @@ const buildSummaryHtml = ({
<div style="background:linear-gradient(90deg,#dc5a3a,#ea744e);padding:28px 24px;border-radius:12px 12px 0 0;">
<h1 style="margin:0 0 6px 0;font-size:26px;font-weight:800;letter-spacing:-0.3px;color:#ffffff;">Resumo Financeiro</h1>
<p style="margin:0;font-size:15px;color:#ffece6;">${escapeHtml(
periodLabel
)}</p>
periodLabel,
)}</p>
</div>
<!-- Cartão principal -->
@@ -246,8 +246,8 @@ const buildSummaryHtml = ({
<!-- Saudação -->
<p style="margin:0 0 24px 0;font-size:15px;color:#334155;">
Olá <strong>${escapeHtml(
pagadorName
)}</strong>, segue o consolidado do mês:
pagadorName,
)}</strong>, segue o consolidado do mês:
</p>
<!-- Totais do mês -->
@@ -258,27 +258,27 @@ const buildSummaryHtml = ({
<td style="padding:16px 18px;background:#fff7f5;border-bottom:1px solid #f1f5f9;font-size:15px;color:#475569;">Total gasto</td>
<td style="padding:16px 18px;background:#fff7f5;border-bottom:1px solid #f1f5f9;text-align:right;">
<strong style="font-size:22px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.totalExpenses
)}</strong>
monthlyBreakdown.totalExpenses,
)}</strong>
</td>
</tr>
<tr>
<td style="padding:12px 18px;font-size:14px;color:#64748b;">💳 Cartões</td>
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.card
)}</strong></td>
monthlyBreakdown.paymentSplits.card,
)}</strong></td>
</tr>
<tr style="background:#fcfcfd;">
<td style="padding:12px 18px;font-size:14px;color:#64748b;">📄 Boletos</td>
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.boleto
)}</strong></td>
monthlyBreakdown.paymentSplits.boleto,
)}</strong></td>
</tr>
<tr>
<td style="padding:12px 18px;font-size:14px;color:#64748b;">⚡ Pix/Débito/Dinheiro</td>
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.instant
)}</strong></td>
monthlyBreakdown.paymentSplits.instant,
)}</strong></td>
</tr>
</tbody>
</table>
@@ -305,8 +305,8 @@ const buildSummaryHtml = ({
<td style="color:#475569;font-weight:700;font-size:15px;">Total</td>
<td style="text-align:right;">
<strong style="font-size:18px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.card
)}</strong>
monthlyBreakdown.paymentSplits.card,
)}</strong>
</td>
</tr>
</table>
@@ -333,8 +333,8 @@ const buildSummaryHtml = ({
<td style="color:#475569;font-weight:700;font-size:15px;">Total</td>
<td style="text-align:right;">
<strong style="font-size:18px;color:#0f172a;">${formatCurrency(
boletoStats.totalAmount
)}</strong>
boletoStats.totalAmount,
)}</strong>
</td>
</tr>
</table>
@@ -396,207 +396,207 @@ const buildSummaryHtml = ({
};
export async function sendPagadorSummaryAction(
input: z.infer<typeof inputSchema>
input: z.infer<typeof inputSchema>,
): Promise<ActionResult> {
try {
const { pagadorId, period } = inputSchema.parse(input);
const user = await getUser();
try {
const { pagadorId, period } = inputSchema.parse(input);
const user = await getUser();
const pagadorRow = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, user.id)),
});
const pagadorRow = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, user.id)),
});
if (!pagadorRow) {
return { success: false, error: "Pagador não encontrado." };
}
if (!pagadorRow) {
return { success: false, error: "Pagador não encontrado." };
}
if (!pagadorRow.email) {
return {
success: false,
error: "Cadastre um e-mail para conseguir enviar o resumo.",
};
}
if (!pagadorRow.email) {
return {
success: false,
error: "Cadastre um e-mail para conseguir enviar o resumo.",
};
}
const resendApiKey = process.env.RESEND_API_KEY;
const resendFrom =
process.env.RESEND_FROM_EMAIL ?? "Opensheets <onboarding@resend.dev>";
const resendApiKey = process.env.RESEND_API_KEY;
const resendFrom =
process.env.RESEND_FROM_EMAIL ?? "Opensheets <onboarding@resend.dev>";
if (!resendApiKey) {
return {
success: false,
error: "Serviço de e-mail não configurado (RESEND_API_KEY ausente).",
};
}
if (!resendApiKey) {
return {
success: false,
error: "Serviço de e-mail não configurado (RESEND_API_KEY ausente).",
};
}
const resend = new Resend(resendApiKey);
const resend = new Resend(resendApiKey);
const [
monthlyBreakdown,
historyData,
cardUsage,
boletoStats,
boletoRows,
lancamentoRows,
parceladoRows,
] = await Promise.all([
fetchPagadorMonthlyBreakdown({
userId: user.id,
pagadorId,
period,
}),
fetchPagadorHistory({
userId: user.id,
pagadorId,
period,
}),
fetchPagadorCardUsage({
userId: user.id,
pagadorId,
period,
}),
fetchPagadorBoletoStats({
userId: user.id,
pagadorId,
period,
}),
db
.select({
name: lancamentos.name,
amount: lancamentos.amount,
dueDate: lancamentos.dueDate,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, "Boleto")
)
)
.orderBy(desc(lancamentos.dueDate)),
db
.select({
id: lancamentos.id,
name: lancamentos.name,
paymentMethod: lancamentos.paymentMethod,
condition: lancamentos.condition,
amount: lancamentos.amount,
transactionType: lancamentos.transactionType,
purchaseDate: lancamentos.purchaseDate,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period)
)
)
.orderBy(desc(lancamentos.purchaseDate)),
db
.select({
name: lancamentos.name,
amount: lancamentos.amount,
installmentCount: lancamentos.installmentCount,
currentInstallment: lancamentos.currentInstallment,
purchaseDate: lancamentos.purchaseDate,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false)
)
)
.orderBy(desc(lancamentos.purchaseDate)),
]);
const [
monthlyBreakdown,
historyData,
cardUsage,
boletoStats,
boletoRows,
lancamentoRows,
parceladoRows,
] = await Promise.all([
fetchPagadorMonthlyBreakdown({
userId: user.id,
pagadorId,
period,
}),
fetchPagadorHistory({
userId: user.id,
pagadorId,
period,
}),
fetchPagadorCardUsage({
userId: user.id,
pagadorId,
period,
}),
fetchPagadorBoletoStats({
userId: user.id,
pagadorId,
period,
}),
db
.select({
name: lancamentos.name,
amount: lancamentos.amount,
dueDate: lancamentos.dueDate,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, "Boleto"),
),
)
.orderBy(desc(lancamentos.dueDate)),
db
.select({
id: lancamentos.id,
name: lancamentos.name,
paymentMethod: lancamentos.paymentMethod,
condition: lancamentos.condition,
amount: lancamentos.amount,
transactionType: lancamentos.transactionType,
purchaseDate: lancamentos.purchaseDate,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
),
)
.orderBy(desc(lancamentos.purchaseDate)),
db
.select({
name: lancamentos.name,
amount: lancamentos.amount,
installmentCount: lancamentos.installmentCount,
currentInstallment: lancamentos.currentInstallment,
purchaseDate: lancamentos.purchaseDate,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false),
),
)
.orderBy(desc(lancamentos.purchaseDate)),
]);
const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({
name: row.name ?? "Sem descrição",
amount: Math.abs(Number(row.amount ?? 0)),
dueDate: row.dueDate,
}));
const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({
name: row.name ?? "Sem descrição",
amount: Math.abs(Number(row.amount ?? 0)),
dueDate: row.dueDate,
}));
const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map(
(row) => ({
id: row.id,
name: row.name,
paymentMethod: row.paymentMethod,
condition: row.condition,
transactionType: row.transactionType,
purchaseDate: row.purchaseDate,
amount: Number(row.amount ?? 0),
})
);
const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map(
(row) => ({
id: row.id,
name: row.name,
paymentMethod: row.paymentMethod,
condition: row.condition,
transactionType: row.transactionType,
purchaseDate: row.purchaseDate,
amount: Number(row.amount ?? 0),
}),
);
const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => {
const installmentAmount = Math.abs(Number(row.amount ?? 0));
const installmentCount = row.installmentCount ?? 1;
const totalAmount = installmentAmount * installmentCount;
const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => {
const installmentAmount = Math.abs(Number(row.amount ?? 0));
const installmentCount = row.installmentCount ?? 1;
const totalAmount = installmentAmount * installmentCount;
return {
name: row.name ?? "Sem descrição",
installmentAmount,
installmentCount,
currentInstallment: row.currentInstallment ?? 1,
totalAmount,
purchaseDate: row.purchaseDate,
};
});
return {
name: row.name ?? "Sem descrição",
installmentAmount,
installmentCount,
currentInstallment: row.currentInstallment ?? 1,
totalAmount,
purchaseDate: row.purchaseDate,
};
});
const html = buildSummaryHtml({
pagadorName: pagadorRow.name,
periodLabel: displayPeriod(period),
monthlyBreakdown,
historyData,
cardUsage,
boletoStats,
boletos: normalizedBoletos,
lancamentos: normalizedLancamentos,
parcelados: normalizedParcelados,
});
const html = buildSummaryHtml({
pagadorName: pagadorRow.name,
periodLabel: displayPeriod(period),
monthlyBreakdown,
historyData,
cardUsage,
boletoStats,
boletos: normalizedBoletos,
lancamentos: normalizedLancamentos,
parcelados: normalizedParcelados,
});
await resend.emails.send({
from: resendFrom,
to: pagadorRow.email,
subject: `Resumo Financeiro | ${displayPeriod(period)}`,
html,
});
await resend.emails.send({
from: resendFrom,
to: pagadorRow.email,
subject: `Resumo Financeiro | ${displayPeriod(period)}`,
html,
});
const now = new Date();
const now = new Date();
await db
.update(pagadores)
.set({ lastMailAt: now })
.where(
and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.id))
);
await db
.update(pagadores)
.set({ lastMailAt: now })
.where(
and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.id)),
);
revalidatePath(`/pagadores/${pagadorRow.id}`);
revalidatePath(`/pagadores/${pagadorRow.id}`);
return { success: true, message: "Resumo enviado com sucesso." };
} catch (error) {
// Log estruturado em desenvolvimento
if (process.env.NODE_ENV === "development") {
console.error("[sendPagadorSummaryAction]", error);
}
return { success: true, message: "Resumo enviado com sucesso." };
} catch (error) {
// Log estruturado em desenvolvimento
if (process.env.NODE_ENV === "development") {
console.error("[sendPagadorSummaryAction]", error);
}
// Tratar erros de validação separadamente
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
// Tratar erros de validação separadamente
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
// Não expor detalhes do erro para o usuário
return {
success: false,
error: "Não foi possível enviar o resumo. Tente novamente mais tarde.",
};
}
// Não expor detalhes do erro para o usuário
return {
success: false,
error: "Não foi possível enviar o resumo. Tente novamente mais tarde.",
};
}
}

View File

@@ -1,90 +1,95 @@
import { lancamentos, pagadorShares, user as usersTable, contas, cartoes, categorias, pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { and, desc, eq, type SQL } from "drizzle-orm";
import {
cartoes,
categorias,
contas,
lancamentos,
pagadores,
pagadorShares,
user as usersTable,
} from "@/db/schema";
import { db } from "@/lib/db";
export type ShareData = {
id: string;
userId: string;
name: string;
email: string;
createdAt: string;
id: string;
userId: string;
name: string;
email: string;
createdAt: string;
};
export async function fetchPagadorShares(
pagadorId: string
pagadorId: string,
): Promise<ShareData[]> {
const shareRows = await db
.select({
id: pagadorShares.id,
sharedWithUserId: pagadorShares.sharedWithUserId,
createdAt: pagadorShares.createdAt,
userName: usersTable.name,
userEmail: usersTable.email,
})
.from(pagadorShares)
.innerJoin(
usersTable,
eq(pagadorShares.sharedWithUserId, usersTable.id)
)
.where(eq(pagadorShares.pagadorId, pagadorId));
const shareRows = await db
.select({
id: pagadorShares.id,
sharedWithUserId: pagadorShares.sharedWithUserId,
createdAt: pagadorShares.createdAt,
userName: usersTable.name,
userEmail: usersTable.email,
})
.from(pagadorShares)
.innerJoin(usersTable, eq(pagadorShares.sharedWithUserId, usersTable.id))
.where(eq(pagadorShares.pagadorId, pagadorId));
return shareRows.map((share) => ({
id: share.id,
userId: share.sharedWithUserId,
name: share.userName ?? "Usuário",
email: share.userEmail ?? "email não informado",
createdAt: share.createdAt?.toISOString() ?? new Date().toISOString(),
}));
return shareRows.map((share) => ({
id: share.id,
userId: share.sharedWithUserId,
name: share.userName ?? "Usuário",
email: share.userEmail ?? "email não informado",
createdAt: share.createdAt?.toISOString() ?? new Date().toISOString(),
}));
}
export async function fetchCurrentUserShare(
pagadorId: string,
userId: string
pagadorId: string,
userId: string,
): Promise<{ id: string; createdAt: string } | null> {
const shareRow = await db.query.pagadorShares.findFirst({
columns: {
id: true,
createdAt: true,
},
where: and(
eq(pagadorShares.pagadorId, pagadorId),
eq(pagadorShares.sharedWithUserId, userId)
),
});
const shareRow = await db.query.pagadorShares.findFirst({
columns: {
id: true,
createdAt: true,
},
where: and(
eq(pagadorShares.pagadorId, pagadorId),
eq(pagadorShares.sharedWithUserId, userId),
),
});
if (!shareRow) {
return null;
}
if (!shareRow) {
return null;
}
return {
id: shareRow.id,
createdAt: shareRow.createdAt?.toISOString() ?? new Date().toISOString(),
};
return {
id: shareRow.id,
createdAt: shareRow.createdAt?.toISOString() ?? new Date().toISOString(),
};
}
export async function fetchPagadorLancamentos(filters: SQL[]) {
const lancamentoRows = await db
.select({
lancamento: lancamentos,
pagador: pagadores,
conta: contas,
cartao: cartoes,
categoria: categorias,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...filters))
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
const lancamentoRows = await db
.select({
lancamento: lancamentos,
pagador: pagadores,
conta: contas,
cartao: cartoes,
categoria: categorias,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...filters))
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
// Transformar resultado para o formato esperado
return lancamentoRows.map((row: any) => ({
...row.lancamento,
pagador: row.pagador,
conta: row.conta,
cartao: row.cartao,
categoria: row.categoria,
}));
// Transformar resultado para o formato esperado
return lancamentoRows.map((row: any) => ({
...row.lancamento,
pagador: row.pagador,
conta: row.conta,
cartao: row.cartao,
categoria: row.categoria,
}));
}

View File

@@ -5,80 +5,80 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos)
*/
export default function PagadorDetailsLoading() {
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Info do Pagador (sempre visível) */}
<div className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start gap-4">
{/* Avatar */}
<Skeleton className="size-20 rounded-full bg-foreground/10" />
{/* Info do Pagador (sempre visível) */}
<div className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start gap-4">
{/* Avatar */}
<Skeleton className="size-20 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-3">
{/* Nome + Badge */}
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
</div>
<div className="flex-1 space-y-3">
{/* Nome + Badge */}
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
</div>
{/* Email */}
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
{/* Email */}
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
{/* Status */}
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Status */}
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Botões de ação */}
<div className="flex gap-2">
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
{/* Botões de ação */}
<div className="flex gap-2">
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
{/* Tabs */}
<div className="space-y-6">
<div className="flex gap-2 border-b">
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
</div>
{/* Tabs */}
<div className="space-y-6">
<div className="flex gap-2 border-b">
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
</div>
{/* Conteúdo da aba Visão Geral (grid de cards) */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Card de resumo mensal */}
<div className="rounded-2xl border p-6 space-y-4 lg:col-span-2">
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
<div className="grid grid-cols-3 gap-4 pt-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-7 w-full rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
{/* Conteúdo da aba Visão Geral (grid de cards) */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Card de resumo mensal */}
<div className="rounded-2xl border p-6 space-y-4 lg:col-span-2">
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
<div className="grid grid-cols-3 gap-4 pt-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-7 w-full rounded-2xl bg-foreground/10" />
</div>
))}
</div>
</div>
{/* Outros cards */}
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
</div>
<div className="space-y-3 pt-4">
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-3/4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-1/2 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
{/* Outros cards */}
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
</div>
<div className="space-y-3 pt-4">
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-3/4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-1/2 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -1,435 +1,443 @@
import { notFound } from "next/navigation";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import type {
ContaCartaoFilterOption,
LancamentoFilterOption,
LancamentoItem,
SelectOption,
} from "@/components/lancamentos/types";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card";
import { PagadorBoletoCard } from "@/components/pagadores/details/pagador-payment-method-cards";
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import type {
ContaCartaoFilterOption,
LancamentoFilterOption,
LancamentoItem,
SelectOption,
} from "@/components/lancamentos/types";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { pagadores } from "@/db/schema";
import type { pagadores } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
mapLancamentosData,
type LancamentoSearchFilters,
type ResolvedSearchParams,
type SlugMaps,
type SluggedFilters,
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
type LancamentoSearchFilters,
mapLancamentosData,
type ResolvedSearchParams,
type SluggedFilters,
type SlugMaps,
} from "@/lib/lancamentos/page-helpers";
import { getPagadorAccess } from "@/lib/pagadores/access";
import {
fetchPagadorBoletoStats,
fetchPagadorCardUsage,
fetchPagadorHistory,
fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details";
import { parsePeriodParam } from "@/lib/utils/period";
import {
fetchPagadorBoletoStats,
fetchPagadorCardUsage,
fetchPagadorHistory,
fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details";
import { notFound } from "next/navigation";
import { fetchPagadorLancamentos, fetchPagadorShares, fetchCurrentUserShare } from "./data";
fetchCurrentUserShare,
fetchPagadorLancamentos,
fetchPagadorShares,
} from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
params: Promise<{ pagadorId: string }>;
searchParams?: PageSearchParams;
params: Promise<{ pagadorId: string }>;
searchParams?: PageSearchParams;
};
const capitalize = (value: string) =>
value.length ? value.charAt(0).toUpperCase().concat(value.slice(1)) : value;
value.length ? value.charAt(0).toUpperCase().concat(value.slice(1)) : value;
const EMPTY_FILTERS: LancamentoSearchFilters = {
transactionFilter: null,
conditionFilter: null,
paymentFilter: null,
pagadorFilter: null,
categoriaFilter: null,
contaCartaoFilter: null,
searchFilter: null,
transactionFilter: null,
conditionFilter: null,
paymentFilter: null,
pagadorFilter: null,
categoriaFilter: null,
contaCartaoFilter: null,
searchFilter: null,
};
const createEmptySlugMaps = (): SlugMaps => ({
pagador: new Map(),
categoria: new Map(),
conta: new Map(),
cartao: new Map(),
pagador: new Map(),
categoria: new Map(),
conta: new Map(),
cartao: new Map(),
});
type OptionSet = ReturnType<typeof buildOptionSets>;
export default async function Page({ params, searchParams }: PageProps) {
const { pagadorId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const { pagadorId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const access = await getPagadorAccess(userId, pagadorId);
const access = await getPagadorAccess(userId, pagadorId);
if (!access) {
notFound();
}
if (!access) {
notFound();
}
const { pagador, canEdit } = access;
const dataOwnerId = pagador.userId;
const { pagador, canEdit } = access;
const dataOwnerId = pagador.userId;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName,
year,
} = parsePeriodParam(periodoParamRaw);
const periodLabel = `${capitalize(monthName)} de ${year}`;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName,
year,
} = parsePeriodParam(periodoParamRaw);
const periodLabel = `${capitalize(monthName)} de ${year}`;
const allSearchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const searchFilters = canEdit
? allSearchFilters
: {
...EMPTY_FILTERS,
searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only
};
const allSearchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const searchFilters = canEdit
? allSearchFilters
: {
...EMPTY_FILTERS,
searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only
};
let filterSources: Awaited<
ReturnType<typeof fetchLancamentoFilterSources>
> | null = null;
let loggedUserFilterSources: Awaited<
ReturnType<typeof fetchLancamentoFilterSources>
> | null = null;
let sluggedFilters: SluggedFilters;
let slugMaps: SlugMaps;
let filterSources: Awaited<
ReturnType<typeof fetchLancamentoFilterSources>
> | null = null;
let loggedUserFilterSources: Awaited<
ReturnType<typeof fetchLancamentoFilterSources>
> | null = null;
let sluggedFilters: SluggedFilters;
let slugMaps: SlugMaps;
if (canEdit) {
filterSources = await fetchLancamentoFilterSources(dataOwnerId);
sluggedFilters = buildSluggedFilters(filterSources);
slugMaps = buildSlugMaps(sluggedFilters);
} else {
// Buscar opções do usuário logado para usar ao importar
loggedUserFilterSources = await fetchLancamentoFilterSources(userId);
sluggedFilters = {
pagadorFiltersRaw: [],
categoriaFiltersRaw: [],
contaFiltersRaw: [],
cartaoFiltersRaw: [],
};
slugMaps = createEmptySlugMaps();
}
if (canEdit) {
filterSources = await fetchLancamentoFilterSources(dataOwnerId);
sluggedFilters = buildSluggedFilters(filterSources);
slugMaps = buildSlugMaps(sluggedFilters);
} else {
// Buscar opções do usuário logado para usar ao importar
loggedUserFilterSources = await fetchLancamentoFilterSources(userId);
sluggedFilters = {
pagadorFiltersRaw: [],
categoriaFiltersRaw: [],
contaFiltersRaw: [],
cartaoFiltersRaw: [],
};
slugMaps = createEmptySlugMaps();
}
const filters = buildLancamentoWhere({
userId: dataOwnerId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
pagadorId: pagador.id,
});
const filters = buildLancamentoWhere({
userId: dataOwnerId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
pagadorId: pagador.id,
});
const sharesPromise = canEdit
? fetchPagadorShares(pagador.id)
: Promise.resolve([]);
const sharesPromise = canEdit
? fetchPagadorShares(pagador.id)
: Promise.resolve([]);
const currentUserSharePromise = !canEdit
? fetchCurrentUserShare(pagador.id, userId)
: Promise.resolve(null);
const currentUserSharePromise = !canEdit
? fetchCurrentUserShare(pagador.id, userId)
: Promise.resolve(null);
const [
lancamentoRows,
monthlyBreakdown,
historyData,
cardUsage,
boletoStats,
shareRows,
currentUserShare,
estabelecimentos,
] = await Promise.all([
fetchPagadorLancamentos(filters),
fetchPagadorMonthlyBreakdown({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorHistory({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorCardUsage({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorBoletoStats({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
sharesPromise,
currentUserSharePromise,
getRecentEstablishmentsAction(),
]);
const [
lancamentoRows,
monthlyBreakdown,
historyData,
cardUsage,
boletoStats,
shareRows,
currentUserShare,
estabelecimentos,
] = await Promise.all([
fetchPagadorLancamentos(filters),
fetchPagadorMonthlyBreakdown({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorHistory({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorCardUsage({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
fetchPagadorBoletoStats({
userId: dataOwnerId,
pagadorId: pagador.id,
period: selectedPeriod,
}),
sharesPromise,
currentUserSharePromise,
getRecentEstablishmentsAction(),
]);
const mappedLancamentos = mapLancamentosData(lancamentoRows);
const lancamentosData = canEdit
? mappedLancamentos
: mappedLancamentos.map((item) => ({ ...item, readonly: true }));
const mappedLancamentos = mapLancamentosData(lancamentoRows);
const lancamentosData = canEdit
? mappedLancamentos
: mappedLancamentos.map((item) => ({ ...item, readonly: true }));
const pagadorSharesData = shareRows;
const pagadorSharesData = shareRows;
let optionSets: OptionSet;
let loggedUserOptionSets: OptionSet | null = null;
let effectiveSluggedFilters = sluggedFilters;
let optionSets: OptionSet;
let loggedUserOptionSets: OptionSet | null = null;
let effectiveSluggedFilters = sluggedFilters;
if (canEdit && filterSources) {
optionSets = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
} else {
effectiveSluggedFilters = {
pagadorFiltersRaw: [
{
id: pagador.id,
label: pagador.name,
slug: pagador.id,
role: pagador.role,
},
],
categoriaFiltersRaw: [],
contaFiltersRaw: [],
cartaoFiltersRaw: [],
};
optionSets = buildReadOnlyOptionSets(lancamentosData, pagador);
if (canEdit && filterSources) {
optionSets = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
} else {
effectiveSluggedFilters = {
pagadorFiltersRaw: [
{
id: pagador.id,
label: pagador.name,
slug: pagador.id,
role: pagador.role,
},
],
categoriaFiltersRaw: [],
contaFiltersRaw: [],
cartaoFiltersRaw: [],
};
optionSets = buildReadOnlyOptionSets(lancamentosData, pagador);
// Construir opções do usuário logado para usar ao importar
if (loggedUserFilterSources) {
const loggedUserSluggedFilters = buildSluggedFilters(loggedUserFilterSources);
loggedUserOptionSets = buildOptionSets({
...loggedUserSluggedFilters,
pagadorRows: loggedUserFilterSources.pagadorRows,
});
}
}
// Construir opções do usuário logado para usar ao importar
if (loggedUserFilterSources) {
const loggedUserSluggedFilters = buildSluggedFilters(
loggedUserFilterSources,
);
loggedUserOptionSets = buildOptionSets({
...loggedUserSluggedFilters,
pagadorRows: loggedUserFilterSources.pagadorRows,
});
}
}
const pagadorSlug =
effectiveSluggedFilters.pagadorFiltersRaw.find(
(item) => item.id === pagador.id
)?.slug ?? null;
const pagadorSlug =
effectiveSluggedFilters.pagadorFiltersRaw.find(
(item) => item.id === pagador.id,
)?.slug ?? null;
const pagadorFilterOptions = pagadorSlug
? optionSets.pagadorFilterOptions.filter(
(option) => option.slug === pagadorSlug
)
: optionSets.pagadorFilterOptions;
const pagadorFilterOptions = pagadorSlug
? optionSets.pagadorFilterOptions.filter(
(option) => option.slug === pagadorSlug,
)
: optionSets.pagadorFilterOptions;
const pagadorData = {
id: pagador.id,
name: pagador.name,
email: pagador.email ?? null,
avatarUrl: pagador.avatarUrl ?? null,
status: pagador.status,
note: pagador.note ?? null,
role: pagador.role ?? null,
isAutoSend: pagador.isAutoSend ?? false,
createdAt: pagador.createdAt
? pagador.createdAt.toISOString()
: new Date().toISOString(),
lastMailAt: pagador.lastMailAt ? pagador.lastMailAt.toISOString() : null,
shareCode: canEdit ? pagador.shareCode : null,
canEdit,
};
const pagadorData = {
id: pagador.id,
name: pagador.name,
email: pagador.email ?? null,
avatarUrl: pagador.avatarUrl ?? null,
status: pagador.status,
note: pagador.note ?? null,
role: pagador.role ?? null,
isAutoSend: pagador.isAutoSend ?? false,
createdAt: pagador.createdAt
? pagador.createdAt.toISOString()
: new Date().toISOString(),
lastMailAt: pagador.lastMailAt ? pagador.lastMailAt.toISOString() : null,
shareCode: canEdit ? pagador.shareCode : null,
canEdit,
};
const summaryPreview = {
periodLabel,
totalExpenses: monthlyBreakdown.totalExpenses,
paymentSplits: monthlyBreakdown.paymentSplits,
cardUsage: cardUsage.slice(0, 3).map((item) => ({
name: item.name,
amount: item.amount,
})),
boletoStats: {
totalAmount: boletoStats.totalAmount,
paidAmount: boletoStats.paidAmount,
pendingAmount: boletoStats.pendingAmount,
paidCount: boletoStats.paidCount,
pendingCount: boletoStats.pendingCount,
},
lancamentoCount: lancamentosData.length,
};
const summaryPreview = {
periodLabel,
totalExpenses: monthlyBreakdown.totalExpenses,
paymentSplits: monthlyBreakdown.paymentSplits,
cardUsage: cardUsage.slice(0, 3).map((item) => ({
name: item.name,
amount: item.amount,
})),
boletoStats: {
totalAmount: boletoStats.totalAmount,
paidAmount: boletoStats.paidAmount,
pendingAmount: boletoStats.pendingAmount,
paidCount: boletoStats.paidCount,
pendingCount: boletoStats.pendingCount,
},
lancamentoCount: lancamentosData.length,
};
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<Tabs defaultValue="profile" className="w-full">
<TabsList className="mb-2">
<TabsTrigger value="profile">Perfil</TabsTrigger>
<TabsTrigger value="painel">Painel</TabsTrigger>
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
</TabsList>
<Tabs defaultValue="profile" className="w-full">
<TabsList className="mb-2">
<TabsTrigger value="profile">Perfil</TabsTrigger>
<TabsTrigger value="painel">Painel</TabsTrigger>
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-4">
<section>
<PagadorInfoCard
pagador={pagadorData}
selectedPeriod={selectedPeriod}
summary={summaryPreview}
/>
</section>
{canEdit && pagadorData.shareCode ? (
<PagadorSharingCard
pagadorId={pagador.id}
shareCode={pagadorData.shareCode}
shares={pagadorSharesData}
/>
) : null}
{!canEdit && currentUserShare ? (
<PagadorLeaveShareCard
shareId={currentUserShare.id}
pagadorName={pagadorData.name}
createdAt={currentUserShare.createdAt}
/>
) : null}
</TabsContent>
<TabsContent value="profile" className="space-y-4">
<section>
<PagadorInfoCard
pagador={pagadorData}
selectedPeriod={selectedPeriod}
summary={summaryPreview}
/>
</section>
{canEdit && pagadorData.shareCode ? (
<PagadorSharingCard
pagadorId={pagador.id}
shareCode={pagadorData.shareCode}
shares={pagadorSharesData}
/>
) : null}
{!canEdit && currentUserShare ? (
<PagadorLeaveShareCard
shareId={currentUserShare.id}
pagadorName={pagadorData.name}
createdAt={currentUserShare.createdAt}
/>
) : null}
</TabsContent>
<TabsContent value="painel" className="space-y-4">
<section className="grid gap-4 lg:grid-cols-2">
<PagadorMonthlySummaryCard
periodLabel={periodLabel}
breakdown={monthlyBreakdown}
/>
<PagadorHistoryCard data={historyData} />
</section>
<TabsContent value="painel" className="space-y-4">
<section className="grid gap-4 lg:grid-cols-2">
<PagadorMonthlySummaryCard
periodLabel={periodLabel}
breakdown={monthlyBreakdown}
/>
<PagadorHistoryCard data={historyData} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<PagadorCardUsageCard items={cardUsage} />
<PagadorBoletoCard stats={boletoStats} />
</section>
</TabsContent>
<section className="grid gap-4 lg:grid-cols-2">
<PagadorCardUsageCard items={cardUsage} />
<PagadorBoletoCard stats={boletoStats} />
</section>
</TabsContent>
<TabsContent value="lancamentos">
<section className="flex flex-col gap-4">
<LancamentosSection
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={optionSets.pagadorOptions}
splitPagadorOptions={optionSets.splitPagadorOptions}
defaultPagadorId={pagador.id}
contaOptions={optionSets.contaOptions}
cartaoOptions={optionSets.cartaoOptions}
categoriaOptions={optionSets.categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={optionSets.categoriaFilterOptions}
contaCartaoFilterOptions={optionSets.contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate={canEdit}
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
importSplitPagadorOptions={loggedUserOptionSets?.splitPagadorOptions}
importDefaultPagadorId={loggedUserOptionSets?.defaultPagadorId}
importContaOptions={loggedUserOptionSets?.contaOptions}
importCartaoOptions={loggedUserOptionSets?.cartaoOptions}
importCategoriaOptions={loggedUserOptionSets?.categoriaOptions}
/>
</section>
</TabsContent>
</Tabs>
</main>
);
<TabsContent value="lancamentos">
<section className="flex flex-col gap-4">
<LancamentosSection
currentUserId={userId}
lancamentos={lancamentosData}
pagadorOptions={optionSets.pagadorOptions}
splitPagadorOptions={optionSets.splitPagadorOptions}
defaultPagadorId={pagador.id}
contaOptions={optionSets.contaOptions}
cartaoOptions={optionSets.cartaoOptions}
categoriaOptions={optionSets.categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={optionSets.categoriaFilterOptions}
contaCartaoFilterOptions={optionSets.contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos}
allowCreate={canEdit}
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
importSplitPagadorOptions={
loggedUserOptionSets?.splitPagadorOptions
}
importDefaultPagadorId={loggedUserOptionSets?.defaultPagadorId}
importContaOptions={loggedUserOptionSets?.contaOptions}
importCartaoOptions={loggedUserOptionSets?.cartaoOptions}
importCategoriaOptions={loggedUserOptionSets?.categoriaOptions}
/>
</section>
</TabsContent>
</Tabs>
</main>
);
}
const normalizeOptionLabel = (
value: string | null | undefined,
fallback: string
value: string | null | undefined,
fallback: string,
) => (value?.trim().length ? value.trim() : fallback);
function buildReadOnlyOptionSets(
items: LancamentoItem[],
pagador: typeof pagadores.$inferSelect
items: LancamentoItem[],
pagador: typeof pagadores.$inferSelect,
): OptionSet {
const pagadorLabel = normalizeOptionLabel(pagador.name, "Pagador");
const pagadorOptions: SelectOption[] = [
{
value: pagador.id,
label: pagadorLabel,
slug: pagador.id,
},
];
const pagadorLabel = normalizeOptionLabel(pagador.name, "Pagador");
const pagadorOptions: SelectOption[] = [
{
value: pagador.id,
label: pagadorLabel,
slug: pagador.id,
},
];
const contaOptionsMap = new Map<string, SelectOption>();
const cartaoOptionsMap = new Map<string, SelectOption>();
const categoriaOptionsMap = new Map<string, SelectOption>();
const contaOptionsMap = new Map<string, SelectOption>();
const cartaoOptionsMap = new Map<string, SelectOption>();
const categoriaOptionsMap = new Map<string, SelectOption>();
items.forEach((item) => {
if (item.contaId && !contaOptionsMap.has(item.contaId)) {
contaOptionsMap.set(item.contaId, {
value: item.contaId,
label: normalizeOptionLabel(item.contaName, "Conta sem nome"),
slug: item.contaId,
});
}
if (item.cartaoId && !cartaoOptionsMap.has(item.cartaoId)) {
cartaoOptionsMap.set(item.cartaoId, {
value: item.cartaoId,
label: normalizeOptionLabel(item.cartaoName, "Cartão sem nome"),
slug: item.cartaoId,
});
}
if (item.categoriaId && !categoriaOptionsMap.has(item.categoriaId)) {
categoriaOptionsMap.set(item.categoriaId, {
value: item.categoriaId,
label: normalizeOptionLabel(item.categoriaName, "Categoria"),
slug: item.categoriaId,
});
}
});
items.forEach((item) => {
if (item.contaId && !contaOptionsMap.has(item.contaId)) {
contaOptionsMap.set(item.contaId, {
value: item.contaId,
label: normalizeOptionLabel(item.contaName, "Conta sem nome"),
slug: item.contaId,
});
}
if (item.cartaoId && !cartaoOptionsMap.has(item.cartaoId)) {
cartaoOptionsMap.set(item.cartaoId, {
value: item.cartaoId,
label: normalizeOptionLabel(item.cartaoName, "Cartão sem nome"),
slug: item.cartaoId,
});
}
if (item.categoriaId && !categoriaOptionsMap.has(item.categoriaId)) {
categoriaOptionsMap.set(item.categoriaId, {
value: item.categoriaId,
label: normalizeOptionLabel(item.categoriaName, "Categoria"),
slug: item.categoriaId,
});
}
});
const contaOptions = Array.from(contaOptionsMap.values());
const cartaoOptions = Array.from(cartaoOptionsMap.values());
const categoriaOptions = Array.from(categoriaOptionsMap.values());
const contaOptions = Array.from(contaOptionsMap.values());
const cartaoOptions = Array.from(cartaoOptionsMap.values());
const categoriaOptions = Array.from(categoriaOptionsMap.values());
const pagadorFilterOptions: LancamentoFilterOption[] = [
{ slug: pagador.id, label: pagadorLabel },
];
const pagadorFilterOptions: LancamentoFilterOption[] = [
{ slug: pagador.id, label: pagadorLabel },
];
const categoriaFilterOptions: LancamentoFilterOption[] = categoriaOptions.map(
(option) => ({
slug: option.value,
label: option.label,
})
);
const categoriaFilterOptions: LancamentoFilterOption[] = categoriaOptions.map(
(option) => ({
slug: option.value,
label: option.label,
}),
);
const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [
...contaOptions.map((option) => ({
slug: option.value,
label: option.label,
kind: "conta" as const,
})),
...cartaoOptions.map((option) => ({
slug: option.value,
label: option.label,
kind: "cartao" as const,
})),
];
const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [
...contaOptions.map((option) => ({
slug: option.value,
label: option.label,
kind: "conta" as const,
})),
...cartaoOptions.map((option) => ({
slug: option.value,
label: option.label,
kind: "cartao" as const,
})),
];
return {
pagadorOptions,
splitPagadorOptions: [],
defaultPagadorId: pagador.id,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
};
return {
pagadorOptions,
splitPagadorOptions: [],
defaultPagadorId: pagador.id,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
};
}

View File

@@ -1,70 +1,70 @@
"use server";
import { randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { pagadores, pagadorShares, user } from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
DEFAULT_PAGADOR_AVATAR,
PAGADOR_ROLE_ADMIN,
PAGADOR_ROLE_TERCEIRO,
PAGADOR_STATUS_OPTIONS,
DEFAULT_PAGADOR_AVATAR,
PAGADOR_ROLE_ADMIN,
PAGADOR_ROLE_TERCEIRO,
PAGADOR_STATUS_OPTIONS,
} from "@/lib/pagadores/constants";
import { normalizeAvatarPath } from "@/lib/pagadores/utils";
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
import { normalizeOptionalString } from "@/lib/utils/string";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { randomBytes } from "node:crypto";
import { z } from "zod";
const statusEnum = z.enum(PAGADOR_STATUS_OPTIONS as [string, ...string[]], {
errorMap: () => ({
message: "Selecione um status válido.",
}),
errorMap: () => ({
message: "Selecione um status válido.",
}),
});
const baseSchema = z.object({
name: z
.string({ message: "Informe o nome do pagador." })
.trim()
.min(1, "Informe o nome do pagador."),
email: z
.string()
.trim()
.email("Informe um e-mail válido.")
.optional()
.transform((value) => normalizeOptionalString(value)),
status: statusEnum,
note: noteSchema,
avatarUrl: z.string().trim().optional(),
isAutoSend: z.boolean().optional().default(false),
name: z
.string({ message: "Informe o nome do pagador." })
.trim()
.min(1, "Informe o nome do pagador."),
email: z
.string()
.trim()
.email("Informe um e-mail válido.")
.optional()
.transform((value) => normalizeOptionalString(value)),
status: statusEnum,
note: noteSchema,
avatarUrl: z.string().trim().optional(),
isAutoSend: z.boolean().optional().default(false),
});
const createSchema = baseSchema;
const updateSchema = baseSchema.extend({
id: uuidSchema("Pagador"),
id: uuidSchema("Pagador"),
});
const deleteSchema = z.object({
id: uuidSchema("Pagador"),
id: uuidSchema("Pagador"),
});
const shareDeleteSchema = z.object({
shareId: uuidSchema("Compartilhamento"),
shareId: uuidSchema("Compartilhamento"),
});
const shareCodeJoinSchema = z.object({
code: z
.string({ message: "Informe o código." })
.trim()
.min(8, "Código inválido."),
code: z
.string({ message: "Informe o código." })
.trim()
.min(8, "Código inválido."),
});
const shareCodeRegenerateSchema = z.object({
pagadorId: uuidSchema("Pagador"),
pagadorId: uuidSchema("Pagador"),
});
type CreateInput = z.infer<typeof createSchema>;
@@ -77,271 +77,286 @@ type ShareCodeRegenerateInput = z.infer<typeof shareCodeRegenerateSchema>;
const revalidate = () => revalidateForEntity("pagadores");
const generateShareCode = () => {
// base64url já retorna apenas [a-zA-Z0-9_-]
// 18 bytes = 24 caracteres em base64
return randomBytes(18).toString("base64url").slice(0, 24);
// base64url já retorna apenas [a-zA-Z0-9_-]
// 18 bytes = 24 caracteres em base64
return randomBytes(18).toString("base64url").slice(0, 24);
};
export async function createPagadorAction(
input: CreateInput
input: CreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createSchema.parse(input);
try {
const user = await getUser();
const data = createSchema.parse(input);
await db.insert(pagadores).values({
name: data.name,
email: data.email,
status: data.status,
note: data.note,
avatarUrl: normalizeAvatarPath(data.avatarUrl) ?? DEFAULT_PAGADOR_AVATAR,
isAutoSend: data.isAutoSend ?? false,
role: PAGADOR_ROLE_TERCEIRO,
shareCode: generateShareCode(),
userId: user.id,
});
await db.insert(pagadores).values({
name: data.name,
email: data.email,
status: data.status,
note: data.note,
avatarUrl: normalizeAvatarPath(data.avatarUrl) ?? DEFAULT_PAGADOR_AVATAR,
isAutoSend: data.isAutoSend ?? false,
role: PAGADOR_ROLE_TERCEIRO,
shareCode: generateShareCode(),
userId: user.id,
});
revalidate();
revalidate();
return { success: true, message: "Pagador criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Pagador criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updatePagadorAction(
input: UpdateInput
input: UpdateInput,
): Promise<ActionResult> {
try {
const currentUser = await getUser();
const data = updateSchema.parse(input);
try {
const currentUser = await getUser();
const data = updateSchema.parse(input);
const existing = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)),
});
const existing = await db.query.pagadores.findFirst({
where: and(
eq(pagadores.id, data.id),
eq(pagadores.userId, currentUser.id),
),
});
if (!existing) {
return {
success: false,
error: "Pagador não encontrado.",
};
}
if (!existing) {
return {
success: false,
error: "Pagador não encontrado.",
};
}
await db
.update(pagadores)
.set({
name: data.name,
email: data.email,
status: data.status,
note: data.note,
avatarUrl:
normalizeAvatarPath(data.avatarUrl) ?? existing.avatarUrl ?? null,
isAutoSend: data.isAutoSend ?? false,
role: existing.role ?? PAGADOR_ROLE_TERCEIRO,
})
.where(and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)));
await db
.update(pagadores)
.set({
name: data.name,
email: data.email,
status: data.status,
note: data.note,
avatarUrl:
normalizeAvatarPath(data.avatarUrl) ?? existing.avatarUrl ?? null,
isAutoSend: data.isAutoSend ?? false,
role: existing.role ?? PAGADOR_ROLE_TERCEIRO,
})
.where(
and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)),
);
// Se o pagador é admin, sincronizar nome com o usuário
if (existing.role === PAGADOR_ROLE_ADMIN) {
await db
.update(user)
.set({ name: data.name })
.where(eq(user.id, currentUser.id));
// Se o pagador é admin, sincronizar nome com o usuário
if (existing.role === PAGADOR_ROLE_ADMIN) {
await db
.update(user)
.set({ name: data.name })
.where(eq(user.id, currentUser.id));
revalidatePath("/", "layout");
}
revalidatePath("/", "layout");
}
revalidate();
revalidate();
return { success: true, message: "Pagador atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Pagador atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deletePagadorAction(
input: DeleteInput
input: DeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteSchema.parse(input);
try {
const user = await getUser();
const data = deleteSchema.parse(input);
const existing = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)),
});
const existing = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)),
});
if (!existing) {
return {
success: false,
error: "Pagador não encontrado.",
};
}
if (!existing) {
return {
success: false,
error: "Pagador não encontrado.",
};
}
if (existing.role === PAGADOR_ROLE_ADMIN) {
return {
success: false,
error: "Pagadores administradores não podem ser removidos.",
};
}
if (existing.role === PAGADOR_ROLE_ADMIN) {
return {
success: false,
error: "Pagadores administradores não podem ser removidos.",
};
}
await db
.delete(pagadores)
.where(and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)));
await db
.delete(pagadores)
.where(and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)));
revalidate();
revalidate();
return { success: true, message: "Pagador removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Pagador removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function joinPagadorByShareCodeAction(
input: ShareCodeJoinInput
input: ShareCodeJoinInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = shareCodeJoinSchema.parse(input);
try {
const user = await getUser();
const data = shareCodeJoinSchema.parse(input);
const pagadorRow = await db.query.pagadores.findFirst({
where: eq(pagadores.shareCode, data.code),
});
const pagadorRow = await db.query.pagadores.findFirst({
where: eq(pagadores.shareCode, data.code),
});
if (!pagadorRow) {
return { success: false, error: "Código inválido ou expirado." };
}
if (!pagadorRow) {
return { success: false, error: "Código inválido ou expirado." };
}
if (pagadorRow.userId === user.id) {
return {
success: false,
error: "Você já é o proprietário deste pagador.",
};
}
if (pagadorRow.userId === user.id) {
return {
success: false,
error: "Você já é o proprietário deste pagador.",
};
}
const existingShare = await db.query.pagadorShares.findFirst({
where: and(
eq(pagadorShares.pagadorId, pagadorRow.id),
eq(pagadorShares.sharedWithUserId, user.id)
),
});
const existingShare = await db.query.pagadorShares.findFirst({
where: and(
eq(pagadorShares.pagadorId, pagadorRow.id),
eq(pagadorShares.sharedWithUserId, user.id),
),
});
if (existingShare) {
return {
success: false,
error: "Você já possui acesso a este pagador.",
};
}
if (existingShare) {
return {
success: false,
error: "Você já possui acesso a este pagador.",
};
}
await db.insert(pagadorShares).values({
pagadorId: pagadorRow.id,
sharedWithUserId: user.id,
permission: "read",
createdByUserId: pagadorRow.userId,
});
await db.insert(pagadorShares).values({
pagadorId: pagadorRow.id,
sharedWithUserId: user.id,
permission: "read",
createdByUserId: pagadorRow.userId,
});
revalidate();
revalidate();
return { success: true, message: "Pagador adicionado à sua lista." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Pagador adicionado à sua lista." };
} catch (error) {
return handleActionError(error);
}
}
export async function deletePagadorShareAction(
input: ShareDeleteInput
input: ShareDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = shareDeleteSchema.parse(input);
try {
const user = await getUser();
const data = shareDeleteSchema.parse(input);
const existing = await db.query.pagadorShares.findFirst({
columns: {
id: true,
pagadorId: true,
sharedWithUserId: true,
},
where: eq(pagadorShares.id, data.shareId),
with: {
pagador: {
columns: {
userId: true,
},
},
},
});
const existing = await db.query.pagadorShares.findFirst({
columns: {
id: true,
pagadorId: true,
sharedWithUserId: true,
},
where: eq(pagadorShares.id, data.shareId),
with: {
pagador: {
columns: {
userId: true,
},
},
},
});
// Permitir que o owner OU o próprio usuário compartilhado remova o share
if (!existing || (existing.pagador.userId !== user.id && existing.sharedWithUserId !== user.id)) {
return {
success: false,
error: "Compartilhamento não encontrado.",
};
}
// Permitir que o owner OU o próprio usuário compartilhado remova o share
if (
!existing ||
(existing.pagador.userId !== user.id &&
existing.sharedWithUserId !== user.id)
) {
return {
success: false,
error: "Compartilhamento não encontrado.",
};
}
await db
.delete(pagadorShares)
.where(eq(pagadorShares.id, data.shareId));
await db.delete(pagadorShares).where(eq(pagadorShares.id, data.shareId));
revalidate();
revalidatePath(`/pagadores/${existing.pagadorId}`);
revalidate();
revalidatePath(`/pagadores/${existing.pagadorId}`);
return { success: true, message: "Compartilhamento removido." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Compartilhamento removido." };
} catch (error) {
return handleActionError(error);
}
}
export async function regeneratePagadorShareCodeAction(
input: ShareCodeRegenerateInput
input: ShareCodeRegenerateInput,
): Promise<{ success: true; message: string; code: string } | ActionResult> {
try {
const user = await getUser();
const data = shareCodeRegenerateSchema.parse(input);
try {
const user = await getUser();
const data = shareCodeRegenerateSchema.parse(input);
const existing = await db.query.pagadores.findFirst({
columns: { id: true, userId: true },
where: and(eq(pagadores.id, data.pagadorId), eq(pagadores.userId, user.id)),
});
const existing = await db.query.pagadores.findFirst({
columns: { id: true, userId: true },
where: and(
eq(pagadores.id, data.pagadorId),
eq(pagadores.userId, user.id),
),
});
if (!existing) {
return { success: false, error: "Pagador não encontrado." };
}
if (!existing) {
return { success: false, error: "Pagador não encontrado." };
}
let attempts = 0;
while (attempts < 5) {
const newCode = generateShareCode();
try {
await db
.update(pagadores)
.set({ shareCode: newCode })
.where(and(eq(pagadores.id, data.pagadorId), eq(pagadores.userId, user.id)));
let attempts = 0;
while (attempts < 5) {
const newCode = generateShareCode();
try {
await db
.update(pagadores)
.set({ shareCode: newCode })
.where(
and(
eq(pagadores.id, data.pagadorId),
eq(pagadores.userId, user.id),
),
);
revalidate();
revalidatePath(`/pagadores/${data.pagadorId}`);
return {
success: true,
message: "Código atualizado com sucesso.",
code: newCode,
};
} catch (error) {
if (
error instanceof Error &&
"constraint" in error &&
// @ts-expect-error constraint is present in postgres errors
error.constraint === "pagadores_share_code_key"
) {
attempts += 1;
continue;
}
throw error;
}
}
revalidate();
revalidatePath(`/pagadores/${data.pagadorId}`);
return {
success: true,
message: "Código atualizado com sucesso.",
code: newCode,
};
} catch (error) {
if (
error instanceof Error &&
"constraint" in error &&
// @ts-expect-error constraint is present in postgres errors
error.constraint === "pagadores_share_code_key"
) {
attempts += 1;
continue;
}
throw error;
}
}
return {
success: false,
error: "Não foi possível gerar um código único. Tente novamente.",
};
} catch (error) {
return handleActionError(error);
}
return {
success: false,
error: "Não foi possível gerar um código único. Tente novamente.",
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiGroupLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Pagadores | Opensheets",
title: "Pagadores | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiGroupLine />}
title="Pagadores"
subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos."
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiGroupLine />}
title="Pagadores"
subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos."
/>
{children}
</section>
);
}

View File

@@ -5,53 +5,53 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: Header + Input de compartilhamento + Grid de cards
*/
export default function PagadoresLoading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full space-y-6">
{/* Input de código de compartilhamento */}
<div className="rounded-2xl border p-4 space-y-3">
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
<div className="flex gap-2">
<Skeleton className="h-10 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-32 rounded-2xl bg-foreground/10" />
</div>
</div>
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full space-y-6">
{/* Input de código de compartilhamento */}
<div className="rounded-2xl border p-4 space-y-3">
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
<div className="flex gap-2">
<Skeleton className="h-10 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-32 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Grid de cards de pagadores */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
{/* Avatar + Nome + Badge */}
<div className="flex items-start gap-4">
<Skeleton className="size-16 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" />
</div>
{i === 0 && (
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
)}
</div>
{/* Grid de cards de pagadores */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
{/* Avatar + Nome + Badge */}
<div className="flex items-start gap-4">
<Skeleton className="size-16 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" />
</div>
{i === 0 && (
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
)}
</div>
{/* Email */}
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
{/* Email */}
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
{/* Status */}
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
{/* Status */}
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
{/* Botões de ação */}
<div className="flex gap-2 pt-2 border-t">
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
{/* Botões de ação */}
<div className="flex gap-2 pt-2 border-t">
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -1,86 +1,86 @@
import { PagadoresPage } from "@/components/pagadores/pagadores-page";
import type { PagadorStatus } from "@/lib/pagadores/constants";
import {
PAGADOR_STATUS_OPTIONS,
DEFAULT_PAGADOR_AVATAR,
PAGADOR_ROLE_ADMIN,
} from "@/lib/pagadores/constants";
import { getUserId } from "@/lib/auth/server";
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
import { readdir } from "node:fs/promises";
import path from "node:path";
import { PagadoresPage } from "@/components/pagadores/pagadores-page";
import { getUserId } from "@/lib/auth/server";
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
import type { PagadorStatus } from "@/lib/pagadores/constants";
import {
DEFAULT_PAGADOR_AVATAR,
PAGADOR_ROLE_ADMIN,
PAGADOR_STATUS_OPTIONS,
} from "@/lib/pagadores/constants";
const AVATAR_DIRECTORY = path.join(process.cwd(), "public", "avatares");
const AVATAR_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
async function loadAvatarOptions() {
try {
const files = await readdir(AVATAR_DIRECTORY, { withFileTypes: true });
try {
const files = await readdir(AVATAR_DIRECTORY, { withFileTypes: true });
const items = files
.filter((file) => file.isFile())
.map((file) => file.name)
.filter((file) => AVATAR_EXTENSIONS.has(path.extname(file).toLowerCase()))
.sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
const items = files
.filter((file) => file.isFile())
.map((file) => file.name)
.filter((file) => AVATAR_EXTENSIONS.has(path.extname(file).toLowerCase()))
.sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
if (items.length === 0) {
items.push(DEFAULT_PAGADOR_AVATAR);
}
if (items.length === 0) {
items.push(DEFAULT_PAGADOR_AVATAR);
}
return Array.from(new Set(items));
} catch {
return [DEFAULT_PAGADOR_AVATAR];
}
return Array.from(new Set(items));
} catch {
return [DEFAULT_PAGADOR_AVATAR];
}
}
const resolveStatus = (status: string | null): PagadorStatus => {
const normalized = status?.trim() ?? "";
const found = PAGADOR_STATUS_OPTIONS.find(
(option) => option.toLowerCase() === normalized.toLowerCase()
);
return found ?? PAGADOR_STATUS_OPTIONS[0];
const normalized = status?.trim() ?? "";
const found = PAGADOR_STATUS_OPTIONS.find(
(option) => option.toLowerCase() === normalized.toLowerCase(),
);
return found ?? PAGADOR_STATUS_OPTIONS[0];
};
export default async function Page() {
const userId = await getUserId();
const userId = await getUserId();
const [pagadorRows, avatarOptions] = await Promise.all([
fetchPagadoresWithAccess(userId),
loadAvatarOptions(),
]);
const [pagadorRows, avatarOptions] = await Promise.all([
fetchPagadoresWithAccess(userId),
loadAvatarOptions(),
]);
const pagadoresData = pagadorRows
.map((pagador) => ({
id: pagador.id,
name: pagador.name,
email: pagador.email,
avatarUrl: pagador.avatarUrl,
status: resolveStatus(pagador.status),
note: pagador.note,
role: pagador.role,
isAutoSend: pagador.isAutoSend ?? false,
createdAt: pagador.createdAt?.toISOString() ?? new Date().toISOString(),
canEdit: pagador.canEdit,
sharedByName: pagador.sharedByName ?? null,
sharedByEmail: pagador.sharedByEmail ?? null,
shareId: pagador.shareId ?? null,
shareCode: pagador.canEdit ? pagador.shareCode ?? null : null,
}))
.sort((a, b) => {
// Admin sempre primeiro
if (a.role === PAGADOR_ROLE_ADMIN && b.role !== PAGADOR_ROLE_ADMIN) {
return -1;
}
if (a.role !== PAGADOR_ROLE_ADMIN && b.role === PAGADOR_ROLE_ADMIN) {
return 1;
}
// Se ambos são admin ou ambos não são, mantém ordem original
return 0;
});
const pagadoresData = pagadorRows
.map((pagador) => ({
id: pagador.id,
name: pagador.name,
email: pagador.email,
avatarUrl: pagador.avatarUrl,
status: resolveStatus(pagador.status),
note: pagador.note,
role: pagador.role,
isAutoSend: pagador.isAutoSend ?? false,
createdAt: pagador.createdAt?.toISOString() ?? new Date().toISOString(),
canEdit: pagador.canEdit,
sharedByName: pagador.sharedByName ?? null,
sharedByEmail: pagador.sharedByEmail ?? null,
shareId: pagador.shareId ?? null,
shareCode: pagador.canEdit ? (pagador.shareCode ?? null) : null,
}))
.sort((a, b) => {
// Admin sempre primeiro
if (a.role === PAGADOR_ROLE_ADMIN && b.role !== PAGADOR_ROLE_ADMIN) {
return -1;
}
if (a.role !== PAGADOR_ROLE_ADMIN && b.role === PAGADOR_ROLE_ADMIN) {
return 1;
}
// Se ambos são admin ou ambos não são, mantém ordem original
return 0;
});
return (
<main className="flex flex-col items-start gap-6">
<PagadoresPage pagadores={pagadoresData} avatarOptions={avatarOptions} />
</main>
);
return (
<main className="flex flex-col items-start gap-6">
<PagadoresPage pagadores={pagadoresData} avatarOptions={avatarOptions} />
</main>
);
}

View File

@@ -1,149 +1,149 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { inboxItems } from "@/db/schema";
import { handleActionError } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const markProcessedSchema = z.object({
inboxItemId: z.string().uuid("ID do item inválido"),
inboxItemId: z.string().uuid("ID do item inválido"),
});
const discardInboxSchema = z.object({
inboxItemId: z.string().uuid("ID do item inválido"),
inboxItemId: z.string().uuid("ID do item inválido"),
});
const bulkDiscardSchema = z.object({
inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"),
inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"),
});
function revalidateInbox() {
revalidatePath("/pre-lancamentos");
revalidatePath("/lancamentos");
revalidatePath("/dashboard");
revalidatePath("/pre-lancamentos");
revalidatePath("/lancamentos");
revalidatePath("/dashboard");
}
/**
* Mark an inbox item as processed after a lancamento was created
*/
export async function markInboxAsProcessedAction(
input: z.infer<typeof markProcessedSchema>,
input: z.infer<typeof markProcessedSchema>,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = markProcessedSchema.parse(input);
try {
const user = await getUser();
const data = markProcessedSchema.parse(input);
// Verificar se item existe e pertence ao usuário
const [item] = await db
.select()
.from(inboxItems)
.where(
and(
eq(inboxItems.id, data.inboxItemId),
eq(inboxItems.userId, user.id),
eq(inboxItems.status, "pending"),
),
)
.limit(1);
// Verificar se item existe e pertence ao usuário
const [item] = await db
.select()
.from(inboxItems)
.where(
and(
eq(inboxItems.id, data.inboxItemId),
eq(inboxItems.userId, user.id),
eq(inboxItems.status, "pending"),
),
)
.limit(1);
if (!item) {
return { success: false, error: "Item não encontrado ou já processado." };
}
if (!item) {
return { success: false, error: "Item não encontrado ou já processado." };
}
// Marcar item como processado
await db
.update(inboxItems)
.set({
status: "processed",
processedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(inboxItems.id, data.inboxItemId));
// Marcar item como processado
await db
.update(inboxItems)
.set({
status: "processed",
processedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(inboxItems.id, data.inboxItemId));
revalidateInbox();
revalidateInbox();
return { success: true, message: "Item processado com sucesso!" };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Item processado com sucesso!" };
} catch (error) {
return handleActionError(error);
}
}
export async function discardInboxItemAction(
input: z.infer<typeof discardInboxSchema>,
input: z.infer<typeof discardInboxSchema>,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = discardInboxSchema.parse(input);
try {
const user = await getUser();
const data = discardInboxSchema.parse(input);
// Verificar se item existe e pertence ao usuário
const [item] = await db
.select()
.from(inboxItems)
.where(
and(
eq(inboxItems.id, data.inboxItemId),
eq(inboxItems.userId, user.id),
eq(inboxItems.status, "pending"),
),
)
.limit(1);
// Verificar se item existe e pertence ao usuário
const [item] = await db
.select()
.from(inboxItems)
.where(
and(
eq(inboxItems.id, data.inboxItemId),
eq(inboxItems.userId, user.id),
eq(inboxItems.status, "pending"),
),
)
.limit(1);
if (!item) {
return { success: false, error: "Item não encontrado ou já processado." };
}
if (!item) {
return { success: false, error: "Item não encontrado ou já processado." };
}
// Marcar item como descartado
await db
.update(inboxItems)
.set({
status: "discarded",
discardedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(inboxItems.id, data.inboxItemId));
// Marcar item como descartado
await db
.update(inboxItems)
.set({
status: "discarded",
discardedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(inboxItems.id, data.inboxItemId));
revalidateInbox();
revalidateInbox();
return { success: true, message: "Item descartado." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Item descartado." };
} catch (error) {
return handleActionError(error);
}
}
export async function bulkDiscardInboxItemsAction(
input: z.infer<typeof bulkDiscardSchema>,
input: z.infer<typeof bulkDiscardSchema>,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = bulkDiscardSchema.parse(input);
try {
const user = await getUser();
const data = bulkDiscardSchema.parse(input);
// Marcar todos os itens como descartados
await db
.update(inboxItems)
.set({
status: "discarded",
discardedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
inArray(inboxItems.id, data.inboxItemIds),
eq(inboxItems.userId, user.id),
eq(inboxItems.status, "pending"),
),
);
// Marcar todos os itens como descartados
await db
.update(inboxItems)
.set({
status: "discarded",
discardedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
inArray(inboxItems.id, data.inboxItemIds),
eq(inboxItems.userId, user.id),
eq(inboxItems.status, "pending"),
),
);
revalidateInbox();
revalidateInbox();
return {
success: true,
message: `${data.inboxItemIds.length} item(s) descartado(s).`,
};
} catch (error) {
return handleActionError(error);
}
return {
success: true,
message: `${data.inboxItemIds.length} item(s) descartado(s).`,
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -2,153 +2,166 @@
* Data fetching functions for Pré-Lançamentos
*/
import { db } from "@/lib/db";
import { inboxItems, categorias, contas, cartoes, lancamentos } from "@/db/schema";
import { eq, desc, and, gte } from "drizzle-orm";
import type { InboxItem, SelectOption } from "@/components/pre-lancamentos/types";
import { and, desc, eq, gte } from "drizzle-orm";
import type {
InboxItem,
SelectOption,
} from "@/components/pre-lancamentos/types";
import {
fetchLancamentoFilterSources,
buildSluggedFilters,
buildOptionSets,
cartoes,
categorias,
contas,
inboxItems,
lancamentos,
} from "@/db/schema";
import { db } from "@/lib/db";
import {
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
} from "@/lib/lancamentos/page-helpers";
export async function fetchInboxItems(
userId: string,
status: "pending" | "processed" | "discarded" = "pending"
userId: string,
status: "pending" | "processed" | "discarded" = "pending",
): Promise<InboxItem[]> {
const items = await db
.select()
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)))
.orderBy(desc(inboxItems.createdAt));
const items = await db
.select()
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)))
.orderBy(desc(inboxItems.createdAt));
return items;
return items;
}
export async function fetchInboxItemById(
userId: string,
itemId: string
userId: string,
itemId: string,
): Promise<InboxItem | null> {
const [item] = await db
.select()
.from(inboxItems)
.where(and(eq(inboxItems.id, itemId), eq(inboxItems.userId, userId)))
.limit(1);
const [item] = await db
.select()
.from(inboxItems)
.where(and(eq(inboxItems.id, itemId), eq(inboxItems.userId, userId)))
.limit(1);
return item ?? null;
return item ?? null;
}
export async function fetchCategoriasForSelect(
userId: string,
type?: string
userId: string,
type?: string,
): Promise<SelectOption[]> {
const query = db
.select({ id: categorias.id, name: categorias.name })
.from(categorias)
.where(
type
? and(eq(categorias.userId, userId), eq(categorias.type, type))
: eq(categorias.userId, userId)
)
.orderBy(categorias.name);
const query = db
.select({ id: categorias.id, name: categorias.name })
.from(categorias)
.where(
type
? and(eq(categorias.userId, userId), eq(categorias.type, type))
: eq(categorias.userId, userId),
)
.orderBy(categorias.name);
return query;
return query;
}
export async function fetchContasForSelect(userId: string): Promise<SelectOption[]> {
const items = await db
.select({ id: contas.id, name: contas.name })
.from(contas)
.where(and(eq(contas.userId, userId), eq(contas.status, "ativo")))
.orderBy(contas.name);
export async function fetchContasForSelect(
userId: string,
): Promise<SelectOption[]> {
const items = await db
.select({ id: contas.id, name: contas.name })
.from(contas)
.where(and(eq(contas.userId, userId), eq(contas.status, "ativo")))
.orderBy(contas.name);
return items;
return items;
}
export async function fetchCartoesForSelect(
userId: string
userId: string,
): Promise<(SelectOption & { lastDigits?: string })[]> {
const items = await db
.select({ id: cartoes.id, name: cartoes.name })
.from(cartoes)
.where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo")))
.orderBy(cartoes.name);
const items = await db
.select({ id: cartoes.id, name: cartoes.name })
.from(cartoes)
.where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo")))
.orderBy(cartoes.name);
return items;
return items;
}
export async function fetchPendingInboxCount(userId: string): Promise<number> {
const items = await db
.select({ id: inboxItems.id })
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")));
const items = await db
.select({ id: inboxItems.id })
.from(inboxItems)
.where(
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
);
return items.length;
return items.length;
}
/**
* Fetch all data needed for the LancamentoDialog in inbox context
*/
export async function fetchInboxDialogData(userId: string): Promise<{
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
}> {
const filterSources = await fetchLancamentoFilterSources(userId);
const sluggedFilters = buildSluggedFilters(filterSources);
const filterSources = await fetchLancamentoFilterSources(userId);
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
// Fetch recent establishments (same approach as getRecentEstablishmentsAction)
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
// Fetch recent establishments (same approach as getRecentEstablishmentsAction)
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const recentEstablishments = await db
.select({ name: lancamentos.name })
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
gte(lancamentos.purchaseDate, threeMonthsAgo)
)
)
.orderBy(desc(lancamentos.purchaseDate));
const recentEstablishments = await db
.select({ name: lancamentos.name })
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
gte(lancamentos.purchaseDate, threeMonthsAgo),
),
)
.orderBy(desc(lancamentos.purchaseDate));
// Remove duplicates and filter empty names
const filteredNames: string[] = recentEstablishments
.map((r: { name: string }) => r.name)
.filter(
(name: string | null): name is string =>
name != null &&
name.trim().length > 0 &&
!name.toLowerCase().startsWith("pagamento fatura")
);
const estabelecimentos = Array.from<string>(new Set(filteredNames)).slice(
0,
100
);
// Remove duplicates and filter empty names
const filteredNames: string[] = recentEstablishments
.map((r: { name: string }) => r.name)
.filter(
(name: string | null): name is string =>
name != null &&
name.trim().length > 0 &&
!name.toLowerCase().startsWith("pagamento fatura"),
);
const estabelecimentos = Array.from<string>(new Set(filteredNames)).slice(
0,
100,
);
return {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
};
return {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
};
}

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiInboxLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Pré-Lançamentos | Opensheets",
title: "Pré-Lançamentos | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiInboxLine />}
title="Pré-Lançamentos"
subtitle="Notificações capturadas aguardando processamento"
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiInboxLine />}
title="Pré-Lançamentos"
subtitle="Notificações capturadas aguardando processamento"
/>
{children}
</section>
);
}

View File

@@ -2,32 +2,32 @@ import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="flex w-full flex-col gap-6">
<div className="flex justify-between">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-10 w-32" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="p-4">
<div className="space-y-3">
<div className="flex justify-between">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-16" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-2 pt-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
</Card>
))}
</div>
</div>
</main>
);
return (
<main className="flex flex-col items-start gap-6">
<div className="flex w-full flex-col gap-6">
<div className="flex justify-between">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-10 w-32" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="p-4">
<div className="space-y-3">
<div className="flex justify-between">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-16" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-2 pt-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
</Card>
))}
</div>
</div>
</main>
);
}

View File

@@ -3,25 +3,25 @@ import { getUserId } from "@/lib/auth/server";
import { fetchInboxDialogData, fetchInboxItems } from "./data";
export default async function Page() {
const userId = await getUserId();
const userId = await getUserId();
const [items, dialogData] = await Promise.all([
fetchInboxItems(userId, "pending"),
fetchInboxDialogData(userId),
]);
const [items, dialogData] = await Promise.all([
fetchInboxItems(userId, "pending"),
fetchInboxDialogData(userId),
]);
return (
<main className="flex flex-col items-start gap-6">
<InboxPage
items={items}
pagadorOptions={dialogData.pagadorOptions}
splitPagadorOptions={dialogData.splitPagadorOptions}
defaultPagadorId={dialogData.defaultPagadorId}
contaOptions={dialogData.contaOptions}
cartaoOptions={dialogData.cartaoOptions}
categoriaOptions={dialogData.categoriaOptions}
estabelecimentos={dialogData.estabelecimentos}
/>
</main>
);
return (
<main className="flex flex-col items-start gap-6">
<InboxPage
items={items}
pagadorOptions={dialogData.pagadorOptions}
splitPagadorOptions={dialogData.splitPagadorOptions}
defaultPagadorId={dialogData.defaultPagadorId}
contaOptions={dialogData.contaOptions}
cartaoOptions={dialogData.cartaoOptions}
categoriaOptions={dialogData.categoriaOptions}
estabelecimentos={dialogData.estabelecimentos}
/>
</main>
);
}

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiBankCard2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Relatório de Cartões | Opensheets",
title: "Relatório de Cartões | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiBankCard2Line />}
title="Relatório de Cartões"
subtitle="Análise detalhada do uso dos seus cartões de crédito."
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiBankCard2Line />}
title="Relatório de Cartões"
subtitle="Análise detalhada do uso dos seus cartões de crédito."
/>
{children}
</section>
);
}

View File

@@ -2,84 +2,84 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-4 px-6">
<div className="flex flex-col gap-1">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
</div>
return (
<main className="flex flex-col gap-4 px-6">
<div className="flex flex-col gap-1">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
</div>
<Skeleton className="h-10 w-full max-w-md" />
<Skeleton className="h-10 w-full max-w-md" />
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-1">
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-1">
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
<div className="lg:col-span-2 space-y-4">
<Skeleton className="h-8 w-48" />
<div className="lg:col-span-2 space-y-4">
<Skeleton className="h-8 w-48" />
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-[280px] w-full" />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-[280px] w-full" />
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
</main>
);
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
</main>
);
}

View File

@@ -1,3 +1,4 @@
import { RiBankCard2Line } from "@remixicon/react";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { CardCategoryBreakdown } from "@/components/relatorios/cartoes/card-category-breakdown";
import { CardInvoiceStatus } from "@/components/relatorios/cartoes/card-invoice-status";
@@ -7,79 +8,78 @@ import { CardsOverview } from "@/components/relatorios/cartoes/cards-overview";
import { getUser } from "@/lib/auth/server";
import { fetchCartoesReportData } from "@/lib/relatorios/cartoes-report";
import { parsePeriodParam } from "@/lib/utils/period";
import { RiBankCard2Line } from "@remixicon/react";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function RelatorioCartoesPage({
searchParams,
searchParams,
}: PageProps) {
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const cartaoParam = getSingleParam(resolvedSearchParams, "cartao");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const cartaoParam = getSingleParam(resolvedSearchParams, "cartao");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const data = await fetchCartoesReportData(
user.id,
selectedPeriod,
cartaoParam,
);
const data = await fetchCartoesReportData(
user.id,
selectedPeriod,
cartaoParam,
);
return (
<main className="flex flex-col gap-4">
<MonthNavigation />
return (
<main className="flex flex-col gap-4">
<MonthNavigation />
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-1">
<CardsOverview data={data} />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-1">
<CardsOverview data={data} />
</div>
<div className="lg:col-span-2 space-y-4">
{data.selectedCard ? (
<>
<CardUsageChart
data={data.selectedCard.monthlyUsage}
limit={data.selectedCard.card.limit}
card={{
name: data.selectedCard.card.name,
logo: data.selectedCard.card.logo,
}}
/>
<div className="lg:col-span-2 space-y-4">
{data.selectedCard ? (
<>
<CardUsageChart
data={data.selectedCard.monthlyUsage}
limit={data.selectedCard.card.limit}
card={{
name: data.selectedCard.card.name,
logo: data.selectedCard.card.logo,
}}
/>
<div className="grid gap-4 md:grid-cols-2">
<CardCategoryBreakdown
data={data.selectedCard.categoryBreakdown}
/>
<CardTopExpenses data={data.selectedCard.topExpenses} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<CardCategoryBreakdown
data={data.selectedCard.categoryBreakdown}
/>
<CardTopExpenses data={data.selectedCard.topExpenses} />
</div>
<CardInvoiceStatus data={data.selectedCard.invoiceStatus} />
</>
) : (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<RiBankCard2Line className="size-12 mb-4" />
<p className="text-lg font-medium">Nenhum cartão selecionado</p>
<p className="text-sm">
Selecione um cartão na lista ao lado para ver detalhes.
</p>
</div>
)}
</div>
</div>
</main>
);
<CardInvoiceStatus data={data.selectedCard.invoiceStatus} />
</>
) : (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<RiBankCard2Line className="size-12 mb-4" />
<p className="text-lg font-medium">Nenhum cartão selecionado</p>
<p className="text-sm">
Selecione um cartão na lista ao lado para ver detalhes.
</p>
</div>
)}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,12 @@
import { asc, eq } from "drizzle-orm";
import { type Categoria, categorias } from "@/db/schema";
import { db } from "@/lib/db";
export async function fetchUserCategories(
userId: string,
): Promise<Categoria[]> {
return db.query.categorias.findMany({
where: eq(categorias.userId, userId),
orderBy: [asc(categorias.name)],
});
}

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiFileChartLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Relatórios | Opensheets",
title: "Relatórios | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiFileChartLine />}
title="Relatórios de Categorias"
subtitle="Acompanhe a evolução dos seus gastos e receitas por categoria ao longo do tempo."
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiFileChartLine />}
title="Relatórios de Categorias"
subtitle="Acompanhe a evolução dos seus gastos e receitas por categoria ao longo do tempo."
/>
{children}
</section>
);
}

View File

@@ -1,9 +1,9 @@
import { CategoryReportSkeleton } from "@/components/skeletons/category-report-skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-6">
<CategoryReportSkeleton />
</main>
);
return (
<main className="flex flex-col gap-6">
<CategoryReportSkeleton />
</main>
);
}

View File

@@ -1,118 +1,114 @@
import { CategoryReportPage } from "@/components/relatorios/category-report-page";
import { getUserId } from "@/lib/auth/server";
import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period";
import { validateDateRange } from "@/lib/relatorios/utils";
import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report";
import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
import type { CategoryReportFilters } from "@/lib/relatorios/types";
import type {
CategoryOption,
FilterState,
} from "@/components/relatorios/types";
import { db } from "@/lib/db";
import { categorias, type Categoria } from "@/db/schema";
import { eq, asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import { CategoryReportPage } from "@/components/relatorios/category-report-page";
import type {
CategoryOption,
FilterState,
} from "@/components/relatorios/types";
import type { Categoria } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report";
import type { CategoryReportFilters } from "@/lib/relatorios/types";
import { validateDateRange } from "@/lib/relatorios/utils";
import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period";
import { fetchUserCategories } from "./data";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string
params: Record<string, string | string[] | undefined> | undefined,
key: string,
): string | null => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? value[0] ?? null : value;
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
// Get authenticated user
const userId = await getUserId();
// Get authenticated user
const userId = await getUserId();
// Resolve search params
const resolvedSearchParams = searchParams ? await searchParams : undefined;
// Resolve search params
const resolvedSearchParams = searchParams ? await searchParams : undefined;
// Extract query params
const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
const fimParam = getSingleParam(resolvedSearchParams, "fim");
const categoriasParam = getSingleParam(resolvedSearchParams, "categorias");
// Extract query params
const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
const fimParam = getSingleParam(resolvedSearchParams, "fim");
const categoriasParam = getSingleParam(resolvedSearchParams, "categorias");
// Calculate default period (last 6 months)
const currentPeriod = getCurrentPeriod();
const defaultStartPeriod = addMonthsToPeriod(currentPeriod, -5); // 6 months including current
// Calculate default period (last 6 months)
const currentPeriod = getCurrentPeriod();
const defaultStartPeriod = addMonthsToPeriod(currentPeriod, -5); // 6 months including current
// Use params or defaults
const startPeriod = inicioParam ?? defaultStartPeriod;
const endPeriod = fimParam ?? currentPeriod;
// Use params or defaults
const startPeriod = inicioParam ?? defaultStartPeriod;
const endPeriod = fimParam ?? currentPeriod;
// Parse selected categories
const selectedCategoryIds = categoriasParam
? categoriasParam.split(",").filter(Boolean)
: [];
// Parse selected categories
const selectedCategoryIds = categoriasParam
? categoriasParam.split(",").filter(Boolean)
: [];
// Validate date range
const validation = validateDateRange(startPeriod, endPeriod);
if (!validation.isValid) {
// Redirect to default if validation fails
redirect(
`/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`
);
}
// Validate date range
const validation = validateDateRange(startPeriod, endPeriod);
if (!validation.isValid) {
// Redirect to default if validation fails
redirect(
`/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`,
);
}
// Fetch all categories for the user
const categoriaRows = await db.query.categorias.findMany({
where: eq(categorias.userId, userId),
orderBy: [asc(categorias.name)],
});
// Fetch all categories for the user
const categoriaRows = await fetchUserCategories(userId);
// Map to CategoryOption format
const categoryOptions: CategoryOption[] = categoriaRows.map(
(cat: Categoria): CategoryOption => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type as "despesa" | "receita",
})
);
// Map to CategoryOption format
const categoryOptions: CategoryOption[] = categoriaRows.map(
(cat: Categoria): CategoryOption => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type as "despesa" | "receita",
}),
);
// Build filters for data fetching
const filters: CategoryReportFilters = {
startPeriod,
endPeriod,
categoryIds:
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
};
// Build filters for data fetching
const filters: CategoryReportFilters = {
startPeriod,
endPeriod,
categoryIds:
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
};
// Fetch report data
const reportData = await fetchCategoryReport(userId, filters);
// Fetch report data
const reportData = await fetchCategoryReport(userId, filters);
// Fetch chart data with same filters
const chartData = await fetchCategoryChartData(
userId,
startPeriod,
endPeriod,
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined
);
// Fetch chart data with same filters
const chartData = await fetchCategoryChartData(
userId,
startPeriod,
endPeriod,
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
);
// Build initial filter state for client component
const initialFilters: FilterState = {
selectedCategories: selectedCategoryIds,
startPeriod,
endPeriod,
};
// Build initial filter state for client component
const initialFilters: FilterState = {
selectedCategories: selectedCategoryIds,
startPeriod,
endPeriod,
};
return (
<main className="flex flex-col gap-6">
<CategoryReportPage
initialData={reportData}
categories={categoryOptions}
initialFilters={initialFilters}
chartData={chartData}
/>
</main>
);
return (
<main className="flex flex-col gap-6">
<CategoryReportPage
initialData={reportData}
categories={categoryOptions}
initialFilters={initialFilters}
chartData={chartData}
/>
</main>
);
}

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiStore2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Top Estabelecimentos | Opensheets",
title: "Top Estabelecimentos | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiStore2Line />}
title="Top Estabelecimentos"
subtitle="Análise dos locais onde você mais compra e gasta"
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiStore2Line />}
title="Top Estabelecimentos"
subtitle="Análise dos locais onde você mais compra e gasta"
/>
{children}
</section>
);
}

View File

@@ -2,57 +2,57 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-4 px-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-col gap-1">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-8 w-48" />
</div>
return (
<main className="flex flex-col gap-4 px-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-col gap-1">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-8 w-48" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</CardContent>
</Card>
</div>
<div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
</main>
);
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</CardContent>
</Card>
</div>
<div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
</main>
);
}

View File

@@ -6,71 +6,71 @@ import { TopCategories } from "@/components/top-estabelecimentos/top-categories"
import { Card } from "@/components/ui/card";
import { getUser } from "@/lib/auth/server";
import {
fetchTopEstabelecimentosData,
type PeriodFilter,
fetchTopEstabelecimentosData,
type PeriodFilter,
} from "@/lib/top-estabelecimentos/fetch-data";
import { parsePeriodParam } from "@/lib/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
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";
if (value === "3" || value === "6" || value === "12") {
return value;
}
return "6";
};
export default async function TopEstabelecimentosPage({
searchParams,
searchParams,
}: PageProps) {
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const mesesParam = getSingleParam(resolvedSearchParams, "meses");
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 { period: currentPeriod } = parsePeriodParam(periodoParam);
const periodFilter = validatePeriodFilter(mesesParam);
const data = await fetchTopEstabelecimentosData(
user.id,
currentPeriod,
periodFilter,
);
const data = await fetchTopEstabelecimentosData(
user.id,
currentPeriod,
periodFilter,
);
return (
<main className="flex flex-col gap-4">
<Card className="p-3 flex-row justify-between items-center">
<span className="text-sm text-muted-foreground">
Selecione o período
</span>
<PeriodFilterButtons currentFilter={periodFilter} />
</Card>
return (
<main className="flex flex-col gap-4">
<Card className="p-3 flex-row justify-between items-center">
<span className="text-sm text-muted-foreground">
Selecione o período
</span>
<PeriodFilterButtons currentFilter={periodFilter} />
</Card>
<SummaryCards summary={data.summary} />
<SummaryCards summary={data.summary} />
<HighlightsCards summary={data.summary} />
<HighlightsCards summary={data.summary} />
<div className="grid gap-4 @3xl/main:grid-cols-3">
<div className="@3xl/main:col-span-2">
<EstablishmentsList establishments={data.establishments} />
</div>
<div>
<TopCategories categories={data.topCategories} />
</div>
</div>
</main>
);
<div className="grid gap-4 @3xl/main:grid-cols-3">
<div className="@3xl/main:col-span-2">
<EstablishmentsList establishments={data.establishments} />
</div>
<div>
<TopCategories categories={data.topCategories} />
</div>
</div>
</main>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { auth } from "@/lib/auth/config";
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth/config";
export const { GET, POST } = toNextJsHandler(auth.handler);

View File

@@ -5,81 +5,88 @@
* Usado pelo app Android quando o access token expira.
*/
import { refreshAccessToken, extractBearerToken, verifyJwt, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import {
extractBearerToken,
hashToken,
refreshAccessToken,
verifyJwt,
} from "@/lib/auth/api-token";
import { db } from "@/lib/db";
export async function POST(request: Request) {
try {
// Extrair refresh token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
try {
// Extrair refresh token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Refresh token não fornecido" },
{ status: 401 }
);
}
if (!token) {
return NextResponse.json(
{ error: "Refresh token não fornecido" },
{ status: 401 },
);
}
// Validar refresh token
const payload = verifyJwt(token);
// Validar refresh token
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_refresh") {
return NextResponse.json(
{ error: "Refresh token inválido ou expirado" },
{ status: 401 }
);
}
if (!payload || payload.type !== "api_refresh") {
return NextResponse.json(
{ error: "Refresh token inválido ou expirado" },
{ status: 401 },
);
}
// Verificar se token não foi revogado
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt)
),
});
// Verificar se token não foi revogado
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token revogado ou não encontrado" },
{ status: 401 }
);
}
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token revogado ou não encontrado" },
{ status: 401 },
);
}
// Gerar novo access token
const result = refreshAccessToken(token);
// Gerar novo access token
const result = refreshAccessToken(token);
if (!result) {
return NextResponse.json(
{ error: "Não foi possível renovar o token" },
{ status: 401 }
);
}
if (!result) {
return NextResponse.json(
{ error: "Não foi possível renovar o token" },
{ status: 401 },
);
}
// Atualizar hash do token e último uso
await db
.update(apiTokens)
.set({
tokenHash: hashToken(result.accessToken),
lastUsedAt: new Date(),
lastUsedIp: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"),
expiresAt: result.expiresAt,
})
.where(eq(apiTokens.id, payload.tokenId));
// Atualizar hash do token e último uso
await db
.update(apiTokens)
.set({
tokenHash: hashToken(result.accessToken),
lastUsedAt: new Date(),
lastUsedIp:
request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip"),
expiresAt: result.expiresAt,
})
.where(eq(apiTokens.id, payload.tokenId));
return NextResponse.json({
accessToken: result.accessToken,
expiresAt: result.expiresAt.toISOString(),
});
} catch (error) {
console.error("[API] Error refreshing device token:", error);
return NextResponse.json(
{ error: "Erro ao renovar token" },
{ status: 500 }
);
}
return NextResponse.json({
accessToken: result.accessToken,
expiresAt: result.expiresAt.toISOString(),
});
} catch (error) {
console.error("[API] Error refreshing device token:", error);
return NextResponse.json(
{ error: "Erro ao renovar token" },
{ status: 500 },
);
}
}

View File

@@ -5,75 +5,74 @@
* Requer sessão web autenticada.
*/
import { auth } from "@/lib/auth/config";
import { generateTokenPair, hashToken, getTokenPrefix } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens } from "@/db/schema";
import {
generateTokenPair,
getTokenPrefix,
hashToken,
} from "@/lib/auth/api-token";
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
const createTokenSchema = z.object({
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
deviceId: z.string().optional(),
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
deviceId: z.string().optional(),
});
export async function POST(request: Request) {
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401 }
);
}
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Validar body
const body = await request.json();
const { name, deviceId } = createTokenSchema.parse(body);
// Validar body
const body = await request.json();
const { name, deviceId } = createTokenSchema.parse(body);
// Gerar par de tokens
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
session.user.id,
deviceId
);
// Gerar par de tokens
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
session.user.id,
deviceId,
);
// Salvar hash do token no banco
await db.insert(apiTokens).values({
id: tokenId,
userId: session.user.id,
name,
tokenHash: hashToken(accessToken),
tokenPrefix: getTokenPrefix(accessToken),
expiresAt,
});
// Salvar hash do token no banco
await db.insert(apiTokens).values({
id: tokenId,
userId: session.user.id,
name,
tokenHash: hashToken(accessToken),
tokenPrefix: getTokenPrefix(accessToken),
expiresAt,
});
// Retornar tokens (mostrados apenas uma vez)
return NextResponse.json(
{
accessToken,
refreshToken,
tokenId,
name,
expiresAt: expiresAt.toISOString(),
message: "Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
},
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 }
);
}
// Retornar tokens (mostrados apenas uma vez)
return NextResponse.json(
{
accessToken,
refreshToken,
tokenId,
name,
expiresAt: expiresAt.toISOString(),
message:
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating device token:", error);
return NextResponse.json(
{ error: "Erro ao criar token" },
{ status: 500 }
);
}
console.error("[API] Error creating device token:", error);
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
}
}

View File

@@ -5,61 +5,58 @@
* Requer sessão web autenticada.
*/
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
interface RouteParams {
params: Promise<{ tokenId: string }>;
params: Promise<{ tokenId: string }>;
}
export async function DELETE(request: Request, { params }: RouteParams) {
try {
const { tokenId } = await params;
export async function DELETE(_request: Request, { params }: RouteParams) {
try {
const { tokenId } = await params;
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401 }
);
}
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Verificar se token pertence ao usuário
const token = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.id, tokenId),
eq(apiTokens.userId, session.user.id)
),
});
// Verificar se token pertence ao usuário
const token = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.id, tokenId),
eq(apiTokens.userId, session.user.id),
),
});
if (!token) {
return NextResponse.json(
{ error: "Token não encontrado" },
{ status: 404 }
);
}
if (!token) {
return NextResponse.json(
{ error: "Token não encontrado" },
{ status: 404 },
);
}
// Revogar token (soft delete)
await db
.update(apiTokens)
.set({ revokedAt: new Date() })
.where(eq(apiTokens.id, tokenId));
// Revogar token (soft delete)
await db
.update(apiTokens)
.set({ revokedAt: new Date() })
.where(eq(apiTokens.id, tokenId));
return NextResponse.json({
message: "Token revogado com sucesso",
tokenId,
});
} catch (error) {
console.error("[API] Error revoking device token:", error);
return NextResponse.json(
{ error: "Erro ao revogar token" },
{ status: 500 }
);
}
return NextResponse.json({
message: "Token revogado com sucesso",
tokenId,
});
} catch (error) {
console.error("[API] Error revoking device token:", error);
return NextResponse.json(
{ error: "Erro ao revogar token" },
{ status: 500 },
);
}
}

View File

@@ -5,49 +5,48 @@
* Requer sessão web autenticada.
*/
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
export async function GET() {
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401 }
);
}
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Buscar tokens ativos do usuário
const tokens = await db
.select({
id: apiTokens.id,
name: apiTokens.name,
tokenPrefix: apiTokens.tokenPrefix,
lastUsedAt: apiTokens.lastUsedAt,
lastUsedIp: apiTokens.lastUsedIp,
expiresAt: apiTokens.expiresAt,
createdAt: apiTokens.createdAt,
})
.from(apiTokens)
.where(eq(apiTokens.userId, session.user.id))
.orderBy(desc(apiTokens.createdAt));
// Buscar tokens ativos do usuário
const tokens = await db
.select({
id: apiTokens.id,
name: apiTokens.name,
tokenPrefix: apiTokens.tokenPrefix,
lastUsedAt: apiTokens.lastUsedAt,
lastUsedIp: apiTokens.lastUsedIp,
expiresAt: apiTokens.expiresAt,
createdAt: apiTokens.createdAt,
})
.from(apiTokens)
.where(eq(apiTokens.userId, session.user.id))
.orderBy(desc(apiTokens.createdAt));
// Separar tokens ativos e revogados
const activeTokens = tokens.filter((t) => !t.expiresAt || new Date(t.expiresAt) > new Date());
// Separar tokens ativos e revogados
const activeTokens = tokens.filter(
(t) => !t.expiresAt || new Date(t.expiresAt) > new Date(),
);
return NextResponse.json({ tokens: activeTokens });
} catch (error) {
console.error("[API] Error listing device tokens:", error);
return NextResponse.json(
{ error: "Erro ao listar tokens" },
{ status: 500 }
);
}
return NextResponse.json({ tokens: activeTokens });
} catch (error) {
console.error("[API] Error listing device tokens:", error);
return NextResponse.json(
{ error: "Erro ao listar tokens" },
{ status: 500 },
);
}
}

View File

@@ -7,75 +7,76 @@
* Aceita tokens no formato os_xxx (hash-based, sem expiração).
*/
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ valid: false, error: "Token não fornecido" },
{ status: 401 }
);
}
if (!token) {
return NextResponse.json(
{ valid: false, error: "Token não fornecido" },
{ status: 401 },
);
}
// Validar token os_xxx via hash lookup
if (!token.startsWith("os_")) {
return NextResponse.json(
{ valid: false, error: "Formato de token inválido" },
{ status: 401 }
);
}
// Validar token os_xxx via hash lookup
if (!token.startsWith("os_")) {
return NextResponse.json(
{ valid: false, error: "Formato de token inválido" },
{ status: 401 },
);
}
// Hash do token para buscar no DB
const tokenHash = hashToken(token);
// Hash do token para buscar no DB
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt)
),
});
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ valid: false, error: "Token inválido ou revogado" },
{ status: 401 }
);
}
if (!tokenRecord) {
return NextResponse.json(
{ valid: false, error: "Token inválido ou revogado" },
{ status: 401 },
);
}
// Atualizar último uso
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| request.headers.get("x-real-ip")
|| null;
// Atualizar último uso
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.id, tokenRecord.id));
await db
.update(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.id, tokenRecord.id));
return NextResponse.json({
valid: true,
userId: tokenRecord.userId,
tokenId: tokenRecord.id,
tokenName: tokenRecord.name,
});
} catch (error) {
console.error("[API] Error verifying device token:", error);
return NextResponse.json(
{ valid: false, error: "Erro ao validar token" },
{ status: 500 }
);
}
return NextResponse.json({
valid: true,
userId: tokenRecord.userId,
tokenId: tokenRecord.id,
tokenName: tokenRecord.name,
});
} catch (error) {
console.error("[API] Error verifying device token:", error);
return NextResponse.json(
{ valid: false, error: "Erro ao validar token" },
{ status: 500 },
);
}
}

View File

@@ -12,33 +12,34 @@ const APP_VERSION = "1.0.0";
* Usado pelo app Android para validar URL do servidor
*/
export async function GET() {
try {
// Tenta fazer uma query simples no banco para verificar conexão
// Isso garante que o app está conectado ao banco antes de considerar "healthy"
await db.execute("SELECT 1");
try {
// Tenta fazer uma query simples no banco para verificar conexão
// Isso garante que o app está conectado ao banco antes de considerar "healthy"
await db.execute("SELECT 1");
return NextResponse.json(
{
status: "ok",
name: "OpenSheets",
version: APP_VERSION,
timestamp: new Date().toISOString(),
},
{ status: 200 }
);
} catch (error) {
// Se houver erro na conexão com banco, retorna status 503 (Service Unavailable)
console.error("Health check failed:", error);
return NextResponse.json(
{
status: "ok",
name: "OpenSheets",
version: APP_VERSION,
timestamp: new Date().toISOString(),
},
{ status: 200 },
);
} catch (error) {
// Se houver erro na conexão com banco, retorna status 503 (Service Unavailable)
console.error("Health check failed:", error);
return NextResponse.json(
{
status: "error",
name: "OpenSheets",
version: APP_VERSION,
timestamp: new Date().toISOString(),
message: error instanceof Error ? error.message : "Database connection failed",
},
{ status: 503 }
);
}
return NextResponse.json(
{
status: "error",
name: "OpenSheets",
version: APP_VERSION,
timestamp: new Date().toISOString(),
message:
error instanceof Error ? error.message : "Database connection failed",
},
{ status: 503 },
);
}
}

View File

@@ -5,13 +5,13 @@
* Requer autenticação via API token (formato os_xxx).
*/
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { inboxBatchSchema } from "@/lib/schemas/inbox";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
// Rate limiting simples em memória
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
@@ -19,153 +19,153 @@ const RATE_LIMIT = 20; // 20 batch requests
const RATE_WINDOW = 60 * 1000; // por minuto
function checkRateLimit(userId: string): boolean {
const now = Date.now();
const userLimit = rateLimitMap.get(userId);
const now = Date.now();
const userLimit = rateLimitMap.get(userId);
if (!userLimit || userLimit.resetAt < now) {
rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
return true;
}
if (!userLimit || userLimit.resetAt < now) {
rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
userLimit.count++;
return true;
userLimit.count++;
return true;
}
interface BatchResult {
clientId?: string;
serverId?: string;
success: boolean;
error?: string;
clientId?: string;
serverId?: string;
success: boolean;
error?: string;
}
export async function POST(request: Request) {
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Token não fornecido" },
{ status: 401 },
);
}
if (!token) {
return NextResponse.json(
{ error: "Token não fornecido" },
{ status: 401 },
);
}
// Validar token os_xxx via hash
if (!token.startsWith("os_")) {
return NextResponse.json(
{ error: "Formato de token inválido" },
{ status: 401 },
);
}
// Validar token os_xxx via hash
if (!token.startsWith("os_")) {
return NextResponse.json(
{ error: "Formato de token inválido" },
{ status: 401 },
);
}
const tokenHash = hashToken(token);
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
),
});
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 },
);
}
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 },
);
}
// Rate limiting
if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json(
{ error: "Limite de requisições excedido", retryAfter: 60 },
{ status: 429 },
);
}
// Rate limiting
if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json(
{ error: "Limite de requisições excedido", retryAfter: 60 },
{ status: 429 },
);
}
// Validar body
const body = await request.json();
const { items } = inboxBatchSchema.parse(body);
// Validar body
const body = await request.json();
const { items } = inboxBatchSchema.parse(body);
// Processar cada item
const results: BatchResult[] = [];
// Processar cada item
const results: BatchResult[] = [];
for (const item of items) {
try {
const [inserted] = await db
.insert(inboxItems)
.values({
userId: tokenRecord.userId,
sourceApp: item.sourceApp,
sourceAppName: item.sourceAppName,
originalTitle: item.originalTitle,
originalText: item.originalText,
notificationTimestamp: item.notificationTimestamp,
parsedName: item.parsedName,
parsedAmount: item.parsedAmount?.toString(),
parsedTransactionType: item.parsedTransactionType,
status: "pending",
})
.returning({ id: inboxItems.id });
for (const item of items) {
try {
const [inserted] = await db
.insert(inboxItems)
.values({
userId: tokenRecord.userId,
sourceApp: item.sourceApp,
sourceAppName: item.sourceAppName,
originalTitle: item.originalTitle,
originalText: item.originalText,
notificationTimestamp: item.notificationTimestamp,
parsedName: item.parsedName,
parsedAmount: item.parsedAmount?.toString(),
parsedTransactionType: item.parsedTransactionType,
status: "pending",
})
.returning({ id: inboxItems.id });
results.push({
clientId: item.clientId,
serverId: inserted.id,
success: true,
});
} catch (error) {
results.push({
clientId: item.clientId,
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
});
}
}
results.push({
clientId: item.clientId,
serverId: inserted.id,
success: true,
});
} catch (error) {
results.push({
clientId: item.clientId,
success: false,
error: error instanceof Error ? error.message : "Erro desconhecido",
});
}
}
// Atualizar último uso do token
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
// Atualizar último uso do token
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.id, tokenRecord.id));
await db
.update(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.id, tokenRecord.id));
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
return NextResponse.json(
{
message: `${successCount} notificações processadas${failCount > 0 ? `, ${failCount} falharam` : ""}`,
total: items.length,
success: successCount,
failed: failCount,
results,
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
return NextResponse.json(
{
message: `${successCount} notificações processadas${failCount > 0 ? `, ${failCount} falharam` : ""}`,
total: items.length,
success: successCount,
failed: failCount,
results,
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating batch inbox items:", error);
return NextResponse.json(
{ error: "Erro ao processar notificações" },
{ status: 500 },
);
}
console.error("[API] Error creating batch inbox items:", error);
return NextResponse.json(
{ error: "Erro ao processar notificações" },
{ status: 500 },
);
}
}

View File

@@ -5,13 +5,13 @@
* Requer autenticação via API token (formato os_xxx).
*/
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { inboxItemSchema } from "@/lib/schemas/inbox";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
// Rate limiting simples em memória (em produção, use Redis)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
@@ -19,123 +19,123 @@ const RATE_LIMIT = 100; // 100 requests
const RATE_WINDOW = 60 * 1000; // por minuto
function checkRateLimit(userId: string): boolean {
const now = Date.now();
const userLimit = rateLimitMap.get(userId);
const now = Date.now();
const userLimit = rateLimitMap.get(userId);
if (!userLimit || userLimit.resetAt < now) {
rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
return true;
}
if (!userLimit || userLimit.resetAt < now) {
rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
return true;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
if (userLimit.count >= RATE_LIMIT) {
return false;
}
userLimit.count++;
return true;
userLimit.count++;
return true;
}
export async function POST(request: Request) {
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
try {
// Extrair token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Token não fornecido" },
{ status: 401 },
);
}
if (!token) {
return NextResponse.json(
{ error: "Token não fornecido" },
{ status: 401 },
);
}
// Validar token os_xxx via hash
if (!token.startsWith("os_")) {
return NextResponse.json(
{ error: "Formato de token inválido" },
{ status: 401 },
);
}
// Validar token os_xxx via hash
if (!token.startsWith("os_")) {
return NextResponse.json(
{ error: "Formato de token inválido" },
{ status: 401 },
);
}
const tokenHash = hashToken(token);
const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
),
});
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 },
);
}
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token inválido ou revogado" },
{ status: 401 },
);
}
// Rate limiting
if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json(
{ error: "Limite de requisições excedido", retryAfter: 60 },
{ status: 429 },
);
}
// Rate limiting
if (!checkRateLimit(tokenRecord.userId)) {
return NextResponse.json(
{ error: "Limite de requisições excedido", retryAfter: 60 },
{ status: 429 },
);
}
// Validar body
const body = await request.json();
const data = inboxItemSchema.parse(body);
// Validar body
const body = await request.json();
const data = inboxItemSchema.parse(body);
// Inserir item na inbox
const [inserted] = await db
.insert(inboxItems)
.values({
userId: tokenRecord.userId,
sourceApp: data.sourceApp,
sourceAppName: data.sourceAppName,
originalTitle: data.originalTitle,
originalText: data.originalText,
notificationTimestamp: data.notificationTimestamp,
parsedName: data.parsedName,
parsedAmount: data.parsedAmount?.toString(),
parsedTransactionType: data.parsedTransactionType,
status: "pending",
})
.returning({ id: inboxItems.id });
// Inserir item na inbox
const [inserted] = await db
.insert(inboxItems)
.values({
userId: tokenRecord.userId,
sourceApp: data.sourceApp,
sourceAppName: data.sourceAppName,
originalTitle: data.originalTitle,
originalText: data.originalText,
notificationTimestamp: data.notificationTimestamp,
parsedName: data.parsedName,
parsedAmount: data.parsedAmount?.toString(),
parsedTransactionType: data.parsedTransactionType,
status: "pending",
})
.returning({ id: inboxItems.id });
// Atualizar último uso do token
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
// Atualizar último uso do token
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.id, tokenRecord.id));
await db
.update(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp: clientIp,
})
.where(eq(apiTokens.id, tokenRecord.id));
return NextResponse.json(
{
id: inserted.id,
clientId: data.clientId,
message: "Notificação recebida",
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
return NextResponse.json(
{
id: inserted.id,
clientId: data.clientId,
message: "Notificação recebida",
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating inbox item:", error);
return NextResponse.json(
{ error: "Erro ao processar notificação" },
{ status: 500 },
);
}
console.error("[API] Error creating inbox item:", error);
return NextResponse.json(
{ error: "Erro ao processar notificação" },
{ status: 500 },
);
}
}

View File

@@ -6,48 +6,48 @@ import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
export default function Error({
error,
reset,
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<Empty className="max-w-md border-0">
<EmptyHeader>
<EmptyMedia variant="icon" className="bg-destructive/10 size-16">
<RiErrorWarningFill className="size-8 text-destructive" />
</EmptyMedia>
<EmptyTitle className="text-2xl">Algo deu errado</EmptyTitle>
<EmptyDescription>
Ocorreu um problema inesperado. Por favor, tente novamente ou volte
para o dashboard.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<div className="flex flex-col gap-2 sm:flex-row">
<Button onClick={() => reset()}>Tentar Novamente</Button>
<Button variant="outline" asChild>
<Link href="/dashboard">Voltar para o Dashboard</Link>
</Button>
</div>
</EmptyContent>
</Empty>
</div>
);
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<Empty className="max-w-md border-0">
<EmptyHeader>
<EmptyMedia variant="icon" className="bg-destructive/10 size-16">
<RiErrorWarningFill className="size-8 text-destructive" />
</EmptyMedia>
<EmptyTitle className="text-2xl">Algo deu errado</EmptyTitle>
<EmptyDescription>
Ocorreu um problema inesperado. Por favor, tente novamente ou volte
para o dashboard.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<div className="flex flex-col gap-2 sm:flex-row">
<Button onClick={() => reset()}>Tentar Novamente</Button>
<Button variant="outline" asChild>
<Link href="/dashboard">Voltar para o Dashboard</Link>
</Button>
</div>
</EmptyContent>
</Empty>
</div>
);
}

View File

@@ -1,37 +1,37 @@
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { main_font } from "@/public/fonts/font_index";
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata } from "next";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { main_font } from "@/public/fonts/font_index";
import "./globals.css";
export const metadata: Metadata = {
title: "Opensheets",
description: "Finanças pessoais descomplicadas.",
title: "Opensheets",
description: "Finanças pessoais descomplicadas.",
};
export default function RootLayout({
children,
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta name="apple-mobile-web-app-title" content="Opensheets" />
</head>
<body
className={`${main_font.className} antialiased `}
suppressHydrationWarning
>
<ThemeProvider attribute="class" defaultTheme="light">
{children}
<Toaster position="top-right" />
</ThemeProvider>
<Analytics />
<SpeedInsights />
</body>
</html>
);
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta name="apple-mobile-web-app-title" content="Opensheets" />
</head>
<body
className={`${main_font.className} antialiased `}
suppressHydrationWarning
>
<ThemeProvider attribute="class" defaultTheme="light">
{children}
<Toaster position="top-right" />
</ThemeProvider>
<Analytics />
<SpeedInsights />
</body>
</html>
);
}

Some files were not shown because too many files have changed in this diff Show More