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:
33
.vscode/settings.json
vendored
33
.vscode/settings.json
vendored
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
70
app/(dashboard)/ajustes/data.ts
Normal file
70
app/(dashboard)/ajustes/data.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/(dashboard)/dashboard/data.ts
Normal file
25
app/(dashboard)/dashboard/data.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """)
|
.replace(/"/g, """)
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
};
|
};
|
||||||
|
|
||||||
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.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/(dashboard)/relatorios/categorias/data.ts
Normal file
12
app/(dashboard)/relatorios/categorias/data.ts
Normal 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)],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
}
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user