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

33
.vscode/settings.json vendored
View File

@@ -1,17 +1,20 @@
{ {
"files.exclude": { "files.exclude": {
"**/.git": true, "**/.git": true,
"**/.svn": true, "**/.svn": true,
"**/.hg": true, "**/.hg": true,
"**/.DS_Store": true, "**/.DS_Store": true,
"**/Thumbs.db": true, "**/Thumbs.db": true,
"**/node_modules": true, "**/node_modules": true,
"node_modules": true, "node_modules": true,
"**/.vscode": true, "**/.vscode": true,
".vscode": true, ".vscode": true,
"**/.next": true, "**/.next": true,
".next": true ".next": true
}, },
"explorerExclude.backup": {}, "explorerExclude.backup": {},
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
} }

View File

@@ -266,7 +266,7 @@ O projeto é open source, seus dados ficam no seu controle (pode rodar localment
- Next.js 16.1 com App Router - Next.js 16.1 com App Router
- Turbopack (fast refresh) - Turbopack (fast refresh)
- TypeScript 5.9 (strict mode) - TypeScript 5.9 (strict mode)
- ESLint 9 - Biome (linting + formatting)
- React 19.2 (com Compiler) - React 19.2 (com Compiler)
- Server Actions - Server Actions
- Parallel data fetching - Parallel data fetching
@@ -322,7 +322,7 @@ O projeto é open source, seus dados ficam no seu controle (pode rodar localment
- **Containerization:** Docker + Docker Compose - **Containerization:** Docker + Docker Compose
- **Package Manager:** pnpm - **Package Manager:** pnpm
- **Build Tool:** Turbopack - **Build Tool:** Turbopack
- **Linting:** ESLint 9.39.2 - **Linting & Formatting:** Biome 2.x
- **Analytics:** Vercel Analytics + Speed Insights - **Analytics:** Vercel Analytics + Speed Insights
--- ---
@@ -991,7 +991,7 @@ opensheets/
├── tailwind.config.ts # Configuração Tailwind CSS ├── tailwind.config.ts # Configuração Tailwind CSS
├── postcss.config.mjs # PostCSS config ├── postcss.config.mjs # PostCSS config
├── components.json # shadcn/ui config ├── components.json # shadcn/ui config
├── eslint.config.mjs # ESLint config ├── biome.json # Biome config (linting + formatting)
├── tsconfig.json # TypeScript config ├── tsconfig.json # TypeScript config
├── package.json # Dependências e scripts ├── package.json # Dependências e scripts
├── .env.example # Template de variáveis de ambiente ├── .env.example # Template de variáveis de ambiente

View File

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

View File

@@ -1,11 +1,11 @@
import { SignupForm } from "@/components/auth/signup-form"; import { SignupForm } from "@/components/auth/signup-form";
export default function Page() { export default function Page() {
return ( return (
<div className="flex min-h-svh flex-col items-center justify-center p-6 md:p-10"> <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"> <div className="w-full max-w-sm md:max-w-4xl">
<SignupForm /> <SignupForm />
</div> </div>
</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 { RiSettingsLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = { export const metadata = {
title: "Ajustes | Opensheets", title: "Ajustes | Opensheets",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 px-6">
<PageDescription <PageDescription
icon={<RiSettingsLine />} icon={<RiSettingsLine />}
title="Ajustes" title="Ajustes"
subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência." subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência."
/> />
{children} {children}
</section> </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 { headers } from "next/headers";
import { redirect } from "next/navigation"; 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() { export default async function Page() {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: await headers(),
}); });
if (!session?.user) { if (!session?.user) {
redirect("/"); redirect("/");
} }
const userName = session.user.name || ""; const userName = session.user.name || "";
const userEmail = session.user.email || ""; const userEmail = session.user.email || "";
// Detectar método de autenticação (Google OAuth vs E-mail/Senha) const { authProvider, userPreferences, userApiTokens } =
const userAccount = await db.query.account.findFirst({ await fetchAjustesPageData(session.user.id);
where: eq(schema.account.userId, session.user.id),
});
// Buscar preferências do usuário return (
const userPreferencesResult = await db <div className="w-full">
.select({ <Tabs defaultValue="preferencias" className="w-full">
disableMagnetlines: schema.userPreferences.disableMagnetlines, <TabsList>
}) <TabsTrigger value="preferencias">Preferências</TabsTrigger>
.from(schema.userPreferences) <TabsTrigger value="dispositivos">Dispositivos</TabsTrigger>
.where(eq(schema.userPreferences.userId, session.user.id)) <TabsTrigger value="nome">Alterar nome</TabsTrigger>
.limit(1); <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 <TabsContent value="dispositivos" className="mt-4">
const authProvider = userAccount?.providerId || "credential"; <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 <TabsContent value="nome" className="mt-4">
const userApiTokens = await db <Card className="p-6">
.select({ <div className="space-y-4">
id: apiTokens.id, <div>
name: apiTokens.name, <h2 className="text-lg font-bold mb-1">Alterar nome</h2>
tokenPrefix: apiTokens.tokenPrefix, <p className="text-sm text-muted-foreground mb-4">
lastUsedAt: apiTokens.lastUsedAt, Atualize como seu nome aparece no Opensheets. Esse nome pode
lastUsedIp: apiTokens.lastUsedIp, ser exibido em diferentes seções do app e em comunicações.
createdAt: apiTokens.createdAt, </p>
expiresAt: apiTokens.expiresAt, </div>
revokedAt: apiTokens.revokedAt, <UpdateNameForm currentName={userName} />
}) </div>
.from(apiTokens) </Card>
.where(eq(apiTokens.userId, session.user.id)) </TabsContent>
.orderBy(desc(apiTokens.createdAt));
return ( <TabsContent value="senha" className="mt-4">
<div className="w-full"> <Card className="p-6">
<Tabs defaultValue="preferencias" className="w-full"> <div className="space-y-4">
<TabsList> <div>
<TabsTrigger value="preferencias">Preferências</TabsTrigger> <h2 className="text-lg font-bold mb-1">Alterar senha</h2>
<TabsTrigger value="dispositivos">Dispositivos</TabsTrigger> <p className="text-sm text-muted-foreground mb-4">
<TabsTrigger value="nome">Alterar nome</TabsTrigger> Defina uma nova senha para sua conta. Guarde-a em local
<TabsTrigger value="senha">Alterar senha</TabsTrigger> seguro.
<TabsTrigger value="email">Alterar e-mail</TabsTrigger> </p>
<TabsTrigger value="deletar" className="text-destructive"> </div>
Deletar conta <UpdatePasswordForm authProvider={authProvider} />
</TabsTrigger> </div>
</TabsList> </Card>
</TabsContent>
<TabsContent value="preferencias" className="mt-4"> <TabsContent value="email" className="mt-4">
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">Preferências</h2> <h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Personalize sua experiência no Opensheets ajustando as Atualize o e-mail associado à sua conta. Você precisará
configurações de acordo com suas necessidades. confirmar os links enviados para o novo e também para o e-mail
</p> atual (quando aplicável) para concluir a alteração.
</div> </p>
<PreferencesForm </div>
disableMagnetlines={ <UpdateEmailForm
userPreferences?.disableMagnetlines ?? false currentEmail={userEmail}
} authProvider={authProvider}
/> />
</div> </div>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="dispositivos" className="mt-4"> <TabsContent value="deletar" className="mt-4">
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold mb-1">OpenSheets Companion</h2> <h2 className="text-lg font-bold mb-1 text-destructive">
<p className="text-sm text-muted-foreground mb-4"> Deletar conta
Conecte o app Android OpenSheets Companion para capturar </h2>
automaticamente notificações de transações financeiras e <p className="text-sm text-muted-foreground mb-4">
enviá-las para sua caixa de entrada. Ao prosseguir, sua conta e todos os dados associados serão
</p> excluídos de forma irreversível.
</div> </p>
<ApiTokensForm tokens={userApiTokens} /> </div>
</div> <DeleteAccountForm />
</Card> </div>
</TabsContent> </Card>
</TabsContent>
<TabsContent value="nome" className="mt-4"> </Tabs>
<Card className="p-6"> </div>
<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>
);
} }

View File

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

View File

@@ -3,12 +3,12 @@ import { getUserId } from "@/lib/auth/server";
import { fetchArquivadasForUser } from "../data"; import { fetchArquivadasForUser } from "../data";
export default async function ArquivadasPage() { export default async function ArquivadasPage() {
const userId = await getUserId(); const userId = await getUserId();
const notes = await fetchArquivadasForUser(userId); const notes = await fetchArquivadasForUser(userId);
return ( return (
<main className="flex flex-col items-start gap-6"> <main className="flex flex-col items-start gap-6">
<NotesPage notes={notes} isArquivadas={true} /> <NotesPage notes={notes} isArquivadas={true} />
</main> </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 { and, eq } from "drizzle-orm";
import { type Anotacao, anotacoes } from "@/db/schema";
import { db } from "@/lib/db";
export type Task = { export type Task = {
id: string; id: string;
text: string; text: string;
completed: boolean; completed: boolean;
}; };
export type NoteData = { export type NoteData = {
id: string; id: string;
title: string; title: string;
description: string; description: string;
type: "nota" | "tarefa"; type: "nota" | "tarefa";
tasks?: Task[]; tasks?: Task[];
arquivada: boolean; arquivada: boolean;
createdAt: string; createdAt: string;
}; };
export async function fetchNotesForUser(userId: string): Promise<NoteData[]> { export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({ const noteRows = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)), where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)], orderBy: (
}); note: typeof anotacoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(note.createdAt)],
});
return noteRows.map((note: Anotacao) => { return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined; let tasks: Task[] | undefined;
// Parse tasks if they exist // Parse tasks if they exist
if (note.tasks) { if (note.tasks) {
try { try {
tasks = JSON.parse(note.tasks); tasks = JSON.parse(note.tasks);
} catch (error) { } catch (error) {
console.error("Failed to parse tasks for note", note.id, error); console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined; tasks = undefined;
} }
} }
return { return {
id: note.id, id: note.id,
title: (note.title ?? "").trim(), title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(), description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa", type: (note.type ?? "nota") as "nota" | "tarefa",
tasks, tasks,
arquivada: note.arquivada, arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(), createdAt: note.createdAt.toISOString(),
}; };
}); });
} }
export async function fetchArquivadasForUser(userId: string): Promise<NoteData[]> { export async function fetchArquivadasForUser(
const noteRows = await db.query.anotacoes.findMany({ userId: string,
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, true)), ): Promise<NoteData[]> {
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, true)),
orderBy: (
note: typeof anotacoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(note.createdAt)],
});
return noteRows.map((note: Anotacao) => { return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined; let tasks: Task[] | undefined;
// Parse tasks if they exist // Parse tasks if they exist
if (note.tasks) { if (note.tasks) {
try { try {
tasks = JSON.parse(note.tasks); tasks = JSON.parse(note.tasks);
} catch (error) { } catch (error) {
console.error("Failed to parse tasks for note", note.id, error); console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined; tasks = undefined;
} }
} }
return { return {
id: note.id, id: note.id,
title: (note.title ?? "").trim(), title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(), description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa", type: (note.type ?? "nota") as "nota" | "tarefa",
tasks, tasks,
arquivada: note.arquivada, arquivada: note.arquivada,
createdAt: note.createdAt.toISOString(), createdAt: note.createdAt.toISOString(),
}; };
}); });
} }

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiCalendarEventLine } from "@remixicon/react"; import { RiCalendarEventLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = { export const metadata = {
title: "Calendário | Opensheets", title: "Calendário | Opensheets",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 px-6">
<PageDescription <PageDescription
icon={<RiCalendarEventLine />} icon={<RiCalendarEventLine />}
title="Calendário" 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." 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} {children}
</section> </section>
); );
} }

View File

@@ -5,55 +5,55 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: MonthPicker + Grade mensal 7x5/6 com dias e eventos * Layout: MonthPicker + Grade mensal 7x5/6 com dias e eventos
*/ */
export default function CalendarioLoading() { export default function CalendarioLoading() {
return ( return (
<main className="flex flex-col gap-3"> <main className="flex flex-col gap-3">
{/* Month Picker placeholder */} {/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" /> <div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Calendar Container */} {/* Calendar Container */}
<div className="rounded-2xl border p-4 space-y-4"> <div className="rounded-2xl border p-4 space-y-4">
{/* Cabeçalho com dias da semana */} {/* Cabeçalho com dias da semana */}
<div className="grid grid-cols-7 gap-2 mb-4"> <div className="grid grid-cols-7 gap-2 mb-4">
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => ( {["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
<div key={day} className="text-center"> <div key={day} className="text-center">
<Skeleton className="h-4 w-12 mx-auto rounded-2xl bg-foreground/10" /> <Skeleton className="h-4 w-12 mx-auto rounded-2xl bg-foreground/10" />
</div> </div>
))} ))}
</div> </div>
{/* Grade de dias (6 semanas) */} {/* Grade de dias (6 semanas) */}
<div className="grid grid-cols-7 gap-2"> <div className="grid grid-cols-7 gap-2">
{Array.from({ length: 42 }).map((_, i) => ( {Array.from({ length: 42 }).map((_, i) => (
<div <div
key={i} key={i}
className="min-h-[100px] rounded-2xl border p-2 space-y-2" className="min-h-[100px] rounded-2xl border p-2 space-y-2"
> >
{/* Número do dia */} {/* Número do dia */}
<Skeleton className="h-5 w-6 rounded-2xl bg-foreground/10" /> <Skeleton className="h-5 w-6 rounded-2xl bg-foreground/10" />
{/* Indicadores de eventos (aleatório entre 0-3) */} {/* Indicadores de eventos (aleatório entre 0-3) */}
{i % 3 === 0 && ( {i % 3 === 0 && (
<div className="space-y-1"> <div className="space-y-1">
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" /> <Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
{i % 5 === 0 && ( {i % 5 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" /> <Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)} )}
</div> </div>
)} )}
</div> </div>
))} ))}
</div> </div>
{/* Legenda */} {/* Legenda */}
<div className="flex flex-wrap items-center gap-4 pt-4 border-t"> <div className="flex flex-wrap items-center gap-4 pt-4 border-t">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<Skeleton className="size-3 rounded-full bg-foreground/10" /> <Skeleton className="size-3 rounded-full bg-foreground/10" />
<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>
</div> </div>
</main> </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 MonthNavigation from "@/components/month-picker/month-navigation";
import { getUserId } from "@/lib/auth/server"; import { getUserId } from "@/lib/auth/server";
import { import {
getSingleParam, getSingleParam,
type ResolvedSearchParams, type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers"; } from "@/lib/lancamentos/page-helpers";
import { parsePeriodParam } from "@/lib/utils/period"; import { parsePeriodParam } from "@/lib/utils/period";
import { MonthlyCalendar } from "@/components/calendario/monthly-calendar";
import { fetchCalendarData } from "./data"; import { fetchCalendarData } from "./data";
import type { CalendarPeriod } from "@/components/calendario/types";
type PageSearchParams = Promise<ResolvedSearchParams>; type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = { type PageProps = {
searchParams?: PageSearchParams; searchParams?: PageSearchParams;
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
const userId = await getUserId(); const userId = await getUserId();
const resolvedParams = searchParams ? await searchParams : undefined; const resolvedParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedParams, "periodo"); const periodoParam = getSingleParam(resolvedParams, "periodo");
const { period, monthName, year } = parsePeriodParam(periodoParam); const { period, monthName, year } = parsePeriodParam(periodoParam);
const calendarData = await fetchCalendarData({ const calendarData = await fetchCalendarData({
userId, userId,
period, period,
}); });
const calendarPeriod: CalendarPeriod = { const calendarPeriod: CalendarPeriod = {
period, period,
monthName, monthName,
year, year,
}; };
return ( return (
<main className="flex flex-col gap-3"> <main className="flex flex-col gap-3">
<MonthNavigation /> <MonthNavigation />
<MonthlyCalendar <MonthlyCalendar
period={calendarPeriod} period={calendarPeriod}
events={calendarData.events} events={calendarData.events}
formOptions={calendarData.formOptions} formOptions={calendarData.formOptions}
/> />
</main> </main>
); );
} }

View File

@@ -1,119 +1,117 @@
"use server"; "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 { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; 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({ const updateInvoicePaymentStatusSchema = z.object({
cartaoId: z cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
.string({ message: "Cartão inválido." }) period: z
.uuid("Cartão inválido."), .string({ message: "Período inválido." })
period: z .regex(PERIOD_FORMAT_REGEX, "Período inválido."),
.string({ message: "Período inválido." }) status: z.enum(
.regex(PERIOD_FORMAT_REGEX, "Período inválido."), INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
status: z.enum( ),
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]] paymentDate: z.string().optional(),
),
paymentDate: z.string().optional(),
}); });
type UpdateInvoicePaymentStatusInput = z.infer< type UpdateInvoicePaymentStatusInput = z.infer<
typeof updateInvoicePaymentStatusSchema typeof updateInvoicePaymentStatusSchema
>; >;
type ActionResult = type ActionResult =
| { success: true; message: string } | { success: true; message: string }
| { success: false; error: string }; | { success: false; error: string };
const successMessageByStatus: Record<InvoicePaymentStatus, string> = { const successMessageByStatus: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.", [INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.",
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.", [INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
}; };
const formatDecimal = (value: number) => const formatDecimal = (value: number) =>
(Math.round(value * 100) / 100).toFixed(2); (Math.round(value * 100) / 100).toFixed(2);
export async function updateInvoicePaymentStatusAction( export async function updateInvoicePaymentStatusAction(
input: UpdateInvoicePaymentStatusInput input: UpdateInvoicePaymentStatusInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
const data = updateInvoicePaymentStatusSchema.parse(input); const data = updateInvoicePaymentStatusSchema.parse(input);
await db.transaction(async (tx: typeof db) => { await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({ const card = await tx.query.cartoes.findFirst({
columns: { id: true, contaId: true, name: true }, columns: { id: true, contaId: true, name: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)), where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
}); });
if (!card) { if (!card) {
throw new Error("Cartão não encontrado."); throw new Error("Cartão não encontrado.");
} }
const existingInvoice = await tx.query.faturas.findFirst({ const existingInvoice = await tx.query.faturas.findFirst({
columns: { columns: {
id: true, id: true,
}, },
where: and( where: and(
eq(faturas.cartaoId, data.cartaoId), eq(faturas.cartaoId, data.cartaoId),
eq(faturas.userId, user.id), eq(faturas.userId, user.id),
eq(faturas.period, data.period) eq(faturas.period, data.period),
), ),
}); });
if (existingInvoice) { if (existingInvoice) {
await tx await tx
.update(faturas) .update(faturas)
.set({ .set({
paymentStatus: data.status, paymentStatus: data.status,
}) })
.where(eq(faturas.id, existingInvoice.id)); .where(eq(faturas.id, existingInvoice.id));
} else { } else {
await tx.insert(faturas).values({ await tx.insert(faturas).values({
cartaoId: data.cartaoId, cartaoId: data.cartaoId,
period: data.period, period: data.period,
paymentStatus: data.status, paymentStatus: data.status,
userId: user.id, userId: user.id,
}); });
} }
const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID; const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID;
await tx await tx
.update(lancamentos) .update(lancamentos)
.set({ isSettled: shouldMarkAsPaid }) .set({ isSettled: shouldMarkAsPaid })
.where( .where(
and( and(
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id), eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period) eq(lancamentos.period, data.period),
) ),
); );
const invoiceNote = buildInvoicePaymentNote(card.id, data.period); const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
if (shouldMarkAsPaid) { if (shouldMarkAsPaid) {
const [adminShareRow] = await tx const [adminShareRow] = await tx
.select({ .select({
total: sql<number>` total: sql<number>`
coalesce( coalesce(
sum( sum(
case case
@@ -124,177 +122,175 @@ export async function updateInvoicePaymentStatusAction(
0 0
) )
`, `,
}) })
.from(lancamentos) .from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where( .where(
and( and(
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id), eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period), eq(lancamentos.period, data.period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN) 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) { if (adminShare > 0 && card.contaId) {
const adminPagador = await tx.query.pagadores.findFirst({ const adminPagador = await tx.query.pagadores.findFirst({
columns: { id: true }, columns: { id: true },
where: and( where: and(
eq(pagadores.userId, user.id), eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN) eq(pagadores.role, PAGADOR_ROLE_ADMIN),
), ),
}); });
const paymentCategory = await tx.query.categorias.findFirst({ const paymentCategory = await tx.query.categorias.findFirst({
columns: { id: true }, columns: { id: true },
where: and( where: and(
eq(categorias.userId, user.id), eq(categorias.userId, user.id),
eq(categorias.name, "Pagamentos") eq(categorias.name, "Pagamentos"),
), ),
}); });
if (adminPagador) { if (adminPagador) {
// Usar a data customizada ou a data atual como data de pagamento // Usar a data customizada ou a data atual como data de pagamento
const invoiceDate = data.paymentDate const invoiceDate = data.paymentDate
? parseLocalDateString(data.paymentDate) ? parseLocalDateString(data.paymentDate)
: new Date(); : new Date();
const amount = `-${formatDecimal(adminShare)}`; const amount = `-${formatDecimal(adminShare)}`;
const payload = { const payload = {
condition: "À vista", condition: "À vista",
name: `Pagamento fatura - ${card.name}`, name: `Pagamento fatura - ${card.name}`,
paymentMethod: "Pix", paymentMethod: "Pix",
note: invoiceNote, note: invoiceNote,
amount, amount,
purchaseDate: invoiceDate, purchaseDate: invoiceDate,
transactionType: "Despesa" as const, transactionType: "Despesa" as const,
period: data.period, period: data.period,
isSettled: true, isSettled: true,
userId: user.id, userId: user.id,
contaId: card.contaId, contaId: card.contaId,
categoriaId: paymentCategory?.id ?? null, categoriaId: paymentCategory?.id ?? null,
pagadorId: adminPagador.id, pagadorId: adminPagador.id,
}; };
const existingPayment = await tx.query.lancamentos.findFirst({ const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true }, columns: { id: true },
where: and( where: and(
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote) eq(lancamentos.note, invoiceNote),
), ),
}); });
if (existingPayment) { if (existingPayment) {
await tx await tx
.update(lancamentos) .update(lancamentos)
.set(payload) .set(payload)
.where(eq(lancamentos.id, existingPayment.id)); .where(eq(lancamentos.id, existingPayment.id));
} else { } else {
await tx.insert(lancamentos).values(payload); await tx.insert(lancamentos).values(payload);
} }
} }
} }
} else { } else {
await tx await tx
.delete(lancamentos) .delete(lancamentos)
.where( .where(
and( and(
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote) eq(lancamentos.note, invoiceNote),
) ),
); );
} }
}); });
revalidatePath(`/cartoes/${data.cartaoId}/fatura`); revalidatePath(`/cartoes/${data.cartaoId}/fatura`);
revalidatePath("/cartoes"); revalidatePath("/cartoes");
revalidatePath("/contas"); revalidatePath("/contas");
return { success: true, message: successMessageByStatus[data.status] }; return { success: true, message: successMessageByStatus[data.status] };
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return { return {
success: false, success: false,
error: error.issues[0]?.message ?? "Dados inválidos.", error: error.issues[0]?.message ?? "Dados inválidos.",
}; };
} }
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro inesperado.", error: error instanceof Error ? error.message : "Erro inesperado.",
}; };
} }
} }
const updatePaymentDateSchema = z.object({ const updatePaymentDateSchema = z.object({
cartaoId: z cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
.string({ message: "Cartão inválido." }) period: z
.uuid("Cartão inválido."), .string({ message: "Período inválido." })
period: z .regex(PERIOD_FORMAT_REGEX, "Período inválido."),
.string({ message: "Período inválido." }) paymentDate: z.string({ message: "Data de pagamento inválida." }),
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
paymentDate: z.string({ message: "Data de pagamento inválida." }),
}); });
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>; type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;
export async function updatePaymentDateAction( export async function updatePaymentDateAction(
input: UpdatePaymentDateInput input: UpdatePaymentDateInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
const data = updatePaymentDateSchema.parse(input); const data = updatePaymentDateSchema.parse(input);
await db.transaction(async (tx: typeof db) => { await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({ const card = await tx.query.cartoes.findFirst({
columns: { id: true }, columns: { id: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)), where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
}); });
if (!card) { if (!card) {
throw new Error("Cartão não encontrado."); 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({ const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true }, columns: { id: true },
where: and( where: and(
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote) eq(lancamentos.note, invoiceNote),
), ),
}); });
if (!existingPayment) { if (!existingPayment) {
throw new Error("Pagamento não encontrado."); throw new Error("Pagamento não encontrado.");
} }
await tx await tx
.update(lancamentos) .update(lancamentos)
.set({ .set({
purchaseDate: parseLocalDateString(data.paymentDate), purchaseDate: parseLocalDateString(data.paymentDate),
}) })
.where(eq(lancamentos.id, existingPayment.id)); .where(eq(lancamentos.id, existingPayment.id));
}); });
revalidatePath(`/cartoes/${data.cartaoId}/fatura`); revalidatePath(`/cartoes/${data.cartaoId}/fatura`);
revalidatePath("/cartoes"); revalidatePath("/cartoes");
revalidatePath("/contas"); revalidatePath("/contas");
return { success: true, message: "Data de pagamento atualizada." }; return { success: true, message: "Data de pagamento atualizada." };
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return { return {
success: false, success: false,
error: error.issues[0]?.message ?? "Dados inválidos.", error: error.issues[0]?.message ?? "Dados inválidos.",
}; };
} }
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : "Erro inesperado.", 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 { cartoes, faturas, lancamentos } from "@/db/schema";
import { buildInvoicePaymentNote } from "@/lib/accounts/constants"; import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { import {
INVOICE_PAYMENT_STATUS, INVOICE_PAYMENT_STATUS,
type InvoicePaymentStatus, type InvoicePaymentStatus,
} from "@/lib/faturas"; } from "@/lib/faturas";
import { and, eq, sum } from "drizzle-orm";
const toNumber = (value: string | number | null | undefined) => { const toNumber = (value: string | number | null | undefined) => {
if (typeof value === "number") { if (typeof value === "number") {
return value; return value;
} }
if (value === null || value === undefined) { if (value === null || value === undefined) {
return 0; return 0;
} }
const parsed = Number(value); const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed; return Number.isNaN(parsed) ? 0 : parsed;
}; };
export async function fetchCardData(userId: string, cartaoId: string) { export async function fetchCardData(userId: string, cartaoId: string) {
const card = await db.query.cartoes.findFirst({ const card = await db.query.cartoes.findFirst({
columns: { columns: {
id: true, id: true,
name: true, name: true,
brand: true, brand: true,
closingDay: true, closingDay: true,
dueDay: true, dueDay: true,
logo: true, logo: true,
limit: true, limit: true,
status: true, status: true,
note: true, note: true,
contaId: true, contaId: true,
}, },
where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)), where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)),
}); });
return card; return card;
} }
export async function fetchInvoiceData( export async function fetchInvoiceData(
userId: string, userId: string,
cartaoId: string, cartaoId: string,
selectedPeriod: string selectedPeriod: string,
): Promise<{ ): Promise<{
totalAmount: number; totalAmount: number;
invoiceStatus: InvoicePaymentStatus; invoiceStatus: InvoicePaymentStatus;
paymentDate: Date | null; paymentDate: Date | null;
}> { }> {
const [invoiceRow, totalRow] = await Promise.all([ const [invoiceRow, totalRow] = await Promise.all([
db.query.faturas.findFirst({ db.query.faturas.findFirst({
columns: { columns: {
id: true, id: true,
period: true, period: true,
paymentStatus: true, paymentStatus: true,
}, },
where: and( where: and(
eq(faturas.cartaoId, cartaoId), eq(faturas.cartaoId, cartaoId),
eq(faturas.userId, userId), eq(faturas.userId, userId),
eq(faturas.period, selectedPeriod) eq(faturas.period, selectedPeriod),
), ),
}), }),
db db
.select({ totalAmount: sum(lancamentos.amount) }) .select({ totalAmount: sum(lancamentos.amount) })
.from(lancamentos) .from(lancamentos)
.where( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cartaoId), eq(lancamentos.cartaoId, cartaoId),
eq(lancamentos.period, selectedPeriod) eq(lancamentos.period, selectedPeriod),
) ),
), ),
]); ]);
const totalAmount = toNumber(totalRow[0]?.totalAmount); const totalAmount = toNumber(totalRow[0]?.totalAmount);
const isInvoiceStatus = ( const isInvoiceStatus = (
value: string | null | undefined value: string | null | undefined,
): value is InvoicePaymentStatus => ): value is InvoicePaymentStatus =>
!!value && ["pendente", "pago"].includes(value); !!value && ["pendente", "pago"].includes(value);
const invoiceStatus = isInvoiceStatus(invoiceRow?.paymentStatus) const invoiceStatus = isInvoiceStatus(invoiceRow?.paymentStatus)
? invoiceRow?.paymentStatus ? invoiceRow?.paymentStatus
: INVOICE_PAYMENT_STATUS.PENDING; : INVOICE_PAYMENT_STATUS.PENDING;
// Buscar data do pagamento se a fatura estiver paga // Buscar data do pagamento se a fatura estiver paga
let paymentDate: Date | null = null; let paymentDate: Date | null = null;
if (invoiceStatus === INVOICE_PAYMENT_STATUS.PAID) { if (invoiceStatus === INVOICE_PAYMENT_STATUS.PAID) {
const invoiceNote = buildInvoicePaymentNote(cartaoId, selectedPeriod); const invoiceNote = buildInvoicePaymentNote(cartaoId, selectedPeriod);
const paymentLancamento = await db.query.lancamentos.findFirst({ const paymentLancamento = await db.query.lancamentos.findFirst({
columns: { columns: {
purchaseDate: true, purchaseDate: true,
}, },
where: and( where: and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.note, invoiceNote) eq(lancamentos.note, invoiceNote),
), ),
}); });
paymentDate = paymentLancamento?.purchaseDate paymentDate = paymentLancamento?.purchaseDate
? new Date(paymentLancamento.purchaseDate) ? new Date(paymentLancamento.purchaseDate)
: null; : 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 { import {
FilterSkeleton, FilterSkeleton,
InvoiceSummaryCardSkeleton, InvoiceSummaryCardSkeleton,
TransactionsTableSkeleton, TransactionsTableSkeleton,
} from "@/components/skeletons"; } from "@/components/skeletons";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -10,32 +10,32 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: MonthPicker + InvoiceSummaryCard + Filtros + Tabela de lançamentos * Layout: MonthPicker + InvoiceSummaryCard + Filtros + Tabela de lançamentos
*/ */
export default function FaturaLoading() { export default function FaturaLoading() {
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
{/* Month Picker placeholder */} {/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" /> <div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Invoice Summary Card */} {/* Invoice Summary Card */}
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<InvoiceSummaryCardSkeleton /> <InvoiceSummaryCardSkeleton />
</section> </section>
{/* Seção de lançamentos */} {/* Seção de lançamentos */}
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" /> <Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" /> <Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div> </div>
{/* Filtros */} {/* Filtros */}
<FilterSkeleton /> <FilterSkeleton />
{/* Tabela */} {/* Tabela */}
<TransactionsTableSkeleton /> <TransactionsTableSkeleton />
</div> </div>
</section> </section>
</main> </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 { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { CardDialog } from "@/components/cartoes/card-dialog"; import { CardDialog } from "@/components/cartoes/card-dialog";
import type { Card } from "@/components/cartoes/types"; 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 { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import MonthNavigation from "@/components/month-picker/month-navigation"; import MonthNavigation from "@/components/month-picker/month-navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { lancamentos, type Conta } from "@/db/schema"; import type { Conta } from "@/db/schema";
import { db } from "@/lib/db";
import { getUserId } from "@/lib/auth/server"; import { getUserId } from "@/lib/auth/server";
import { import {
buildLancamentoWhere, buildLancamentoWhere,
buildOptionSets, buildOptionSets,
buildSluggedFilters, buildSluggedFilters,
buildSlugMaps, buildSlugMaps,
extractLancamentoSearchFilters, extractLancamentoSearchFilters,
fetchLancamentoFilterSources, fetchLancamentoFilterSources,
getSingleParam, getSingleParam,
mapLancamentosData, mapLancamentosData,
type ResolvedSearchParams, type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers"; } from "@/lib/lancamentos/page-helpers";
import { loadLogoOptions } from "@/lib/logo/options"; import { loadLogoOptions } from "@/lib/logo/options";
import { parsePeriodParam } from "@/lib/utils/period"; import { parsePeriodParam } from "@/lib/utils/period";
import { RiPencilLine } from "@remixicon/react"; import { fetchCardData, fetchCardLancamentos, fetchInvoiceData } from "./data";
import { and, desc } from "drizzle-orm";
import { notFound } from "next/navigation";
import { fetchCardData, fetchInvoiceData } from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>; type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = { type PageProps = {
params: Promise<{ cartaoId: string }>; params: Promise<{ cartaoId: string }>;
searchParams?: PageSearchParams; searchParams?: PageSearchParams;
}; };
export default async function Page({ params, searchParams }: PageProps) { export default async function Page({ params, searchParams }: PageProps) {
const { cartaoId } = await params; const { cartaoId } = await params;
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const { const {
period: selectedPeriod, period: selectedPeriod,
monthName, monthName,
year, year,
} = parsePeriodParam(periodoParamRaw); } = parsePeriodParam(periodoParamRaw);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const card = await fetchCardData(userId, cartaoId); const card = await fetchCardData(userId, cartaoId);
if (!card) { if (!card) {
notFound(); notFound();
} }
const [ const [filterSources, logoOptions, invoiceData, estabelecimentos] =
filterSources, await Promise.all([
logoOptions, fetchLancamentoFilterSources(userId),
invoiceData, loadLogoOptions(),
estabelecimentos, fetchInvoiceData(userId, cartaoId, selectedPeriod),
] = await Promise.all([ getRecentEstablishmentsAction(),
fetchLancamentoFilterSources(userId), ]);
loadLogoOptions(), const sluggedFilters = buildSluggedFilters(filterSources);
fetchInvoiceData(userId, cartaoId, selectedPeriod), const slugMaps = buildSlugMaps(sluggedFilters);
getRecentEstablishmentsAction(),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildLancamentoWhere({ const filters = buildLancamentoWhere({
userId, userId,
period: selectedPeriod, period: selectedPeriod,
filters: searchFilters, filters: searchFilters,
slugMaps, slugMaps,
cardId: card.id, cardId: card.id,
}); });
const lancamentoRows = await db.query.lancamentos.findMany({ const lancamentoRows = await fetchCardLancamentos(filters);
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 { const {
pagadorOptions, pagadorOptions,
splitPagadorOptions, splitPagadorOptions,
defaultPagadorId, defaultPagadorId,
contaOptions, contaOptions,
cartaoOptions, cartaoOptions,
categoriaOptions, categoriaOptions,
pagadorFilterOptions, pagadorFilterOptions,
categoriaFilterOptions, categoriaFilterOptions,
contaCartaoFilterOptions, contaCartaoFilterOptions,
} = buildOptionSets({ } = buildOptionSets({
...sluggedFilters, ...sluggedFilters,
pagadorRows: filterSources.pagadorRows, pagadorRows: filterSources.pagadorRows,
limitCartaoId: card.id, limitCartaoId: card.id,
}); });
const accountOptions = filterSources.contaRows.map((conta: Conta) => ({ const accountOptions = filterSources.contaRows.map((conta: Conta) => ({
id: conta.id, id: conta.id,
name: conta.name ?? "Conta", name: conta.name ?? "Conta",
})); }));
const contaName = const contaName =
filterSources.contaRows.find((conta: Conta) => conta.id === card.contaId) filterSources.contaRows.find((conta: Conta) => conta.id === card.contaId)
?.name ?? "Conta"; ?.name ?? "Conta";
const cardDialogData: Card = { const cardDialogData: Card = {
id: card.id, id: card.id,
name: card.name, name: card.name,
brand: card.brand ?? "", brand: card.brand ?? "",
status: card.status ?? "", status: card.status ?? "",
closingDay: card.closingDay, closingDay: card.closingDay,
dueDay: card.dueDay, dueDay: card.dueDay,
note: card.note ?? null, note: card.note ?? null,
logo: card.logo, logo: card.logo,
limit: limit:
card.limit !== null && card.limit !== undefined card.limit !== null && card.limit !== undefined
? Number(card.limit) ? Number(card.limit)
: null, : null,
contaId: card.contaId, contaId: card.contaId,
contaName, contaName,
limitInUse: null, limitInUse: null,
limitAvailable: null, limitAvailable: null,
}; };
const { totalAmount, invoiceStatus, paymentDate } = invoiceData; const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
const limitAmount = const limitAmount =
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null; card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice( const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
1 1,
)} de ${year}`; )} de ${year}`;
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<MonthNavigation /> <MonthNavigation />
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<InvoiceSummaryCard <InvoiceSummaryCard
cartaoId={card.id} cartaoId={card.id}
period={selectedPeriod} period={selectedPeriod}
cardName={card.name} cardName={card.name}
cardBrand={card.brand ?? null} cardBrand={card.brand ?? null}
cardStatus={card.status ?? null} cardStatus={card.status ?? null}
closingDay={card.closingDay} closingDay={card.closingDay}
dueDay={card.dueDay} dueDay={card.dueDay}
periodLabel={periodLabel} periodLabel={periodLabel}
totalAmount={totalAmount} totalAmount={totalAmount}
limitAmount={limitAmount} limitAmount={limitAmount}
invoiceStatus={invoiceStatus} invoiceStatus={invoiceStatus}
paymentDate={paymentDate} paymentDate={paymentDate}
logo={card.logo} logo={card.logo}
actions={ actions={
<CardDialog <CardDialog
mode="update" mode="update"
card={cardDialogData} card={cardDialogData}
logoOptions={logoOptions} logoOptions={logoOptions}
accounts={accountOptions} accounts={accountOptions}
trigger={ trigger={
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
aria-label="Editar cartão" aria-label="Editar cartão"
> >
<RiPencilLine className="size-4" /> <RiPencilLine className="size-4" />
</Button> </Button>
} }
/> />
} }
/> />
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<LancamentosSection <LancamentosSection
currentUserId={userId} currentUserId={userId}
lancamentos={lancamentosData} lancamentos={lancamentosData}
pagadorOptions={pagadorOptions} pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions} splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId} defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions} contaOptions={contaOptions}
cartaoOptions={cartaoOptions} cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions} categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions} pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions} categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions} contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod} selectedPeriod={selectedPeriod}
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
allowCreate allowCreate
defaultCartaoId={card.id} defaultCartaoId={card.id}
defaultPaymentMethod="Cartão de crédito" defaultPaymentMethod="Cartão de crédito"
lockCartaoSelection lockCartaoSelection
lockPaymentMethod lockPaymentMethod
/> />
</section> </section>
</main> </main>
); );
} }

View File

@@ -1,51 +1,54 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { cartoes, contas } from "@/db/schema"; import { cartoes, contas } from "@/db/schema";
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
import { revalidateForEntity } from "@/lib/actions/helpers";
import { import {
dayOfMonthSchema, type ActionResult,
noteSchema, handleActionError,
optionalDecimalSchema, revalidateForEntity,
uuidSchema, } from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
dayOfMonthSchema,
noteSchema,
optionalDecimalSchema,
uuidSchema,
} from "@/lib/schemas/common"; } from "@/lib/schemas/common";
import { formatDecimalForDb } from "@/lib/utils/currency"; import { formatDecimalForDb } from "@/lib/utils/currency";
import { normalizeFilePath } from "@/lib/utils/string"; 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({ const cardBaseSchema = z.object({
name: z name: z
.string({ message: "Informe o nome do cartão." }) .string({ message: "Informe o nome do cartão." })
.trim() .trim()
.min(1, "Informe o nome do cartão."), .min(1, "Informe o nome do cartão."),
brand: z brand: z
.string({ message: "Informe a bandeira." }) .string({ message: "Informe a bandeira." })
.trim() .trim()
.min(1, "Informe a bandeira."), .min(1, "Informe a bandeira."),
status: z status: z
.string({ message: "Informe o status do cartão." }) .string({ message: "Informe o status do cartão." })
.trim() .trim()
.min(1, "Informe o status do cartão."), .min(1, "Informe o status do cartão."),
closingDay: dayOfMonthSchema, closingDay: dayOfMonthSchema,
dueDay: dayOfMonthSchema, dueDay: dayOfMonthSchema,
note: noteSchema, note: noteSchema,
limit: optionalDecimalSchema, limit: optionalDecimalSchema,
logo: z logo: z
.string({ message: "Selecione um logo." }) .string({ message: "Selecione um logo." })
.trim() .trim()
.min(1, "Selecione um logo."), .min(1, "Selecione um logo."),
contaId: uuidSchema("Conta"), contaId: uuidSchema("Conta"),
}); });
const createCardSchema = cardBaseSchema; const createCardSchema = cardBaseSchema;
const updateCardSchema = cardBaseSchema.extend({ const updateCardSchema = cardBaseSchema.extend({
id: uuidSchema("Cartão"), id: uuidSchema("Cartão"),
}); });
const deleteCardSchema = z.object({ const deleteCardSchema = z.object({
id: uuidSchema("Cartão"), id: uuidSchema("Cartão"),
}); });
type CardCreateInput = z.infer<typeof createCardSchema>; type CardCreateInput = z.infer<typeof createCardSchema>;
@@ -53,113 +56,113 @@ type CardUpdateInput = z.infer<typeof updateCardSchema>;
type CardDeleteInput = z.infer<typeof deleteCardSchema>; type CardDeleteInput = z.infer<typeof deleteCardSchema>;
async function assertAccountOwnership(userId: string, contaId: string) { async function assertAccountOwnership(userId: string, contaId: string) {
const account = await db.query.contas.findFirst({ const account = await db.query.contas.findFirst({
columns: { id: true }, columns: { id: true },
where: and(eq(contas.id, contaId), eq(contas.userId, userId)), where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
}); });
if (!account) { if (!account) {
throw new Error("Conta vinculada não encontrada."); throw new Error("Conta vinculada não encontrada.");
} }
} }
export async function createCardAction( export async function createCardAction(
input: CardCreateInput input: CardCreateInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
const data = createCardSchema.parse(input); 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({ await db.insert(cartoes).values({
name: data.name, name: data.name,
brand: data.brand, brand: data.brand,
status: data.status, status: data.status,
closingDay: data.closingDay, closingDay: data.closingDay,
dueDay: data.dueDay, dueDay: data.dueDay,
note: data.note ?? null, note: data.note ?? null,
limit: formatDecimalForDb(data.limit), limit: formatDecimalForDb(data.limit),
logo: logoFile, logo: logoFile,
contaId: data.contaId, contaId: data.contaId,
userId: user.id, userId: user.id,
}); });
revalidateForEntity("cartoes"); revalidateForEntity("cartoes");
return { success: true, message: "Cartão criado com sucesso." }; return { success: true, message: "Cartão criado com sucesso." };
} catch (error) { } catch (error) {
return handleActionError(error); return handleActionError(error);
} }
} }
export async function updateCardAction( export async function updateCardAction(
input: CardUpdateInput input: CardUpdateInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
const data = updateCardSchema.parse(input); 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 const [updated] = await db
.update(cartoes) .update(cartoes)
.set({ .set({
name: data.name, name: data.name,
brand: data.brand, brand: data.brand,
status: data.status, status: data.status,
closingDay: data.closingDay, closingDay: data.closingDay,
dueDay: data.dueDay, dueDay: data.dueDay,
note: data.note ?? null, note: data.note ?? null,
limit: formatDecimalForDb(data.limit), limit: formatDecimalForDb(data.limit),
logo: logoFile, logo: logoFile,
contaId: data.contaId, contaId: data.contaId,
}) })
.where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id))) .where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id)))
.returning(); .returning();
if (!updated) { if (!updated) {
return { return {
success: false, success: false,
error: "Cartão não encontrado.", error: "Cartão não encontrado.",
}; };
} }
revalidateForEntity("cartoes"); revalidateForEntity("cartoes");
return { success: true, message: "Cartão atualizado com sucesso." }; return { success: true, message: "Cartão atualizado com sucesso." };
} catch (error) { } catch (error) {
return handleActionError(error); return handleActionError(error);
} }
} }
export async function deleteCardAction( export async function deleteCardAction(
input: CardDeleteInput input: CardDeleteInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
const data = deleteCardSchema.parse(input); const data = deleteCardSchema.parse(input);
const [deleted] = await db const [deleted] = await db
.delete(cartoes) .delete(cartoes)
.where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id))) .where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id)))
.returning({ id: cartoes.id }); .returning({ id: cartoes.id });
if (!deleted) { if (!deleted) {
return { return {
success: false, success: false,
error: "Cartão não encontrado.", error: "Cartão não encontrado.",
}; };
} }
revalidateForEntity("cartoes"); revalidateForEntity("cartoes");
return { success: true, message: "Cartão removido com sucesso." }; return { success: true, message: "Cartão removido com sucesso." };
} catch (error) { } catch (error) {
return handleActionError(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"; import { and, eq, ilike, isNull, not, or, sql } from "drizzle-orm";
export type CardData = { export type CardData = {
id: string; id: string;
name: string; name: string;
brand: string | null; brand: string | null;
status: string | null; status: string | null;
closingDay: number; closingDay: number;
dueDay: number; dueDay: number;
note: string | null; note: string | null;
logo: string | null; logo: string | null;
limit: number | null; limit: number | null;
limitInUse: number; limitInUse: number;
limitAvailable: number | null; limitAvailable: number | null;
contaId: string; contaId: string;
contaName: string; contaName: string;
}; };
export type AccountSimple = { export type AccountSimple = {
id: string; id: string;
name: string; name: string;
logo: string | null; logo: string | null;
}; };
export async function fetchCardsForUser(userId: string): Promise<{ export async function fetchCardsForUser(userId: string): Promise<{
cards: CardData[]; cards: CardData[];
accounts: AccountSimple[]; accounts: AccountSimple[];
logoOptions: LogoOption[]; logoOptions: LogoOption[];
}> { }> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([ const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({ db.query.cartoes.findMany({
orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)], orderBy: (
where: and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))), card: typeof cartoes.$inferSelect,
with: { { desc }: { desc: (field: unknown) => unknown },
conta: { ) => [desc(card.name)],
columns: { where: and(
id: true, eq(cartoes.userId, userId),
name: true, not(ilike(cartoes.status, "inativo")),
}, ),
}, with: {
}, conta: {
}), columns: {
db.query.contas.findMany({ id: true,
orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)], name: true,
where: eq(contas.userId, userId), },
columns: { },
id: true, },
name: true, }),
logo: true, db.query.contas.findMany({
}, orderBy: (
}), account: typeof contas.$inferSelect,
loadLogoOptions(), { desc }: { desc: (field: unknown) => unknown },
db ) => [desc(account.name)],
.select({ where: eq(contas.userId, userId),
cartaoId: lancamentos.cartaoId, columns: {
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`, id: true,
}) name: true,
.from(lancamentos) logo: true,
.where( },
and( }),
eq(lancamentos.userId, userId), loadLogoOptions(),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)) db
) .select({
) cartaoId: lancamentos.cartaoId,
.groupBy(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>(); const usageMap = new Map<string, number>();
usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => { usageRows.forEach(
if (!row.cartaoId) return; (row: { cartaoId: string | null; total: number | null }) => {
usageMap.set(row.cartaoId, Number(row.total ?? 0)); if (!row.cartaoId) return;
}); usageMap.set(row.cartaoId, Number(row.total ?? 0));
},
);
const cards = cardRows.map((card) => ({ const cards = cardRows.map((card) => ({
id: card.id, id: card.id,
name: card.name, name: card.name,
brand: card.brand, brand: card.brand,
status: card.status, status: card.status,
closingDay: card.closingDay, closingDay: card.closingDay,
dueDay: card.dueDay, dueDay: card.dueDay,
note: card.note, note: card.note,
logo: card.logo, logo: card.logo,
limit: card.limit ? Number(card.limit) : null, limit: card.limit ? Number(card.limit) : null,
limitInUse: (() => { limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0; const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0; return total < 0 ? Math.abs(total) : 0;
})(), })(),
limitAvailable: (() => { limitAvailable: (() => {
if (!card.limit) { if (!card.limit) {
return null; return null;
} }
const total = usageMap.get(card.id) ?? 0; const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0; const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0); return Math.max(Number(card.limit) - inUse, 0);
})(), })(),
contaId: card.contaId, contaId: card.contaId,
contaName: card.conta?.name ?? "Conta não encontrada", contaName: card.conta?.name ?? "Conta não encontrada",
})); }));
const accounts = accountRows.map((account) => ({ const accounts = accountRows.map((account) => ({
id: account.id, id: account.id,
name: account.name, name: account.name,
logo: account.logo, logo: account.logo,
})); }));
return { cards, accounts, logoOptions }; return { cards, accounts, logoOptions };
} }
export async function fetchInativosForUser(userId: string): Promise<{ export async function fetchInativosForUser(userId: string): Promise<{
cards: CardData[]; cards: CardData[];
accounts: AccountSimple[]; accounts: AccountSimple[];
logoOptions: LogoOption[]; logoOptions: LogoOption[];
}> { }> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([ const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({ db.query.cartoes.findMany({
orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)], orderBy: (
where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")), card: typeof cartoes.$inferSelect,
with: { { desc }: { desc: (field: unknown) => unknown },
conta: { ) => [desc(card.name)],
columns: { where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")),
id: true, with: {
name: true, 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: { db.query.contas.findMany({
id: true, orderBy: (
name: true, account: typeof contas.$inferSelect,
logo: true, { desc }: { desc: (field: unknown) => unknown },
}, ) => [desc(account.name)],
}), where: eq(contas.userId, userId),
loadLogoOptions(), columns: {
db id: true,
.select({ name: true,
cartaoId: lancamentos.cartaoId, logo: true,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`, },
}) }),
.from(lancamentos) loadLogoOptions(),
.where( db
and( .select({
eq(lancamentos.userId, userId), cartaoId: lancamentos.cartaoId,
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)) total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
) })
) .from(lancamentos)
.groupBy(lancamentos.cartaoId), .where(
]); and(
eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
),
)
.groupBy(lancamentos.cartaoId),
]);
const usageMap = new Map<string, number>(); const usageMap = new Map<string, number>();
usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => { usageRows.forEach(
if (!row.cartaoId) return; (row: { cartaoId: string | null; total: number | null }) => {
usageMap.set(row.cartaoId, Number(row.total ?? 0)); if (!row.cartaoId) return;
}); usageMap.set(row.cartaoId, Number(row.total ?? 0));
},
);
const cards = cardRows.map((card) => ({ const cards = cardRows.map((card) => ({
id: card.id, id: card.id,
name: card.name, name: card.name,
brand: card.brand, brand: card.brand,
status: card.status, status: card.status,
closingDay: card.closingDay, closingDay: card.closingDay,
dueDay: card.dueDay, dueDay: card.dueDay,
note: card.note, note: card.note,
logo: card.logo, logo: card.logo,
limit: card.limit ? Number(card.limit) : null, limit: card.limit ? Number(card.limit) : null,
limitInUse: (() => { limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0; const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0; return total < 0 ? Math.abs(total) : 0;
})(), })(),
limitAvailable: (() => { limitAvailable: (() => {
if (!card.limit) { if (!card.limit) {
return null; return null;
} }
const total = usageMap.get(card.id) ?? 0; const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0; const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0); return Math.max(Number(card.limit) - inUse, 0);
})(), })(),
contaId: card.contaId, contaId: card.contaId,
contaName: card.conta?.name ?? "Conta não encontrada", contaName: card.conta?.name ?? "Conta não encontrada",
})); }));
const accounts = accountRows.map((account) => ({ const accounts = accountRows.map((account) => ({
id: account.id, id: account.id,
name: account.name, name: account.name,
logo: account.logo, 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"; import { fetchInativosForUser } from "../data";
export default async function InativosPage() { export default async function InativosPage() {
const userId = await getUserId(); const userId = await getUserId();
const { cards, accounts, logoOptions } = await fetchInativosForUser(userId); const { cards, accounts, logoOptions } = await fetchInativosForUser(userId);
return ( return (
<main className="flex flex-col items-start gap-6"> <main className="flex flex-col items-start gap-6">
<CardsPage <CardsPage
cards={cards} cards={cards}
accounts={accounts} accounts={accounts}
logoOptions={logoOptions} logoOptions={logoOptions}
isInativos={true} isInativos={true}
/> />
</main> </main>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiPriceTag3Line } from "@remixicon/react"; import { RiPriceTag3Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = { export const metadata = {
title: "Categorias | Opensheets", title: "Categorias | Opensheets",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 px-6">
<PageDescription <PageDescription
icon={<RiPriceTag3Line />} icon={<RiPriceTag3Line />}
title="Categorias" 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." 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} {children}
</section> </section>
); );
} }

View File

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

View File

@@ -3,12 +3,12 @@ import { getUserId } from "@/lib/auth/server";
import { fetchCategoriesForUser } from "./data"; import { fetchCategoriesForUser } from "./data";
export default async function Page() { export default async function Page() {
const userId = await getUserId(); const userId = await getUserId();
const categories = await fetchCategoriesForUser(userId); const categories = await fetchCategoriesForUser(userId);
return ( return (
<main className="flex flex-col items-start gap-6"> <main className="flex flex-col items-start gap-6">
<CategoriesPage categories={categories} /> <CategoriesPage categories={categories} />
</main> </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 { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants"; import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, lt, sql } from "drizzle-orm";
export type AccountSummaryData = { export type AccountSummaryData = {
openingBalance: number; openingBalance: number;
currentBalance: number; currentBalance: number;
totalIncomes: number; totalIncomes: number;
totalExpenses: number; totalExpenses: number;
}; };
export async function fetchAccountData(userId: string, contaId: string) { export async function fetchAccountData(userId: string, contaId: string) {
const account = await db.query.contas.findFirst({ const account = await db.query.contas.findFirst({
columns: { columns: {
id: true, id: true,
name: true, name: true,
accountType: true, accountType: true,
status: true, status: true,
initialBalance: true, initialBalance: true,
logo: true, logo: true,
note: true, note: true,
}, },
where: and(eq(contas.id, contaId), eq(contas.userId, userId)), where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
}); });
return account; return account;
} }
export async function fetchAccountSummary( export async function fetchAccountSummary(
userId: string, userId: string,
contaId: string, contaId: string,
selectedPeriod: string selectedPeriod: string,
): Promise<AccountSummaryData> { ): Promise<AccountSummaryData> {
const [periodSummary] = await db const [periodSummary] = await db
.select({ .select({
netAmount: sql<number>` netAmount: sql<number>`
coalesce( coalesce(
sum( sum(
case case
@@ -46,7 +46,7 @@ export async function fetchAccountSummary(
0 0
) )
`, `,
incomes: sql<number>` incomes: sql<number>`
coalesce( coalesce(
sum( sum(
case case
@@ -58,7 +58,7 @@ export async function fetchAccountSummary(
0 0
) )
`, `,
expenses: sql<number>` expenses: sql<number>`
coalesce( coalesce(
sum( sum(
case case
@@ -70,22 +70,22 @@ export async function fetchAccountSummary(
0 0
) )
`, `,
}) })
.from(lancamentos) .from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.contaId, contaId), eq(lancamentos.contaId, contaId),
eq(lancamentos.period, selectedPeriod), eq(lancamentos.period, selectedPeriod),
eq(lancamentos.isSettled, true), eq(lancamentos.isSettled, true),
eq(pagadores.role, PAGADOR_ROLE_ADMIN) eq(pagadores.role, PAGADOR_ROLE_ADMIN),
) ),
); );
const [previousRow] = await db const [previousRow] = await db
.select({ .select({
previousMovements: sql<number>` previousMovements: sql<number>`
coalesce( coalesce(
sum( sum(
case case
@@ -96,36 +96,56 @@ export async function fetchAccountSummary(
0 0
) )
`, `,
}) })
.from(lancamentos) .from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.contaId, contaId), eq(lancamentos.contaId, contaId),
lt(lancamentos.period, selectedPeriod), lt(lancamentos.period, selectedPeriod),
eq(lancamentos.isSettled, true), eq(lancamentos.isSettled, true),
eq(pagadores.role, PAGADOR_ROLE_ADMIN) eq(pagadores.role, PAGADOR_ROLE_ADMIN),
) ),
); );
const account = await fetchAccountData(userId, contaId); const account = await fetchAccountData(userId, contaId);
if (!account) { if (!account) {
throw new Error("Account not found"); throw new Error("Account not found");
} }
const initialBalance = Number(account.initialBalance ?? 0); const initialBalance = Number(account.initialBalance ?? 0);
const previousMovements = Number(previousRow?.previousMovements ?? 0); const previousMovements = Number(previousRow?.previousMovements ?? 0);
const openingBalance = initialBalance + previousMovements; const openingBalance = initialBalance + previousMovements;
const netAmount = Number(periodSummary?.netAmount ?? 0); const netAmount = Number(periodSummary?.netAmount ?? 0);
const totalIncomes = Number(periodSummary?.incomes ?? 0); const totalIncomes = Number(periodSummary?.incomes ?? 0);
const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0)); const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0));
const currentBalance = openingBalance + netAmount; const currentBalance = openingBalance + netAmount;
return { return {
openingBalance, openingBalance,
currentBalance, currentBalance,
totalIncomes, totalIncomes,
totalExpenses, 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 { import {
AccountStatementCardSkeleton, AccountStatementCardSkeleton,
FilterSkeleton, FilterSkeleton,
TransactionsTableSkeleton, TransactionsTableSkeleton,
} from "@/components/skeletons"; } from "@/components/skeletons";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -10,29 +10,29 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: MonthPicker + AccountStatementCard + Filtros + Tabela de lançamentos * Layout: MonthPicker + AccountStatementCard + Filtros + Tabela de lançamentos
*/ */
export default function ExtratoLoading() { export default function ExtratoLoading() {
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
{/* Month Picker placeholder */} {/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" /> <div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Account Statement Card */} {/* Account Statement Card */}
<AccountStatementCardSkeleton /> <AccountStatementCardSkeleton />
{/* Seção de lançamentos */} {/* Seção de lançamentos */}
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" /> <Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
</div> </div>
{/* Filtros */} {/* Filtros */}
<FilterSkeleton /> <FilterSkeleton />
{/* Tabela */} {/* Tabela */}
<TransactionsTableSkeleton /> <TransactionsTableSkeleton />
</div> </div>
</section> </section>
</main> </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 { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { AccountDialog } from "@/components/contas/account-dialog"; import { AccountDialog } from "@/components/contas/account-dialog";
import { AccountStatementCard } from "@/components/contas/account-statement-card"; 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 { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import MonthNavigation from "@/components/month-picker/month-navigation"; import MonthNavigation from "@/components/month-picker/month-navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
import { getUserId } from "@/lib/auth/server"; import { getUserId } from "@/lib/auth/server";
import { import {
buildLancamentoWhere, buildLancamentoWhere,
buildOptionSets, buildOptionSets,
buildSluggedFilters, buildSluggedFilters,
buildSlugMaps, buildSlugMaps,
extractLancamentoSearchFilters, extractLancamentoSearchFilters,
fetchLancamentoFilterSources, fetchLancamentoFilterSources,
getSingleParam, getSingleParam,
mapLancamentosData, mapLancamentosData,
type ResolvedSearchParams, type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers"; } from "@/lib/lancamentos/page-helpers";
import { loadLogoOptions } from "@/lib/logo/options"; import { loadLogoOptions } from "@/lib/logo/options";
import { parsePeriodParam } from "@/lib/utils/period"; import { parsePeriodParam } from "@/lib/utils/period";
import { RiPencilLine } from "@remixicon/react"; import {
import { and, desc, eq } from "drizzle-orm"; fetchAccountData,
import { notFound } from "next/navigation"; fetchAccountLancamentos,
import { fetchAccountData, fetchAccountSummary } from "./data"; fetchAccountSummary,
} from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>; type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = { type PageProps = {
params: Promise<{ contaId: string }>; params: Promise<{ contaId: string }>;
searchParams?: PageSearchParams; searchParams?: PageSearchParams;
}; };
const capitalize = (value: string) => 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) { export default async function Page({ params, searchParams }: PageProps) {
const { contaId } = await params; const { contaId } = await params;
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const { const {
period: selectedPeriod, period: selectedPeriod,
monthName, monthName,
year, year,
} = parsePeriodParam(periodoParamRaw); } = parsePeriodParam(periodoParamRaw);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const account = await fetchAccountData(userId, contaId); const account = await fetchAccountData(userId, contaId);
if (!account) { if (!account) {
notFound(); notFound();
} }
const [ const [filterSources, logoOptions, accountSummary, estabelecimentos] =
filterSources, await Promise.all([
logoOptions, fetchLancamentoFilterSources(userId),
accountSummary, loadLogoOptions(),
estabelecimentos, fetchAccountSummary(userId, contaId, selectedPeriod),
] = await Promise.all([ getRecentEstablishmentsAction(),
fetchLancamentoFilterSources(userId), ]);
loadLogoOptions(), const sluggedFilters = buildSluggedFilters(filterSources);
fetchAccountSummary(userId, contaId, selectedPeriod), const slugMaps = buildSlugMaps(sluggedFilters);
getRecentEstablishmentsAction(),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildLancamentoWhere({ const filters = buildLancamentoWhere({
userId, userId,
period: selectedPeriod, period: selectedPeriod,
filters: searchFilters, filters: searchFilters,
slugMaps, slugMaps,
accountId: account.id, accountId: account.id,
}); });
filters.push(eq(lancamentos.isSettled, true)); const lancamentoRows = await fetchAccountLancamentos(filters);
const lancamentoRows = await db.query.lancamentos.findMany({ const lancamentosData = mapLancamentosData(lancamentoRows);
where: and(...filters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
const lancamentosData = mapLancamentosData(lancamentoRows); const { openingBalance, currentBalance, totalIncomes, totalExpenses } =
accountSummary;
const { openingBalance, currentBalance, totalIncomes, totalExpenses } = const periodLabel = `${capitalize(monthName)} de ${year}`;
accountSummary;
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 = { const {
id: account.id, pagadorOptions,
name: account.name, splitPagadorOptions,
accountType: account.accountType, defaultPagadorId,
status: account.status, contaOptions,
note: account.note, cartaoOptions,
logo: account.logo, categoriaOptions,
initialBalance: Number(account.initialBalance ?? 0), pagadorFilterOptions,
balance: currentBalance, categoriaFilterOptions,
}; contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
limitContaId: account.id,
});
const { return (
pagadorOptions, <main className="flex flex-col gap-6">
splitPagadorOptions, <MonthNavigation />
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
limitContaId: account.id,
});
return ( <AccountStatementCard
<main className="flex flex-col gap-6"> accountName={account.name}
<MonthNavigation /> 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 <section className="flex flex-col gap-4">
accountName={account.name} <LancamentosSection
accountType={account.accountType} currentUserId={userId}
status={account.status} lancamentos={lancamentosData}
periodLabel={periodLabel} pagadorOptions={pagadorOptions}
openingBalance={openingBalance} splitPagadorOptions={splitPagadorOptions}
currentBalance={currentBalance} defaultPagadorId={defaultPagadorId}
totalIncomes={totalIncomes} contaOptions={contaOptions}
totalExpenses={totalExpenses} cartaoOptions={cartaoOptions}
logo={account.logo} categoriaOptions={categoriaOptions}
actions={ pagadorFilterOptions={pagadorFilterOptions}
<AccountDialog categoriaFilterOptions={categoriaFilterOptions}
mode="update" contaCartaoFilterOptions={contaCartaoFilterOptions}
account={accountDialogData} selectedPeriod={selectedPeriod}
logoOptions={logoOptions} estabelecimentos={estabelecimentos}
trigger={ allowCreate={false}
<Button />
type="button" </section>
variant="ghost" </main>
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>
);
} }

View File

@@ -1,72 +1,75 @@
"use server"; "use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { categorias, contas, lancamentos, pagadores } from "@/db/schema"; import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
import { import {
INITIAL_BALANCE_CATEGORY_NAME, INITIAL_BALANCE_CATEGORY_NAME,
INITIAL_BALANCE_CONDITION, INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
INITIAL_BALANCE_PAYMENT_METHOD, INITIAL_BALANCE_PAYMENT_METHOD,
INITIAL_BALANCE_TRANSACTION_TYPE, INITIAL_BALANCE_TRANSACTION_TYPE,
} from "@/lib/accounts/constants"; } from "@/lib/accounts/constants";
import { type ActionResult, handleActionError } from "@/lib/actions/helpers"; import {
import { revalidateForEntity } from "@/lib/actions/helpers"; 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 { 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 { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { getTodayInfo } from "@/lib/utils/date"; import { getTodayInfo } from "@/lib/utils/date";
import { normalizeFilePath } from "@/lib/utils/string"; 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({ const accountBaseSchema = z.object({
name: z name: z
.string({ message: "Informe o nome da conta." }) .string({ message: "Informe o nome da conta." })
.trim() .trim()
.min(1, "Informe o nome da conta."), .min(1, "Informe o nome da conta."),
accountType: z accountType: z
.string({ message: "Informe o tipo da conta." }) .string({ message: "Informe o tipo da conta." })
.trim() .trim()
.min(1, "Informe o tipo da conta."), .min(1, "Informe o tipo da conta."),
status: z status: z
.string({ message: "Informe o status da conta." }) .string({ message: "Informe o status da conta." })
.trim() .trim()
.min(1, "Informe o status da conta."), .min(1, "Informe o status da conta."),
note: noteSchema, note: noteSchema,
logo: z logo: z
.string({ message: "Selecione um logo." }) .string({ message: "Selecione um logo." })
.trim() .trim()
.min(1, "Selecione um logo."), .min(1, "Selecione um logo."),
initialBalance: z initialBalance: z
.string() .string()
.trim() .trim()
.transform((value) => (value.length === 0 ? "0" : value.replace(",", "."))) .transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
.refine( .refine(
(value) => !Number.isNaN(Number.parseFloat(value)), (value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um saldo inicial válido." "Informe um saldo inicial válido.",
) )
.transform((value) => Number.parseFloat(value)), .transform((value) => Number.parseFloat(value)),
excludeFromBalance: z excludeFromBalance: z
.union([z.boolean(), z.string()]) .union([z.boolean(), z.string()])
.transform((value) => value === true || value === "true"), .transform((value) => value === true || value === "true"),
excludeInitialBalanceFromIncome: z excludeInitialBalanceFromIncome: z
.union([z.boolean(), z.string()]) .union([z.boolean(), z.string()])
.transform((value) => value === true || value === "true"), .transform((value) => value === true || value === "true"),
}); });
const createAccountSchema = accountBaseSchema; const createAccountSchema = accountBaseSchema;
const updateAccountSchema = accountBaseSchema.extend({ const updateAccountSchema = accountBaseSchema.extend({
id: uuidSchema("Conta"), id: uuidSchema("Conta"),
}); });
const deleteAccountSchema = z.object({ const deleteAccountSchema = z.object({
id: uuidSchema("Conta"), id: uuidSchema("Conta"),
}); });
type AccountCreateInput = z.infer<typeof createAccountSchema>; type AccountCreateInput = z.infer<typeof createAccountSchema>;
@@ -74,315 +77,315 @@ type AccountUpdateInput = z.infer<typeof updateAccountSchema>;
type AccountDeleteInput = z.infer<typeof deleteAccountSchema>; type AccountDeleteInput = z.infer<typeof deleteAccountSchema>;
export async function createAccountAction( export async function createAccountAction(
input: AccountCreateInput input: AccountCreateInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
const data = createAccountSchema.parse(input); const data = createAccountSchema.parse(input);
const logoFile = normalizeFilePath(data.logo); const logoFile = normalizeFilePath(data.logo);
const normalizedInitialBalance = Math.abs(data.initialBalance); const normalizedInitialBalance = Math.abs(data.initialBalance);
const hasInitialBalance = normalizedInitialBalance > 0; const hasInitialBalance = normalizedInitialBalance > 0;
await db.transaction(async (tx: typeof db) => { await db.transaction(async (tx: typeof db) => {
const [createdAccount] = await tx const [createdAccount] = await tx
.insert(contas) .insert(contas)
.values({ .values({
name: data.name, name: data.name,
accountType: data.accountType, accountType: data.accountType,
status: data.status, status: data.status,
note: data.note ?? null, note: data.note ?? null,
logo: logoFile, logo: logoFile,
initialBalance: formatDecimalForDbRequired(data.initialBalance), initialBalance: formatDecimalForDbRequired(data.initialBalance),
excludeFromBalance: data.excludeFromBalance, excludeFromBalance: data.excludeFromBalance,
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome, excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
userId: user.id, userId: user.id,
}) })
.returning({ id: contas.id, name: contas.name }); .returning({ id: contas.id, name: contas.name });
if (!createdAccount) { if (!createdAccount) {
throw new Error("Não foi possível criar a conta."); throw new Error("Não foi possível criar a conta.");
} }
if (!hasInitialBalance) { if (!hasInitialBalance) {
return; return;
} }
const [category, adminPagador] = await Promise.all([ const [category, adminPagador] = await Promise.all([
tx.query.categorias.findFirst({ tx.query.categorias.findFirst({
columns: { id: true }, columns: { id: true },
where: and( where: and(
eq(categorias.userId, user.id), eq(categorias.userId, user.id),
eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME) eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME),
), ),
}), }),
tx.query.pagadores.findFirst({ tx.query.pagadores.findFirst({
columns: { id: true }, columns: { id: true },
where: and( where: and(
eq(pagadores.userId, user.id), eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN) eq(pagadores.role, PAGADOR_ROLE_ADMIN),
), ),
}), }),
]); ]);
if (!category) { if (!category) {
throw new Error( throw new Error(
'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.' 'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.',
); );
} }
if (!adminPagador) { if (!adminPagador) {
throw new Error( throw new Error(
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial." "Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
); );
} }
const { date, period } = getTodayInfo(); const { date, period } = getTodayInfo();
await tx.insert(lancamentos).values({ await tx.insert(lancamentos).values({
condition: INITIAL_BALANCE_CONDITION, condition: INITIAL_BALANCE_CONDITION,
name: `Saldo inicial - ${createdAccount.name}`, name: `Saldo inicial - ${createdAccount.name}`,
paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD, paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD,
note: INITIAL_BALANCE_NOTE, note: INITIAL_BALANCE_NOTE,
amount: formatDecimalForDbRequired(normalizedInitialBalance), amount: formatDecimalForDbRequired(normalizedInitialBalance),
purchaseDate: date, purchaseDate: date,
transactionType: INITIAL_BALANCE_TRANSACTION_TYPE, transactionType: INITIAL_BALANCE_TRANSACTION_TYPE,
period, period,
isSettled: true, isSettled: true,
userId: user.id, userId: user.id,
contaId: createdAccount.id, contaId: createdAccount.id,
categoriaId: category.id, categoriaId: category.id,
pagadorId: adminPagador.id, pagadorId: adminPagador.id,
}); });
}); });
revalidateForEntity("contas"); revalidateForEntity("contas");
return { return {
success: true, success: true,
message: "Conta criada com sucesso.", message: "Conta criada com sucesso.",
}; };
} catch (error) { } catch (error) {
return handleActionError(error); return handleActionError(error);
} }
} }
export async function updateAccountAction( export async function updateAccountAction(
input: AccountUpdateInput input: AccountUpdateInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
const data = updateAccountSchema.parse(input); const data = updateAccountSchema.parse(input);
const logoFile = normalizeFilePath(data.logo); const logoFile = normalizeFilePath(data.logo);
const [updated] = await db const [updated] = await db
.update(contas) .update(contas)
.set({ .set({
name: data.name, name: data.name,
accountType: data.accountType, accountType: data.accountType,
status: data.status, status: data.status,
note: data.note ?? null, note: data.note ?? null,
logo: logoFile, logo: logoFile,
initialBalance: formatDecimalForDbRequired(data.initialBalance), initialBalance: formatDecimalForDbRequired(data.initialBalance),
excludeFromBalance: data.excludeFromBalance, excludeFromBalance: data.excludeFromBalance,
excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome, excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome,
}) })
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id))) .where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
.returning(); .returning();
if (!updated) { if (!updated) {
return { return {
success: false, success: false,
error: "Conta não encontrada.", error: "Conta não encontrada.",
}; };
} }
revalidateForEntity("contas"); revalidateForEntity("contas");
return { return {
success: true, success: true,
message: "Conta atualizada com sucesso.", message: "Conta atualizada com sucesso.",
}; };
} catch (error) { } catch (error) {
return handleActionError(error); return handleActionError(error);
} }
} }
export async function deleteAccountAction( export async function deleteAccountAction(
input: AccountDeleteInput input: AccountDeleteInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
const data = deleteAccountSchema.parse(input); const data = deleteAccountSchema.parse(input);
const [deleted] = await db const [deleted] = await db
.delete(contas) .delete(contas)
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id))) .where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
.returning({ id: contas.id }); .returning({ id: contas.id });
if (!deleted) { if (!deleted) {
return { return {
success: false, success: false,
error: "Conta não encontrada.", error: "Conta não encontrada.",
}; };
} }
revalidateForEntity("contas"); revalidateForEntity("contas");
return { return {
success: true, success: true,
message: "Conta removida com sucesso.", message: "Conta removida com sucesso.",
}; };
} catch (error) { } catch (error) {
return handleActionError(error); return handleActionError(error);
} }
} }
// Transfer between accounts // Transfer between accounts
const transferSchema = z.object({ const transferSchema = z.object({
fromAccountId: uuidSchema("Conta de origem"), fromAccountId: uuidSchema("Conta de origem"),
toAccountId: uuidSchema("Conta de destino"), toAccountId: uuidSchema("Conta de destino"),
amount: z amount: z
.string() .string()
.trim() .trim()
.transform((value) => (value.length === 0 ? "0" : value.replace(",", "."))) .transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
.refine( .refine(
(value) => !Number.isNaN(Number.parseFloat(value)), (value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um valor válido." "Informe um valor válido.",
) )
.transform((value) => Number.parseFloat(value)) .transform((value) => Number.parseFloat(value))
.refine((value) => value > 0, "O valor deve ser maior que zero."), .refine((value) => value > 0, "O valor deve ser maior que zero."),
date: z.coerce.date({ message: "Informe uma data válida." }), date: z.coerce.date({ message: "Informe uma data válida." }),
period: z period: z
.string({ message: "Informe o período." }) .string({ message: "Informe o período." })
.trim() .trim()
.min(1, "Informe o período."), .min(1, "Informe o período."),
}); });
type TransferInput = z.infer<typeof transferSchema>; type TransferInput = z.infer<typeof transferSchema>;
export async function transferBetweenAccountsAction( export async function transferBetweenAccountsAction(
input: TransferInput input: TransferInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
const data = transferSchema.parse(input); const data = transferSchema.parse(input);
// Validate that accounts are different // Validate that accounts are different
if (data.fromAccountId === data.toAccountId) { if (data.fromAccountId === data.toAccountId) {
return { return {
success: false, success: false,
error: "A conta de origem e destino devem ser diferentes.", error: "A conta de origem e destino devem ser diferentes.",
}; };
} }
// Generate a unique transfer ID to link both transactions // Generate a unique transfer ID to link both transactions
const transferId = crypto.randomUUID(); const transferId = crypto.randomUUID();
await db.transaction(async (tx: typeof db) => { await db.transaction(async (tx: typeof db) => {
// Verify both accounts exist and belong to the user // Verify both accounts exist and belong to the user
const [fromAccount, toAccount] = await Promise.all([ const [fromAccount, toAccount] = await Promise.all([
tx.query.contas.findFirst({ tx.query.contas.findFirst({
columns: { id: true, name: true }, columns: { id: true, name: true },
where: and( where: and(
eq(contas.id, data.fromAccountId), eq(contas.id, data.fromAccountId),
eq(contas.userId, user.id) eq(contas.userId, user.id),
), ),
}), }),
tx.query.contas.findFirst({ tx.query.contas.findFirst({
columns: { id: true, name: true }, columns: { id: true, name: true },
where: and( where: and(
eq(contas.id, data.toAccountId), eq(contas.id, data.toAccountId),
eq(contas.userId, user.id) eq(contas.userId, user.id),
), ),
}), }),
]); ]);
if (!fromAccount) { if (!fromAccount) {
throw new Error("Conta de origem não encontrada."); throw new Error("Conta de origem não encontrada.");
} }
if (!toAccount) { if (!toAccount) {
throw new Error("Conta de destino não encontrada."); throw new Error("Conta de destino não encontrada.");
} }
// Get the transfer category // Get the transfer category
const transferCategory = await tx.query.categorias.findFirst({ const transferCategory = await tx.query.categorias.findFirst({
columns: { id: true }, columns: { id: true },
where: and( where: and(
eq(categorias.userId, user.id), eq(categorias.userId, user.id),
eq(categorias.name, TRANSFER_CATEGORY_NAME) eq(categorias.name, TRANSFER_CATEGORY_NAME),
), ),
}); });
if (!transferCategory) { if (!transferCategory) {
throw new Error( throw new Error(
`Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.` `Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`,
); );
} }
// Get the admin payer // Get the admin payer
const adminPagador = await tx.query.pagadores.findFirst({ const adminPagador = await tx.query.pagadores.findFirst({
columns: { id: true }, columns: { id: true },
where: and( where: and(
eq(pagadores.userId, user.id), eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN) eq(pagadores.role, PAGADOR_ROLE_ADMIN),
), ),
}); });
if (!adminPagador) { if (!adminPagador) {
throw new Error( throw new Error(
"Pagador administrador não encontrado. Por favor, crie um pagador admin." "Pagador administrador não encontrado. Por favor, crie um pagador admin.",
); );
} }
// Create outgoing transaction (transfer from source account) // Create outgoing transaction (transfer from source account)
await tx.insert(lancamentos).values({ await tx.insert(lancamentos).values({
condition: TRANSFER_CONDITION, condition: TRANSFER_CONDITION,
name: `${TRANSFER_ESTABLISHMENT}${toAccount.name}`, name: `${TRANSFER_ESTABLISHMENT}${toAccount.name}`,
paymentMethod: TRANSFER_PAYMENT_METHOD, paymentMethod: TRANSFER_PAYMENT_METHOD,
note: `Transferência para ${toAccount.name}`, note: `Transferência para ${toAccount.name}`,
amount: formatDecimalForDbRequired(-Math.abs(data.amount)), amount: formatDecimalForDbRequired(-Math.abs(data.amount)),
purchaseDate: data.date, purchaseDate: data.date,
transactionType: "Transferência", transactionType: "Transferência",
period: data.period, period: data.period,
isSettled: true, isSettled: true,
userId: user.id, userId: user.id,
contaId: fromAccount.id, contaId: fromAccount.id,
categoriaId: transferCategory.id, categoriaId: transferCategory.id,
pagadorId: adminPagador.id, pagadorId: adminPagador.id,
transferId, transferId,
}); });
// Create incoming transaction (transfer to destination account) // Create incoming transaction (transfer to destination account)
await tx.insert(lancamentos).values({ await tx.insert(lancamentos).values({
condition: TRANSFER_CONDITION, condition: TRANSFER_CONDITION,
name: `${TRANSFER_ESTABLISHMENT}${fromAccount.name}`, name: `${TRANSFER_ESTABLISHMENT}${fromAccount.name}`,
paymentMethod: TRANSFER_PAYMENT_METHOD, paymentMethod: TRANSFER_PAYMENT_METHOD,
note: `Transferência de ${fromAccount.name}`, note: `Transferência de ${fromAccount.name}`,
amount: formatDecimalForDbRequired(Math.abs(data.amount)), amount: formatDecimalForDbRequired(Math.abs(data.amount)),
purchaseDate: data.date, purchaseDate: data.date,
transactionType: "Transferência", transactionType: "Transferência",
period: data.period, period: data.period,
isSettled: true, isSettled: true,
userId: user.id, userId: user.id,
contaId: toAccount.id, contaId: toAccount.id,
categoriaId: transferCategory.id, categoriaId: transferCategory.id,
pagadorId: adminPagador.id, pagadorId: adminPagador.id,
transferId, transferId,
}); });
}); });
revalidateForEntity("contas"); revalidateForEntity("contas");
revalidateForEntity("lancamentos"); revalidateForEntity("lancamentos");
return { return {
success: true, success: true,
message: "Transferência registrada com sucesso.", message: "Transferência registrada com sucesso.",
}; };
} catch (error) { } catch (error) {
return handleActionError(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 { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants"; import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { loadLogoOptions } from "@/lib/logo/options"; import { loadLogoOptions } from "@/lib/logo/options";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, ilike, not, sql } from "drizzle-orm";
export type AccountData = { export type AccountData = {
id: string; id: string;
name: string; name: string;
accountType: string; accountType: string;
status: string; status: string;
note: string | null; note: string | null;
logo: string | null; logo: string | null;
initialBalance: number; initialBalance: number;
balance: number; balance: number;
excludeFromBalance: boolean; excludeFromBalance: boolean;
excludeInitialBalanceFromIncome: boolean; excludeInitialBalanceFromIncome: boolean;
}; };
export async function fetchAccountsForUser( export async function fetchAccountsForUser(
userId: string userId: string,
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> { ): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
const [accountRows, logoOptions] = await Promise.all([ const [accountRows, logoOptions] = await Promise.all([
db db
.select({ .select({
id: contas.id, id: contas.id,
name: contas.name, name: contas.name,
accountType: contas.accountType, accountType: contas.accountType,
status: contas.status, status: contas.status,
note: contas.note, note: contas.note,
logo: contas.logo, logo: contas.logo,
initialBalance: contas.initialBalance, initialBalance: contas.initialBalance,
excludeFromBalance: contas.excludeFromBalance, excludeFromBalance: contas.excludeFromBalance,
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome, excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
balanceMovements: sql<number>` balanceMovements: sql<number>`
coalesce( coalesce(
sum( sum(
case case
@@ -44,72 +44,72 @@ export async function fetchAccountsForUser(
0 0
) )
`, `,
}) })
.from(contas) .from(contas)
.leftJoin( .leftJoin(
lancamentos, lancamentos,
and( and(
eq(lancamentos.contaId, contas.id), eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true) eq(lancamentos.isSettled, true),
) ),
) )
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where( .where(
and( and(
eq(contas.userId, userId), eq(contas.userId, userId),
not(ilike(contas.status, "inativa")), not(ilike(contas.status, "inativa")),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})` sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
) ),
) )
.groupBy( .groupBy(
contas.id, contas.id,
contas.name, contas.name,
contas.accountType, contas.accountType,
contas.status, contas.status,
contas.note, contas.note,
contas.logo, contas.logo,
contas.initialBalance, contas.initialBalance,
contas.excludeFromBalance, contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome contas.excludeInitialBalanceFromIncome,
), ),
loadLogoOptions(), loadLogoOptions(),
]); ]);
const accounts = accountRows.map((account) => ({ const accounts = accountRows.map((account) => ({
id: account.id, id: account.id,
name: account.name, name: account.name,
accountType: account.accountType, accountType: account.accountType,
status: account.status, status: account.status,
note: account.note, note: account.note,
logo: account.logo, logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0), initialBalance: Number(account.initialBalance ?? 0),
balance: balance:
Number(account.initialBalance ?? 0) + Number(account.initialBalance ?? 0) +
Number(account.balanceMovements ?? 0), Number(account.balanceMovements ?? 0),
excludeFromBalance: account.excludeFromBalance, excludeFromBalance: account.excludeFromBalance,
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome, excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome,
})); }));
return { accounts, logoOptions }; return { accounts, logoOptions };
} }
export async function fetchInativosForUser( export async function fetchInativosForUser(
userId: string userId: string,
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> { ): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
const [accountRows, logoOptions] = await Promise.all([ const [accountRows, logoOptions] = await Promise.all([
db db
.select({ .select({
id: contas.id, id: contas.id,
name: contas.name, name: contas.name,
accountType: contas.accountType, accountType: contas.accountType,
status: contas.status, status: contas.status,
note: contas.note, note: contas.note,
logo: contas.logo, logo: contas.logo,
initialBalance: contas.initialBalance, initialBalance: contas.initialBalance,
excludeFromBalance: contas.excludeFromBalance, excludeFromBalance: contas.excludeFromBalance,
excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome, excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome,
balanceMovements: sql<number>` balanceMovements: sql<number>`
coalesce( coalesce(
sum( sum(
case case
@@ -120,52 +120,52 @@ export async function fetchInativosForUser(
0 0
) )
`, `,
}) })
.from(contas) .from(contas)
.leftJoin( .leftJoin(
lancamentos, lancamentos,
and( and(
eq(lancamentos.contaId, contas.id), eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true) eq(lancamentos.isSettled, true),
) ),
) )
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where( .where(
and( and(
eq(contas.userId, userId), eq(contas.userId, userId),
ilike(contas.status, "inativa"), ilike(contas.status, "inativa"),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})` sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
) ),
) )
.groupBy( .groupBy(
contas.id, contas.id,
contas.name, contas.name,
contas.accountType, contas.accountType,
contas.status, contas.status,
contas.note, contas.note,
contas.logo, contas.logo,
contas.initialBalance, contas.initialBalance,
contas.excludeFromBalance, contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome contas.excludeInitialBalanceFromIncome,
), ),
loadLogoOptions(), loadLogoOptions(),
]); ]);
const accounts = accountRows.map((account) => ({ const accounts = accountRows.map((account) => ({
id: account.id, id: account.id,
name: account.name, name: account.name,
accountType: account.accountType, accountType: account.accountType,
status: account.status, status: account.status,
note: account.note, note: account.note,
logo: account.logo, logo: account.logo,
initialBalance: Number(account.initialBalance ?? 0), initialBalance: Number(account.initialBalance ?? 0),
balance: balance:
Number(account.initialBalance ?? 0) + Number(account.initialBalance ?? 0) +
Number(account.balanceMovements ?? 0), Number(account.balanceMovements ?? 0),
excludeFromBalance: account.excludeFromBalance, excludeFromBalance: account.excludeFromBalance,
excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome, 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"; import { fetchInativosForUser } from "../data";
export default async function InativosPage() { export default async function InativosPage() {
const userId = await getUserId(); const userId = await getUserId();
const { accounts, logoOptions } = await fetchInativosForUser(userId); const { accounts, logoOptions } = await fetchInativosForUser(userId);
return ( return (
<main className="flex flex-col items-start gap-6"> <main className="flex flex-col items-start gap-6">
<AccountsPage accounts={accounts} logoOptions={logoOptions} isInativos={true} /> <AccountsPage
</main> 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 { RiBankLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = { export const metadata = {
title: "Contas | Opensheets", title: "Contas | Opensheets",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 px-6">
<PageDescription <PageDescription
icon={<RiBankLine />} icon={<RiBankLine />}
title="Contas" title="Contas"
subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas, subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas,
despesas e transações previstas. Use o seletor abaixo para navegar pelos despesas e transações previstas. Use o seletor abaixo para navegar pelos
meses e visualizar as movimentações correspondentes." meses e visualizar as movimentações correspondentes."
/> />
{children} {children}
</section> </section>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -3,12 +3,12 @@ import { getUser } from "@/lib/auth/server";
import { fetchInstallmentAnalysis } from "@/lib/dashboard/expenses/installment-analysis"; import { fetchInstallmentAnalysis } from "@/lib/dashboard/expenses/installment-analysis";
export default async function Page() { export default async function Page() {
const user = await getUser(); const user = await getUser();
const data = await fetchInstallmentAnalysis(user.id); const data = await fetchInstallmentAnalysis(user.id);
return ( return (
<main className="flex flex-col gap-4 pb-8"> <main className="flex flex-col gap-4 pb-8">
<InstallmentAnalysisPage data={data} /> <InstallmentAnalysisPage data={data} />
</main> </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 * Usa skeleton fiel ao layout final para evitar layout shift
*/ */
export default function DashboardLoading() { export default function DashboardLoading() {
return ( return (
<main className="flex flex-col gap-6 px-6"> <main className="flex flex-col gap-6 px-6">
{/* Month Picker placeholder */} {/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" /> <div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Dashboard content skeleton */} {/* Dashboard content skeleton */}
<DashboardGridSkeleton /> <DashboardGridSkeleton />
</main> </main>
); );
} }

View File

@@ -4,59 +4,50 @@ import { SectionCards } from "@/components/dashboard/section-cards";
import MonthNavigation from "@/components/month-picker/month-navigation"; import MonthNavigation from "@/components/month-picker/month-navigation";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data"; import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import { db, schema } from "@/lib/db";
import { parsePeriodParam } from "@/lib/utils/period"; import { parsePeriodParam } from "@/lib/utils/period";
import { eq } from "drizzle-orm"; import { fetchUserDashboardPreferences } from "./data";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>; type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = { type PageProps = {
searchParams?: PageSearchParams; searchParams?: PageSearchParams;
}; };
const getSingleParam = ( const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined, params: Record<string, string | string[] | undefined> | undefined,
key: string, key: string,
) => { ) => {
const value = params?.[key]; const value = params?.[key];
if (!value) return null; if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value; return Array.isArray(value) ? (value[0] ?? null) : value;
}; };
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
const user = await getUser(); const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam); const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [data, preferencesResult] = await Promise.all([ const [data, preferences] = await Promise.all([
fetchDashboardData(user.id, selectedPeriod), fetchDashboardData(user.id, selectedPeriod),
db fetchUserDashboardPreferences(user.id),
.select({ ]);
disableMagnetlines: schema.userPreferences.disableMagnetlines,
dashboardWidgets: schema.userPreferences.dashboardWidgets,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, user.id))
.limit(1),
]);
const disableMagnetlines = preferencesResult[0]?.disableMagnetlines ?? false; const { disableMagnetlines, dashboardWidgets } = preferences;
const dashboardWidgets = preferencesResult[0]?.dashboardWidgets ?? null;
return ( return (
<main className="flex flex-col gap-4 px-6"> <main className="flex flex-col gap-4 px-6">
<DashboardWelcome <DashboardWelcome
name={user.name} name={user.name}
disableMagnetlines={disableMagnetlines} disableMagnetlines={disableMagnetlines}
/> />
<MonthNavigation /> <MonthNavigation />
<SectionCards metrics={data.metrics} /> <SectionCards metrics={data.metrics} />
<DashboardGridEditable <DashboardGridEditable
data={data} data={data}
period={selectedPeriod} period={selectedPeriod}
initialPreferences={dashboardWidgets} initialPreferences={dashboardWidgets}
/> />
</main> </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 * Metadados dos providers
*/ */
export const PROVIDERS = { export const PROVIDERS = {
openai: { openai: {
id: "openai" as const, id: "openai" as const,
name: "ChatGPT", name: "ChatGPT",
icon: "RiOpenaiLine", icon: "RiOpenaiLine",
}, },
anthropic: { anthropic: {
id: "anthropic" as const, id: "anthropic" as const,
name: "Claude AI", name: "Claude AI",
icon: "RiRobot2Line", icon: "RiRobot2Line",
}, },
google: { google: {
id: "google" as const, id: "google" as const,
name: "Gemini", name: "Gemini",
icon: "RiGoogleLine", icon: "RiGoogleLine",
}, },
openrouter: { openrouter: {
id: "openrouter" as const, id: "openrouter" as const,
name: "OpenRouter", name: "OpenRouter",
icon: "RiRouterLine", icon: "RiRouterLine",
}, },
} as const; } as const;
/** /**
* Lista de modelos de IA disponíveis para análise de insights * Lista de modelos de IA disponíveis para análise de insights
*/ */
export const AVAILABLE_MODELS = [ export const AVAILABLE_MODELS = [
// OpenAI Models - GPT-5.2 Family (Latest) // OpenAI Models - GPT-5.2 Family (Latest)
{ id: "gpt-5.2", name: "GPT-5.2", provider: "openai" as const }, { id: "gpt-5.2", name: "GPT-5.2", provider: "openai" as const },
{ {
id: "gpt-5.2-instant", id: "gpt-5.2-instant",
name: "GPT-5.2 Instant", name: "GPT-5.2 Instant",
provider: "openai" as const, provider: "openai" as const,
}, },
{ {
id: "gpt-5.2-thinking", id: "gpt-5.2-thinking",
name: "GPT-5.2 Thinking", name: "GPT-5.2 Thinking",
provider: "openai" as const, provider: "openai" as const,
}, },
// OpenAI Models - GPT-5 Family // OpenAI Models - GPT-5 Family
{ id: "gpt-5", name: "GPT-5", provider: "openai" as const }, { id: "gpt-5", name: "GPT-5", provider: "openai" as const },
{ id: "gpt-5-instant", name: "GPT-5 Instant", provider: "openai" as const }, { id: "gpt-5-instant", name: "GPT-5 Instant", provider: "openai" as const },
// Anthropic Models - Claude 4.5 // Anthropic Models - Claude 4.5
{ {
id: "claude-4.5-haiku", id: "claude-4.5-haiku",
name: "Claude 4.5 Haiku", name: "Claude 4.5 Haiku",
provider: "anthropic" as const, provider: "anthropic" as const,
}, },
{ {
id: "claude-4.5-sonnet", id: "claude-4.5-sonnet",
name: "Claude 4.5 Sonnet", name: "Claude 4.5 Sonnet",
provider: "anthropic" as const, provider: "anthropic" as const,
}, },
{ {
id: "claude-opus-4.1", id: "claude-opus-4.1",
name: "Claude 4.1 Opus", name: "Claude 4.1 Opus",
provider: "anthropic" as const, provider: "anthropic" as const,
}, },
// Google Models - Gemini 3 (Latest) // Google Models - Gemini 3 (Latest)
{ {
id: "gemini-3-flash-preview", id: "gemini-3-flash-preview",
name: "Gemini 3 Flash", name: "Gemini 3 Flash",
provider: "google" as const, provider: "google" as const,
}, },
{ {
id: "gemini-3-pro-preview", id: "gemini-3-pro-preview",
name: "Gemini 3 Pro", name: "Gemini 3 Pro",
provider: "google" as const, provider: "google" as const,
}, },
// Google Models - Gemini 2.0 // Google Models - Gemini 2.0
{ {
id: "gemini-2.0-flash", id: "gemini-2.0-flash",
name: "Gemini 2.0 Flash", name: "Gemini 2.0 Flash",
provider: "google" as const, provider: "google" as const,
}, },
] as const; ] as const;
export const DEFAULT_MODEL = "gpt-5.2"; 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 { RiSparklingLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = { export const metadata = {
title: "Insights | Opensheets", title: "Insights | Opensheets",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 px-6">
<PageDescription <PageDescription
icon={<RiSparklingLine />} icon={<RiSparklingLine />}
title="Insights" title="Insights"
subtitle="Análise inteligente dos seus dados financeiros para identificar padrões, comportamentos e oportunidades de melhoria." subtitle="Análise inteligente dos seus dados financeiros para identificar padrões, comportamentos e oportunidades de melhoria."
/> />
{children} {children}
</section> </section>
); );
} }

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,125 +1,127 @@
import { and, asc, eq, inArray, sum } from "drizzle-orm";
import { import {
categorias, categorias,
lancamentos, lancamentos,
orcamentos, type Orcamento,
type Orcamento, orcamentos,
} from "@/db/schema"; } from "@/db/schema";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { and, asc, eq, inArray, sum } from "drizzle-orm";
const toNumber = (value: string | number | null | undefined) => { const toNumber = (value: string | number | null | undefined) => {
if (typeof value === "number") return value; if (typeof value === "number") return value;
if (typeof value === "string") { if (typeof value === "string") {
const parsed = Number.parseFloat(value); const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? 0 : parsed; return Number.isNaN(parsed) ? 0 : parsed;
} }
return 0; return 0;
}; };
export type BudgetData = { export type BudgetData = {
id: string; id: string;
amount: number; amount: number;
spent: number; spent: number;
period: string; period: string;
createdAt: string; createdAt: string;
category: { category: {
id: string; id: string;
name: string; name: string;
icon: string | null; icon: string | null;
} | null; } | null;
}; };
export type CategoryOption = { export type CategoryOption = {
id: string; id: string;
name: string; name: string;
icon: string | null; icon: string | null;
}; };
export async function fetchBudgetsForUser( export async function fetchBudgetsForUser(
userId: string, userId: string,
selectedPeriod: string selectedPeriod: string,
): Promise<{ ): Promise<{
budgets: BudgetData[]; budgets: BudgetData[];
categoriesOptions: CategoryOption[]; categoriesOptions: CategoryOption[];
}> { }> {
const [budgetRows, categoryRows] = await Promise.all([ const [budgetRows, categoryRows] = await Promise.all([
db.query.orcamentos.findMany({ db.query.orcamentos.findMany({
where: and( where: and(
eq(orcamentos.userId, userId), eq(orcamentos.userId, userId),
eq(orcamentos.period, selectedPeriod) eq(orcamentos.period, selectedPeriod),
), ),
with: { with: {
categoria: true, categoria: true,
}, },
}), }),
db.query.categorias.findMany({ db.query.categorias.findMany({
columns: { columns: {
id: true, id: true,
name: true, name: true,
icon: true, icon: true,
}, },
where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")), where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")),
orderBy: asc(categorias.name), orderBy: asc(categorias.name),
}), }),
]); ]);
const categoryIds = budgetRows const categoryIds = budgetRows
.map((budget: Orcamento) => budget.categoriaId) .map((budget: Orcamento) => budget.categoriaId)
.filter((id: string | null): id is string => Boolean(id)); .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) { if (categoryIds.length > 0) {
const totals = await db const totals = await db
.select({ .select({
categoriaId: lancamentos.categoriaId, categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("totalAmount"), totalAmount: sum(lancamentos.amount).as("totalAmount"),
}) })
.from(lancamentos) .from(lancamentos)
.where( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.period, selectedPeriod), eq(lancamentos.period, selectedPeriod),
eq(lancamentos.transactionType, "Despesa"), eq(lancamentos.transactionType, "Despesa"),
inArray(lancamentos.categoriaId, categoryIds) inArray(lancamentos.categoriaId, categoryIds),
) ),
) )
.groupBy(lancamentos.categoriaId); .groupBy(lancamentos.categoriaId);
totalsByCategory = new Map( totalsByCategory = new Map(
totals.map((row: { categoriaId: string | null; totalAmount: string | null }) => [ totals.map(
row.categoriaId ?? "", (row: { categoriaId: string | null; totalAmount: string | null }) => [
Math.abs(toNumber(row.totalAmount)), row.categoriaId ?? "",
]) Math.abs(toNumber(row.totalAmount)),
); ],
} ),
);
}
const budgets = budgetRows const budgets = budgetRows
.map((budget: Orcamento) => ({ .map((budget: Orcamento) => ({
id: budget.id, id: budget.id,
amount: toNumber(budget.amount), amount: toNumber(budget.amount),
spent: totalsByCategory.get(budget.categoriaId ?? "") ?? 0, spent: totalsByCategory.get(budget.categoriaId ?? "") ?? 0,
period: budget.period, period: budget.period,
createdAt: budget.createdAt.toISOString(), createdAt: budget.createdAt.toISOString(),
category: budget.categoria category: budget.categoria
? { ? {
id: budget.categoria.id, id: budget.categoria.id,
name: budget.categoria.name, name: budget.categoria.name,
icon: budget.categoria.icon, icon: budget.categoria.icon,
} }
: null, : null,
})) }))
.sort((a, b) => .sort((a, b) =>
(a.category?.name ?? "").localeCompare(b.category?.name ?? "", "pt-BR", { (a.category?.name ?? "").localeCompare(b.category?.name ?? "", "pt-BR", {
sensitivity: "base", sensitivity: "base",
}) }),
); );
const categoriesOptions = categoryRows.map((category) => ({ const categoriesOptions = categoryRows.map((category) => ({
id: category.id, id: category.id,
name: category.name, name: category.name,
icon: category.icon, 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 { RiFundsLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = { export const metadata = {
title: "Anotações | Opensheets", title: "Anotações | Opensheets",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 px-6">
<PageDescription <PageDescription
icon={<RiFundsLine />} icon={<RiFundsLine />}
title="Orçamentos" 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." subtitle="Gerencie seus orçamentos mensais por categorias. Acompanhe o progresso do seu orçamento e faça ajustes conforme necessário."
/> />
{children} {children}
</section> </section>
); );
} }

View File

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

View File

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

View File

@@ -1,234 +1,234 @@
"use server"; "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 { and, desc, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { Resend } from "resend"; import { Resend } from "resend";
import { z } from "zod"; 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({ const inputSchema = z.object({
pagadorId: z.string().uuid("Pagador inválido."), pagadorId: z.string().uuid("Pagador inválido."),
period: z period: z
.string() .string()
.regex(/^\d{4}-\d{2}$/, "Período inválido. Informe no formato AAAA-MM."), .regex(/^\d{4}-\d{2}$/, "Período inválido. Informe no formato AAAA-MM."),
}); });
type ActionResult = type ActionResult =
| { success: true; message: string } | { success: true; message: string }
| { success: false; error: string }; | { success: false; error: string };
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", { value.toLocaleString("pt-BR", {
style: "currency", style: "currency",
currency: "BRL", currency: "BRL",
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
const formatDate = (value: Date | null | undefined) => { const formatDate = (value: Date | null | undefined) => {
if (!value) return "—"; if (!value) return "—";
return value.toLocaleDateString("pt-BR", { return value.toLocaleDateString("pt-BR", {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
year: "numeric", year: "numeric",
}); });
}; };
// Escapa HTML para prevenir XSS // Escapa HTML para prevenir XSS
const escapeHtml = (text: string | null | undefined): string => { const escapeHtml = (text: string | null | undefined): string => {
if (!text) return ""; if (!text) return "";
return text return text
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;") .replace(/"/g, "&quot;")
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
}; };
type LancamentoRow = { type LancamentoRow = {
id: string; id: string;
name: string | null; name: string | null;
paymentMethod: string | null; paymentMethod: string | null;
condition: string | null; condition: string | null;
amount: number; amount: number;
transactionType: string | null; transactionType: string | null;
purchaseDate: Date | null; purchaseDate: Date | null;
}; };
type BoletoItem = { type BoletoItem = {
name: string; name: string;
amount: number; amount: number;
dueDate: Date | null; dueDate: Date | null;
}; };
type ParceladoItem = { type ParceladoItem = {
name: string; name: string;
totalAmount: number; totalAmount: number;
installmentCount: number; installmentCount: number;
currentInstallment: number; currentInstallment: number;
installmentAmount: number; installmentAmount: number;
purchaseDate: Date | null; purchaseDate: Date | null;
}; };
type SummaryPayload = { type SummaryPayload = {
pagadorName: string; pagadorName: string;
periodLabel: string; periodLabel: string;
monthlyBreakdown: Awaited<ReturnType<typeof fetchPagadorMonthlyBreakdown>>; monthlyBreakdown: Awaited<ReturnType<typeof fetchPagadorMonthlyBreakdown>>;
historyData: Awaited<ReturnType<typeof fetchPagadorHistory>>; historyData: Awaited<ReturnType<typeof fetchPagadorHistory>>;
cardUsage: Awaited<ReturnType<typeof fetchPagadorCardUsage>>; cardUsage: Awaited<ReturnType<typeof fetchPagadorCardUsage>>;
boletoStats: Awaited<ReturnType<typeof fetchPagadorBoletoStats>>; boletoStats: Awaited<ReturnType<typeof fetchPagadorBoletoStats>>;
boletos: BoletoItem[]; boletos: BoletoItem[];
lancamentos: LancamentoRow[]; lancamentos: LancamentoRow[];
parcelados: ParceladoItem[]; parcelados: ParceladoItem[];
}; };
const buildSectionHeading = (label: string) => 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 = ({ const buildSummaryHtml = ({
pagadorName, pagadorName,
periodLabel, periodLabel,
monthlyBreakdown, monthlyBreakdown,
historyData, historyData,
cardUsage, cardUsage,
boletoStats, boletoStats,
boletos, boletos,
lancamentos, lancamentos,
parcelados, parcelados,
}: SummaryPayload) => { }: SummaryPayload) => {
// Calcular máximo de despesas para barras de progresso // Calcular máximo de despesas para barras de progresso
const maxDespesas = Math.max(...historyData.map((p) => p.despesas), 1); const maxDespesas = Math.max(...historyData.map((p) => p.despesas), 1);
const historyRows = const historyRows =
historyData.length > 0 historyData.length > 0
? historyData ? historyData
.map((point) => { .map((point) => {
const percentage = (point.despesas / maxDespesas) * 100; const percentage = (point.despesas / maxDespesas) * 100;
const barColor = const barColor =
point.despesas > maxDespesas * 0.8 point.despesas > maxDespesas * 0.8
? "#ef4444" ? "#ef4444"
: point.despesas > maxDespesas * 0.5 : point.despesas > maxDespesas * 0.5
? "#f59e0b" ? "#f59e0b"
: "#10b981"; : "#10b981";
return ` return `
<tr> <tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml( <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
point.label point.label,
)}</td> )}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;"> <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">
<div style="display:flex;align-items:center;gap:12px;"> <div style="display:flex;align-items:center;gap:12px;">
<div style="flex:1;background:#f1f5f9;border-radius:6px;height:24px;overflow:hidden;"> <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 style="background:${barColor};height:100%;width:${percentage}%;transition:width 0.3s;"></div>
</div> </div>
<span style="font-weight:600;min-width:100px;text-align:right;">${formatCurrency( <span style="font-weight:600;min-width:100px;text-align:right;">${formatCurrency(
point.despesas point.despesas,
)}</span> )}</span>
</div> </div>
</td> </td>
</tr>`; </tr>`;
}) })
.join("") .join("")
: `<tr><td colspan="2" style="padding:16px;text-align:center;color:#94a3b8;">Sem histórico suficiente.</td></tr>`; : `<tr><td colspan="2" style="padding:16px;text-align:center;color:#94a3b8;">Sem histórico suficiente.</td></tr>`;
const cardUsageRows = const cardUsageRows =
cardUsage.length > 0 cardUsage.length > 0
? cardUsage ? cardUsage
.map( .map(
(item) => ` (item) => `
<tr> <tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml( <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
item.name item.name,
)}</td> )}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency( <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.amount item.amount,
)}</td> )}</td>
</tr>` </tr>`,
) )
.join("") .join("")
: `<tr><td colspan="2" style="padding:16px;text-align:center;color:#94a3b8;">Sem gastos com cartão neste período.</td></tr>`; : `<tr><td colspan="2" style="padding:16px;text-align:center;color:#94a3b8;">Sem gastos com cartão neste período.</td></tr>`;
const boletoRows = const boletoRows =
boletos.length > 0 boletos.length > 0
? boletos ? boletos
.map( .map(
(item) => ` (item) => `
<tr> <tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml( <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
item.name item.name,
)}</td> )}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${ <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
item.dueDate ? formatDate(item.dueDate) : "—" item.dueDate ? formatDate(item.dueDate) : "—"
}</td> }</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency( <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.amount item.amount,
)}</td> )}</td>
</tr>` </tr>`,
) )
.join("") .join("")
: `<tr><td colspan="3" style="padding:16px;text-align:center;color:#94a3b8;">Sem boletos neste período.</td></tr>`; : `<tr><td colspan="3" style="padding:16px;text-align:center;color:#94a3b8;">Sem boletos neste período.</td></tr>`;
const lancamentoRows = const lancamentoRows =
lancamentos.length > 0 lancamentos.length > 0
? lancamentos ? lancamentos
.map( .map(
(item) => ` (item) => `
<tr> <tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate( <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate(
item.purchaseDate item.purchaseDate,
)}</td> )}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${ <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
escapeHtml(item.name) || "Sem descrição" escapeHtml(item.name) || "Sem descrição"
}</td> }</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${ <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
escapeHtml(item.condition) || "—" escapeHtml(item.condition) || "—"
}</td> }</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${ <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
escapeHtml(item.paymentMethod) || "—" escapeHtml(item.paymentMethod) || "—"
}</td> }</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency( <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.amount item.amount,
)}</td> )}</td>
</tr>` </tr>`,
) )
.join("") .join("")
: `<tr><td colspan="5" style="padding:16px;text-align:center;color:#94a3b8;">Nenhum lançamento registrado no período.</td></tr>`; : `<tr><td colspan="5" style="padding:16px;text-align:center;color:#94a3b8;">Nenhum lançamento registrado no período.</td></tr>`;
const parceladoRows = const parceladoRows =
parcelados.length > 0 parcelados.length > 0
? parcelados ? parcelados
.map( .map(
(item) => ` (item) => `
<tr> <tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate( <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate(
item.purchaseDate item.purchaseDate,
)}</td> )}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${ <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
escapeHtml(item.name) || "Sem descrição" escapeHtml(item.name) || "Sem descrição"
}</td> }</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:center;">${ <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:center;">${
item.currentInstallment item.currentInstallment
}/${item.installmentCount}</td> }/${item.installmentCount}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency( <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.installmentAmount item.installmentAmount,
)}</td> )}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;color:#64748b;">${formatCurrency( <td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;color:#64748b;">${formatCurrency(
item.totalAmount item.totalAmount,
)}</td> )}</td>
</tr>` </tr>`,
) )
.join("") .join("")
: `<tr><td colspan="5" style="padding:16px;text-align:center;color:#94a3b8;">Nenhum lançamento parcelado neste período.</td></tr>`; : `<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;"> <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) --> <!-- 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> <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;"> <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> <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( <p style="margin:0;font-size:15px;color:#ffece6;">${escapeHtml(
periodLabel periodLabel,
)}</p> )}</p>
</div> </div>
<!-- Cartão principal --> <!-- Cartão principal -->
@@ -246,8 +246,8 @@ const buildSummaryHtml = ({
<!-- Saudação --> <!-- Saudação -->
<p style="margin:0 0 24px 0;font-size:15px;color:#334155;"> <p style="margin:0 0 24px 0;font-size:15px;color:#334155;">
Olá <strong>${escapeHtml( Olá <strong>${escapeHtml(
pagadorName pagadorName,
)}</strong>, segue o consolidado do mês: )}</strong>, segue o consolidado do mês:
</p> </p>
<!-- Totais do mês --> <!-- 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;font-size:15px;color:#475569;">Total gasto</td>
<td style="padding:16px 18px;background:#fff7f5;border-bottom:1px solid #f1f5f9;text-align:right;"> <td style="padding:16px 18px;background:#fff7f5;border-bottom:1px solid #f1f5f9;text-align:right;">
<strong style="font-size:22px;color:#0f172a;">${formatCurrency( <strong style="font-size:22px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.totalExpenses monthlyBreakdown.totalExpenses,
)}</strong> )}</strong>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding:12px 18px;font-size:14px;color:#64748b;">💳 Cartões</td> <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( <td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.card monthlyBreakdown.paymentSplits.card,
)}</strong></td> )}</strong></td>
</tr> </tr>
<tr style="background:#fcfcfd;"> <tr style="background:#fcfcfd;">
<td style="padding:12px 18px;font-size:14px;color:#64748b;">📄 Boletos</td> <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( <td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.boleto monthlyBreakdown.paymentSplits.boleto,
)}</strong></td> )}</strong></td>
</tr> </tr>
<tr> <tr>
<td style="padding:12px 18px;font-size:14px;color:#64748b;">⚡ Pix/Débito/Dinheiro</td> <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( <td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.instant monthlyBreakdown.paymentSplits.instant,
)}</strong></td> )}</strong></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -305,8 +305,8 @@ const buildSummaryHtml = ({
<td style="color:#475569;font-weight:700;font-size:15px;">Total</td> <td style="color:#475569;font-weight:700;font-size:15px;">Total</td>
<td style="text-align:right;"> <td style="text-align:right;">
<strong style="font-size:18px;color:#0f172a;">${formatCurrency( <strong style="font-size:18px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.card monthlyBreakdown.paymentSplits.card,
)}</strong> )}</strong>
</td> </td>
</tr> </tr>
</table> </table>
@@ -333,8 +333,8 @@ const buildSummaryHtml = ({
<td style="color:#475569;font-weight:700;font-size:15px;">Total</td> <td style="color:#475569;font-weight:700;font-size:15px;">Total</td>
<td style="text-align:right;"> <td style="text-align:right;">
<strong style="font-size:18px;color:#0f172a;">${formatCurrency( <strong style="font-size:18px;color:#0f172a;">${formatCurrency(
boletoStats.totalAmount boletoStats.totalAmount,
)}</strong> )}</strong>
</td> </td>
</tr> </tr>
</table> </table>
@@ -396,207 +396,207 @@ const buildSummaryHtml = ({
}; };
export async function sendPagadorSummaryAction( export async function sendPagadorSummaryAction(
input: z.infer<typeof inputSchema> input: z.infer<typeof inputSchema>,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { pagadorId, period } = inputSchema.parse(input); const { pagadorId, period } = inputSchema.parse(input);
const user = await getUser(); const user = await getUser();
const pagadorRow = await db.query.pagadores.findFirst({ const pagadorRow = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, user.id)), where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, user.id)),
}); });
if (!pagadorRow) { if (!pagadorRow) {
return { success: false, error: "Pagador não encontrado." }; return { success: false, error: "Pagador não encontrado." };
} }
if (!pagadorRow.email) { if (!pagadorRow.email) {
return { return {
success: false, success: false,
error: "Cadastre um e-mail para conseguir enviar o resumo.", error: "Cadastre um e-mail para conseguir enviar o resumo.",
}; };
} }
const resendApiKey = process.env.RESEND_API_KEY; const resendApiKey = process.env.RESEND_API_KEY;
const resendFrom = const resendFrom =
process.env.RESEND_FROM_EMAIL ?? "Opensheets <onboarding@resend.dev>"; process.env.RESEND_FROM_EMAIL ?? "Opensheets <onboarding@resend.dev>";
if (!resendApiKey) { if (!resendApiKey) {
return { return {
success: false, success: false,
error: "Serviço de e-mail não configurado (RESEND_API_KEY ausente).", error: "Serviço de e-mail não configurado (RESEND_API_KEY ausente).",
}; };
} }
const resend = new Resend(resendApiKey); const resend = new Resend(resendApiKey);
const [ const [
monthlyBreakdown, monthlyBreakdown,
historyData, historyData,
cardUsage, cardUsage,
boletoStats, boletoStats,
boletoRows, boletoRows,
lancamentoRows, lancamentoRows,
parceladoRows, parceladoRows,
] = await Promise.all([ ] = await Promise.all([
fetchPagadorMonthlyBreakdown({ fetchPagadorMonthlyBreakdown({
userId: user.id, userId: user.id,
pagadorId, pagadorId,
period, period,
}), }),
fetchPagadorHistory({ fetchPagadorHistory({
userId: user.id, userId: user.id,
pagadorId, pagadorId,
period, period,
}), }),
fetchPagadorCardUsage({ fetchPagadorCardUsage({
userId: user.id, userId: user.id,
pagadorId, pagadorId,
period, period,
}), }),
fetchPagadorBoletoStats({ fetchPagadorBoletoStats({
userId: user.id, userId: user.id,
pagadorId, pagadorId,
period, period,
}), }),
db db
.select({ .select({
name: lancamentos.name, name: lancamentos.name,
amount: lancamentos.amount, amount: lancamentos.amount,
dueDate: lancamentos.dueDate, dueDate: lancamentos.dueDate,
}) })
.from(lancamentos) .from(lancamentos)
.where( .where(
and( and(
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
eq(lancamentos.pagadorId, pagadorId), eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period), eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, "Boleto") eq(lancamentos.paymentMethod, "Boleto"),
) ),
) )
.orderBy(desc(lancamentos.dueDate)), .orderBy(desc(lancamentos.dueDate)),
db db
.select({ .select({
id: lancamentos.id, id: lancamentos.id,
name: lancamentos.name, name: lancamentos.name,
paymentMethod: lancamentos.paymentMethod, paymentMethod: lancamentos.paymentMethod,
condition: lancamentos.condition, condition: lancamentos.condition,
amount: lancamentos.amount, amount: lancamentos.amount,
transactionType: lancamentos.transactionType, transactionType: lancamentos.transactionType,
purchaseDate: lancamentos.purchaseDate, purchaseDate: lancamentos.purchaseDate,
}) })
.from(lancamentos) .from(lancamentos)
.where( .where(
and( and(
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
eq(lancamentos.pagadorId, pagadorId), eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period) eq(lancamentos.period, period),
) ),
) )
.orderBy(desc(lancamentos.purchaseDate)), .orderBy(desc(lancamentos.purchaseDate)),
db db
.select({ .select({
name: lancamentos.name, name: lancamentos.name,
amount: lancamentos.amount, amount: lancamentos.amount,
installmentCount: lancamentos.installmentCount, installmentCount: lancamentos.installmentCount,
currentInstallment: lancamentos.currentInstallment, currentInstallment: lancamentos.currentInstallment,
purchaseDate: lancamentos.purchaseDate, purchaseDate: lancamentos.purchaseDate,
}) })
.from(lancamentos) .from(lancamentos)
.where( .where(
and( and(
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
eq(lancamentos.pagadorId, pagadorId), eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period), eq(lancamentos.period, period),
eq(lancamentos.condition, "Parcelado"), eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false) eq(lancamentos.isAnticipated, false),
) ),
) )
.orderBy(desc(lancamentos.purchaseDate)), .orderBy(desc(lancamentos.purchaseDate)),
]); ]);
const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({ const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({
name: row.name ?? "Sem descrição", name: row.name ?? "Sem descrição",
amount: Math.abs(Number(row.amount ?? 0)), amount: Math.abs(Number(row.amount ?? 0)),
dueDate: row.dueDate, dueDate: row.dueDate,
})); }));
const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map( const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map(
(row) => ({ (row) => ({
id: row.id, id: row.id,
name: row.name, name: row.name,
paymentMethod: row.paymentMethod, paymentMethod: row.paymentMethod,
condition: row.condition, condition: row.condition,
transactionType: row.transactionType, transactionType: row.transactionType,
purchaseDate: row.purchaseDate, purchaseDate: row.purchaseDate,
amount: Number(row.amount ?? 0), amount: Number(row.amount ?? 0),
}) }),
); );
const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => { const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => {
const installmentAmount = Math.abs(Number(row.amount ?? 0)); const installmentAmount = Math.abs(Number(row.amount ?? 0));
const installmentCount = row.installmentCount ?? 1; const installmentCount = row.installmentCount ?? 1;
const totalAmount = installmentAmount * installmentCount; const totalAmount = installmentAmount * installmentCount;
return { return {
name: row.name ?? "Sem descrição", name: row.name ?? "Sem descrição",
installmentAmount, installmentAmount,
installmentCount, installmentCount,
currentInstallment: row.currentInstallment ?? 1, currentInstallment: row.currentInstallment ?? 1,
totalAmount, totalAmount,
purchaseDate: row.purchaseDate, purchaseDate: row.purchaseDate,
}; };
}); });
const html = buildSummaryHtml({ const html = buildSummaryHtml({
pagadorName: pagadorRow.name, pagadorName: pagadorRow.name,
periodLabel: displayPeriod(period), periodLabel: displayPeriod(period),
monthlyBreakdown, monthlyBreakdown,
historyData, historyData,
cardUsage, cardUsage,
boletoStats, boletoStats,
boletos: normalizedBoletos, boletos: normalizedBoletos,
lancamentos: normalizedLancamentos, lancamentos: normalizedLancamentos,
parcelados: normalizedParcelados, parcelados: normalizedParcelados,
}); });
await resend.emails.send({ await resend.emails.send({
from: resendFrom, from: resendFrom,
to: pagadorRow.email, to: pagadorRow.email,
subject: `Resumo Financeiro | ${displayPeriod(period)}`, subject: `Resumo Financeiro | ${displayPeriod(period)}`,
html, html,
}); });
const now = new Date(); const now = new Date();
await db await db
.update(pagadores) .update(pagadores)
.set({ lastMailAt: now }) .set({ lastMailAt: now })
.where( .where(
and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.id)) 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." }; return { success: true, message: "Resumo enviado com sucesso." };
} catch (error) { } catch (error) {
// Log estruturado em desenvolvimento // Log estruturado em desenvolvimento
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
console.error("[sendPagadorSummaryAction]", error); console.error("[sendPagadorSummaryAction]", error);
} }
// Tratar erros de validação separadamente // Tratar erros de validação separadamente
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return { return {
success: false, success: false,
error: error.issues[0]?.message ?? "Dados inválidos.", error: error.issues[0]?.message ?? "Dados inválidos.",
}; };
} }
// Não expor detalhes do erro para o usuário // Não expor detalhes do erro para o usuário
return { return {
success: false, success: false,
error: "Não foi possível enviar o resumo. Tente novamente mais tarde.", 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 { 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 = { export type ShareData = {
id: string; id: string;
userId: string; userId: string;
name: string; name: string;
email: string; email: string;
createdAt: string; createdAt: string;
}; };
export async function fetchPagadorShares( export async function fetchPagadorShares(
pagadorId: string pagadorId: string,
): Promise<ShareData[]> { ): Promise<ShareData[]> {
const shareRows = await db const shareRows = await db
.select({ .select({
id: pagadorShares.id, id: pagadorShares.id,
sharedWithUserId: pagadorShares.sharedWithUserId, sharedWithUserId: pagadorShares.sharedWithUserId,
createdAt: pagadorShares.createdAt, createdAt: pagadorShares.createdAt,
userName: usersTable.name, userName: usersTable.name,
userEmail: usersTable.email, userEmail: usersTable.email,
}) })
.from(pagadorShares) .from(pagadorShares)
.innerJoin( .innerJoin(usersTable, eq(pagadorShares.sharedWithUserId, usersTable.id))
usersTable, .where(eq(pagadorShares.pagadorId, pagadorId));
eq(pagadorShares.sharedWithUserId, usersTable.id)
)
.where(eq(pagadorShares.pagadorId, pagadorId));
return shareRows.map((share) => ({ return shareRows.map((share) => ({
id: share.id, id: share.id,
userId: share.sharedWithUserId, userId: share.sharedWithUserId,
name: share.userName ?? "Usuário", name: share.userName ?? "Usuário",
email: share.userEmail ?? "email não informado", email: share.userEmail ?? "email não informado",
createdAt: share.createdAt?.toISOString() ?? new Date().toISOString(), createdAt: share.createdAt?.toISOString() ?? new Date().toISOString(),
})); }));
} }
export async function fetchCurrentUserShare( export async function fetchCurrentUserShare(
pagadorId: string, pagadorId: string,
userId: string userId: string,
): Promise<{ id: string; createdAt: string } | null> { ): Promise<{ id: string; createdAt: string } | null> {
const shareRow = await db.query.pagadorShares.findFirst({ const shareRow = await db.query.pagadorShares.findFirst({
columns: { columns: {
id: true, id: true,
createdAt: true, createdAt: true,
}, },
where: and( where: and(
eq(pagadorShares.pagadorId, pagadorId), eq(pagadorShares.pagadorId, pagadorId),
eq(pagadorShares.sharedWithUserId, userId) eq(pagadorShares.sharedWithUserId, userId),
), ),
}); });
if (!shareRow) { if (!shareRow) {
return null; return null;
} }
return { return {
id: shareRow.id, id: shareRow.id,
createdAt: shareRow.createdAt?.toISOString() ?? new Date().toISOString(), createdAt: shareRow.createdAt?.toISOString() ?? new Date().toISOString(),
}; };
} }
export async function fetchPagadorLancamentos(filters: SQL[]) { export async function fetchPagadorLancamentos(filters: SQL[]) {
const lancamentoRows = await db const lancamentoRows = await db
.select({ .select({
lancamento: lancamentos, lancamento: lancamentos,
pagador: pagadores, pagador: pagadores,
conta: contas, conta: contas,
cartao: cartoes, cartao: cartoes,
categoria: categorias, categoria: categorias,
}) })
.from(lancamentos) .from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id)) .leftJoin(contas, eq(lancamentos.contaId, contas.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id)) .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...filters)) .where(and(...filters))
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)); .orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
// Transformar resultado para o formato esperado // Transformar resultado para o formato esperado
return lancamentoRows.map((row: any) => ({ return lancamentoRows.map((row: any) => ({
...row.lancamento, ...row.lancamento,
pagador: row.pagador, pagador: row.pagador,
conta: row.conta, conta: row.conta,
cartao: row.cartao, cartao: row.cartao,
categoria: row.categoria, 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) * Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos)
*/ */
export default function PagadorDetailsLoading() { export default function PagadorDetailsLoading() {
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
{/* Month Picker placeholder */} {/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" /> <div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Info do Pagador (sempre visível) */} {/* Info do Pagador (sempre visível) */}
<div className="rounded-2xl border p-6 space-y-4"> <div className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{/* Avatar */} {/* Avatar */}
<Skeleton className="size-20 rounded-full bg-foreground/10" /> <Skeleton className="size-20 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-3"> <div className="flex-1 space-y-3">
{/* Nome + Badge */} {/* Nome + Badge */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" /> <Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" /> <Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
</div> </div>
{/* Email */} {/* Email */}
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" /> <Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
{/* Status */} {/* Status */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" /> <Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" /> <Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div> </div>
</div> </div>
{/* Botões de ação */} {/* Botões de ação */}
<div className="flex gap-2"> <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" />
<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>
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="space-y-6"> <div className="space-y-6">
<div className="flex gap-2 border-b"> <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" />
<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> </div>
{/* Conteúdo da aba Visão Geral (grid de cards) */} {/* 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"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Card de resumo mensal */} {/* Card de resumo mensal */}
<div className="rounded-2xl border p-6 space-y-4 lg:col-span-2"> <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" /> <Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
<div className="grid grid-cols-3 gap-4 pt-4"> <div className="grid grid-cols-3 gap-4 pt-4">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2"> <div key={i} className="space-y-2">
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" /> <Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
<Skeleton className="h-7 w-full rounded-2xl bg-foreground/10" /> <Skeleton className="h-7 w-full rounded-2xl bg-foreground/10" />
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* Outros cards */} {/* Outros cards */}
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4"> <div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-2xl bg-foreground/10" /> <Skeleton className="size-5 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" /> <Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
</div> </div>
<div className="space-y-3 pt-4"> <div className="space-y-3 pt-4">
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" /> <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-3/4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-1/2 rounded-2xl bg-foreground/10" /> <Skeleton className="h-5 w-1/2 rounded-2xl bg-foreground/10" />
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</main> </main>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -5,53 +5,53 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: Header + Input de compartilhamento + Grid de cards * Layout: Header + Input de compartilhamento + Grid de cards
*/ */
export default function PagadoresLoading() { export default function PagadoresLoading() {
return ( return (
<main className="flex flex-col items-start gap-6"> <main className="flex flex-col items-start gap-6">
<div className="w-full space-y-6"> <div className="w-full space-y-6">
{/* Input de código de compartilhamento */} {/* Input de código de compartilhamento */}
<div className="rounded-2xl border p-4 space-y-3"> <div className="rounded-2xl border p-4 space-y-3">
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" /> <Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
<div className="flex gap-2"> <div className="flex gap-2">
<Skeleton className="h-10 flex-1 rounded-2xl bg-foreground/10" /> <Skeleton className="h-10 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-32 rounded-2xl bg-foreground/10" /> <Skeleton className="h-10 w-32 rounded-2xl bg-foreground/10" />
</div> </div>
</div> </div>
{/* Grid de cards de pagadores */} {/* Grid de cards de pagadores */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4"> <div key={i} className="rounded-2xl border p-6 space-y-4">
{/* Avatar + Nome + Badge */} {/* Avatar + Nome + Badge */}
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<Skeleton className="size-16 rounded-full bg-foreground/10" /> <Skeleton className="size-16 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" /> <Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" /> <Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" />
</div> </div>
{i === 0 && ( {i === 0 && (
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" /> <Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
)} )}
</div> </div>
{/* Email */} {/* Email */}
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" /> <Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
{/* Status */} {/* Status */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" /> <Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" /> <Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div> </div>
{/* Botões de ação */} {/* Botões de ação */}
<div className="flex gap-2 pt-2 border-t"> <div className="flex gap-2 pt-2 border-t">
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" /> <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" />
<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>
))} ))}
</div> </div>
</div> </div>
</main> </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 { readdir } from "node:fs/promises";
import path from "node:path"; 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_DIRECTORY = path.join(process.cwd(), "public", "avatares");
const AVATAR_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]); const AVATAR_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
async function loadAvatarOptions() { async function loadAvatarOptions() {
try { try {
const files = await readdir(AVATAR_DIRECTORY, { withFileTypes: true }); const files = await readdir(AVATAR_DIRECTORY, { withFileTypes: true });
const items = files const items = files
.filter((file) => file.isFile()) .filter((file) => file.isFile())
.map((file) => file.name) .map((file) => file.name)
.filter((file) => AVATAR_EXTENSIONS.has(path.extname(file).toLowerCase())) .filter((file) => AVATAR_EXTENSIONS.has(path.extname(file).toLowerCase()))
.sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })); .sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
if (items.length === 0) { if (items.length === 0) {
items.push(DEFAULT_PAGADOR_AVATAR); items.push(DEFAULT_PAGADOR_AVATAR);
} }
return Array.from(new Set(items)); return Array.from(new Set(items));
} catch { } catch {
return [DEFAULT_PAGADOR_AVATAR]; return [DEFAULT_PAGADOR_AVATAR];
} }
} }
const resolveStatus = (status: string | null): PagadorStatus => { const resolveStatus = (status: string | null): PagadorStatus => {
const normalized = status?.trim() ?? ""; const normalized = status?.trim() ?? "";
const found = PAGADOR_STATUS_OPTIONS.find( const found = PAGADOR_STATUS_OPTIONS.find(
(option) => option.toLowerCase() === normalized.toLowerCase() (option) => option.toLowerCase() === normalized.toLowerCase(),
); );
return found ?? PAGADOR_STATUS_OPTIONS[0]; return found ?? PAGADOR_STATUS_OPTIONS[0];
}; };
export default async function Page() { export default async function Page() {
const userId = await getUserId(); const userId = await getUserId();
const [pagadorRows, avatarOptions] = await Promise.all([ const [pagadorRows, avatarOptions] = await Promise.all([
fetchPagadoresWithAccess(userId), fetchPagadoresWithAccess(userId),
loadAvatarOptions(), loadAvatarOptions(),
]); ]);
const pagadoresData = pagadorRows const pagadoresData = pagadorRows
.map((pagador) => ({ .map((pagador) => ({
id: pagador.id, id: pagador.id,
name: pagador.name, name: pagador.name,
email: pagador.email, email: pagador.email,
avatarUrl: pagador.avatarUrl, avatarUrl: pagador.avatarUrl,
status: resolveStatus(pagador.status), status: resolveStatus(pagador.status),
note: pagador.note, note: pagador.note,
role: pagador.role, role: pagador.role,
isAutoSend: pagador.isAutoSend ?? false, isAutoSend: pagador.isAutoSend ?? false,
createdAt: pagador.createdAt?.toISOString() ?? new Date().toISOString(), createdAt: pagador.createdAt?.toISOString() ?? new Date().toISOString(),
canEdit: pagador.canEdit, canEdit: pagador.canEdit,
sharedByName: pagador.sharedByName ?? null, sharedByName: pagador.sharedByName ?? null,
sharedByEmail: pagador.sharedByEmail ?? null, sharedByEmail: pagador.sharedByEmail ?? null,
shareId: pagador.shareId ?? null, shareId: pagador.shareId ?? null,
shareCode: pagador.canEdit ? pagador.shareCode ?? null : null, shareCode: pagador.canEdit ? (pagador.shareCode ?? null) : null,
})) }))
.sort((a, b) => { .sort((a, b) => {
// Admin sempre primeiro // Admin sempre primeiro
if (a.role === PAGADOR_ROLE_ADMIN && b.role !== PAGADOR_ROLE_ADMIN) { if (a.role === PAGADOR_ROLE_ADMIN && b.role !== PAGADOR_ROLE_ADMIN) {
return -1; return -1;
} }
if (a.role !== PAGADOR_ROLE_ADMIN && b.role === PAGADOR_ROLE_ADMIN) { if (a.role !== PAGADOR_ROLE_ADMIN && b.role === PAGADOR_ROLE_ADMIN) {
return 1; return 1;
} }
// Se ambos são admin ou ambos não são, mantém ordem original // Se ambos são admin ou ambos não são, mantém ordem original
return 0; return 0;
}); });
return ( return (
<main className="flex flex-col items-start gap-6"> <main className="flex flex-col items-start gap-6">
<PagadoresPage pagadores={pagadoresData} avatarOptions={avatarOptions} /> <PagadoresPage pagadores={pagadoresData} avatarOptions={avatarOptions} />
</main> </main>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { RiBankCard2Line } from "@remixicon/react";
import MonthNavigation from "@/components/month-picker/month-navigation"; import MonthNavigation from "@/components/month-picker/month-navigation";
import { CardCategoryBreakdown } from "@/components/relatorios/cartoes/card-category-breakdown"; import { CardCategoryBreakdown } from "@/components/relatorios/cartoes/card-category-breakdown";
import { CardInvoiceStatus } from "@/components/relatorios/cartoes/card-invoice-status"; 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 { getUser } from "@/lib/auth/server";
import { fetchCartoesReportData } from "@/lib/relatorios/cartoes-report"; import { fetchCartoesReportData } from "@/lib/relatorios/cartoes-report";
import { parsePeriodParam } from "@/lib/utils/period"; import { parsePeriodParam } from "@/lib/utils/period";
import { RiBankCard2Line } from "@remixicon/react";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>; type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = { type PageProps = {
searchParams?: PageSearchParams; searchParams?: PageSearchParams;
}; };
const getSingleParam = ( const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined, params: Record<string, string | string[] | undefined> | undefined,
key: string, key: string,
) => { ) => {
const value = params?.[key]; const value = params?.[key];
if (!value) return null; if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value; return Array.isArray(value) ? (value[0] ?? null) : value;
}; };
export default async function RelatorioCartoesPage({ export default async function RelatorioCartoesPage({
searchParams, searchParams,
}: PageProps) { }: PageProps) {
const user = await getUser(); const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const cartaoParam = getSingleParam(resolvedSearchParams, "cartao"); const cartaoParam = getSingleParam(resolvedSearchParams, "cartao");
const { period: selectedPeriod } = parsePeriodParam(periodoParam); const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const data = await fetchCartoesReportData( const data = await fetchCartoesReportData(
user.id, user.id,
selectedPeriod, selectedPeriod,
cartaoParam, cartaoParam,
); );
return ( return (
<main className="flex flex-col gap-4"> <main className="flex flex-col gap-4">
<MonthNavigation /> <MonthNavigation />
<div className="grid gap-4 lg:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<CardsOverview data={data} /> <CardsOverview data={data} />
</div> </div>
<div className="lg:col-span-2 space-y-4"> <div className="lg:col-span-2 space-y-4">
{data.selectedCard ? ( {data.selectedCard ? (
<> <>
<CardUsageChart <CardUsageChart
data={data.selectedCard.monthlyUsage} data={data.selectedCard.monthlyUsage}
limit={data.selectedCard.card.limit} limit={data.selectedCard.card.limit}
card={{ card={{
name: data.selectedCard.card.name, name: data.selectedCard.card.name,
logo: data.selectedCard.card.logo, logo: data.selectedCard.card.logo,
}} }}
/> />
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<CardCategoryBreakdown <CardCategoryBreakdown
data={data.selectedCard.categoryBreakdown} data={data.selectedCard.categoryBreakdown}
/> />
<CardTopExpenses data={data.selectedCard.topExpenses} /> <CardTopExpenses data={data.selectedCard.topExpenses} />
</div> </div>
<CardInvoiceStatus data={data.selectedCard.invoiceStatus} /> <CardInvoiceStatus data={data.selectedCard.invoiceStatus} />
</> </>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<RiBankCard2Line className="size-12 mb-4" /> <RiBankCard2Line className="size-12 mb-4" />
<p className="text-lg font-medium">Nenhum cartão selecionado</p> <p className="text-lg font-medium">Nenhum cartão selecionado</p>
<p className="text-sm"> <p className="text-sm">
Selecione um cartão na lista ao lado para ver detalhes. Selecione um cartão na lista ao lado para ver detalhes.
</p> </p>
</div> </div>
)} )}
</div> </div>
</div> </div>
</main> </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 { RiFileChartLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = { export const metadata = {
title: "Relatórios | Opensheets", title: "Relatórios | Opensheets",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 px-6">
<PageDescription <PageDescription
icon={<RiFileChartLine />} icon={<RiFileChartLine />}
title="Relatórios de Categorias" title="Relatórios de Categorias"
subtitle="Acompanhe a evolução dos seus gastos e receitas por categoria ao longo do tempo." subtitle="Acompanhe a evolução dos seus gastos e receitas por categoria ao longo do tempo."
/> />
{children} {children}
</section> </section>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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