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

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

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

View File

@@ -13,5 +13,8 @@
".next": true ".next": true
}, },
"explorerExclude.backup": {}, "explorerExclude.backup": {},
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
} }

View File

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

View File

@@ -1,14 +1,14 @@
"use server"; "use server";
import { apiTokens, pagadores } from "@/db/schema";
import { auth } from "@/lib/auth/config"; import { auth } from "@/lib/auth/config";
import { db, schema } from "@/lib/db"; import { db, schema } from "@/lib/db";
import { apiTokens, pagadores } from "@/db/schema";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { eq, and, ne, isNull } from "drizzle-orm"; import { and, eq, isNull, ne } from "drizzle-orm";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { createHash, randomBytes } from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import { createHash, randomBytes } from "crypto";
type ActionResponse<T = void> = { type ActionResponse<T = void> = {
success: boolean; success: boolean;
@@ -58,7 +58,7 @@ const updatePreferencesSchema = z.object({
// Actions // Actions
export async function updateNameAction( export async function updateNameAction(
data: z.infer<typeof updateNameSchema> data: z.infer<typeof updateNameSchema>,
): Promise<ActionResponse> { ): Promise<ActionResponse> {
try { try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -88,8 +88,8 @@ export async function updateNameAction(
.where( .where(
and( and(
eq(pagadores.userId, session.user.id), eq(pagadores.userId, session.user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN) eq(pagadores.role, PAGADOR_ROLE_ADMIN),
) ),
); );
// Revalidar o layout do dashboard para atualizar a sidebar // Revalidar o layout do dashboard para atualizar a sidebar
@@ -117,7 +117,7 @@ export async function updateNameAction(
} }
export async function updatePasswordAction( export async function updatePasswordAction(
data: z.infer<typeof updatePasswordSchema> data: z.infer<typeof updatePasswordSchema>,
): Promise<ActionResponse> { ): Promise<ActionResponse> {
try { try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -137,14 +137,15 @@ export async function updatePasswordAction(
const userAccount = await db.query.account.findFirst({ const userAccount = await db.query.account.findFirst({
where: and( where: and(
eq(schema.account.userId, session.user.id), eq(schema.account.userId, session.user.id),
eq(schema.account.providerId, "google") eq(schema.account.providerId, "google"),
), ),
}); });
if (userAccount) { if (userAccount) {
return { return {
success: false, success: false,
error: "Não é possível alterar senha para contas autenticadas via Google", error:
"Não é possível alterar senha para contas autenticadas via Google",
}; };
} }
@@ -166,7 +167,10 @@ export async function updatePasswordAction(
console.error("Erro na API do Better Auth:", authError); console.error("Erro na API do Better Auth:", authError);
// Verificar se o erro é de senha incorreta // Verificar se o erro é de senha incorreta
if (authError?.message?.includes("password") || authError?.message?.includes("incorrect")) { if (
authError?.message?.includes("password") ||
authError?.message?.includes("incorrect")
) {
return { return {
success: false, success: false,
error: "Senha atual incorreta", error: "Senha atual incorreta",
@@ -175,7 +179,8 @@ export async function updatePasswordAction(
return { return {
success: false, success: false,
error: "Erro ao atualizar senha. Verifique se a senha atual está correta.", error:
"Erro ao atualizar senha. Verifique se a senha atual está correta.",
}; };
} }
} catch (error) { } catch (error) {
@@ -195,7 +200,7 @@ export async function updatePasswordAction(
} }
export async function updateEmailAction( export async function updateEmailAction(
data: z.infer<typeof updateEmailSchema> data: z.infer<typeof updateEmailSchema>,
): Promise<ActionResponse> { ): Promise<ActionResponse> {
try { try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -215,7 +220,7 @@ export async function updateEmailAction(
const userAccount = await db.query.account.findFirst({ const userAccount = await db.query.account.findFirst({
where: and( where: and(
eq(schema.account.userId, session.user.id), eq(schema.account.userId, session.user.id),
eq(schema.account.providerId, "google") eq(schema.account.providerId, "google"),
), ),
}); });
@@ -254,7 +259,7 @@ export async function updateEmailAction(
const existingUser = await db.query.user.findFirst({ const existingUser = await db.query.user.findFirst({
where: and( where: and(
eq(schema.user.email, validated.newEmail), eq(schema.user.email, validated.newEmail),
ne(schema.user.id, session.user.id) ne(schema.user.id, session.user.id),
), ),
}); });
@@ -307,7 +312,7 @@ export async function updateEmailAction(
} }
export async function deleteAccountAction( export async function deleteAccountAction(
data: z.infer<typeof deleteAccountSchema> data: z.infer<typeof deleteAccountSchema>,
): Promise<ActionResponse> { ): Promise<ActionResponse> {
try { try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -349,7 +354,7 @@ export async function deleteAccountAction(
} }
export async function updatePreferencesAction( export async function updatePreferencesAction(
data: z.infer<typeof updatePreferencesSchema> data: z.infer<typeof updatePreferencesSchema>,
): Promise<ActionResponse> { ): Promise<ActionResponse> {
try { try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -435,7 +440,7 @@ function hashToken(token: string): string {
} }
export async function createApiTokenAction( export async function createApiTokenAction(
data: z.infer<typeof createApiTokenSchema> data: z.infer<typeof createApiTokenSchema>,
): Promise<ActionResponse<{ token: string; tokenId: string }>> { ): Promise<ActionResponse<{ token: string; tokenId: string }>> {
try { try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -495,7 +500,7 @@ export async function createApiTokenAction(
} }
export async function revokeApiTokenAction( export async function revokeApiTokenAction(
data: z.infer<typeof revokeApiTokenSchema> data: z.infer<typeof revokeApiTokenSchema>,
): Promise<ActionResponse> { ): Promise<ActionResponse> {
try { try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -519,8 +524,8 @@ export async function revokeApiTokenAction(
and( and(
eq(apiTokens.id, validated.tokenId), eq(apiTokens.id, validated.tokenId),
eq(apiTokens.userId, session.user.id), eq(apiTokens.userId, session.user.id),
isNull(apiTokens.revokedAt) isNull(apiTokens.revokedAt),
) ),
) )
.limit(1); .limit(1);

View File

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

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -1,17 +1,17 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { ApiTokensForm } from "@/components/ajustes/api-tokens-form"; import { ApiTokensForm } from "@/components/ajustes/api-tokens-form";
import { DeleteAccountForm } from "@/components/ajustes/delete-account-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 { UpdateEmailForm } from "@/components/ajustes/update-email-form";
import { UpdateNameForm } from "@/components/ajustes/update-name-form"; import { UpdateNameForm } from "@/components/ajustes/update-name-form";
import { UpdatePasswordForm } from "@/components/ajustes/update-password-form"; import { UpdatePasswordForm } from "@/components/ajustes/update-password-form";
import { PreferencesForm } from "@/components/ajustes/preferences-form";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { auth } from "@/lib/auth/config"; import { auth } from "@/lib/auth/config";
import { db, schema } from "@/lib/db";
import { apiTokens } from "@/db/schema"; import { fetchAjustesPageData } from "./data";
import { eq, desc } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function Page() { export default async function Page() {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -25,40 +25,8 @@ export default async function Page() {
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
const userPreferencesResult = await db
.select({
disableMagnetlines: schema.userPreferences.disableMagnetlines,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, session.user.id))
.limit(1);
const userPreferences = userPreferencesResult[0] || null;
// Se o providerId for "google", o usuário usa Google OAuth
const authProvider = userAccount?.providerId || "credential";
// Buscar tokens de API do usuário
const userApiTokens = await db
.select({
id: apiTokens.id,
name: apiTokens.name,
tokenPrefix: apiTokens.tokenPrefix,
lastUsedAt: apiTokens.lastUsedAt,
lastUsedIp: apiTokens.lastUsedIp,
createdAt: apiTokens.createdAt,
expiresAt: apiTokens.expiresAt,
revokedAt: apiTokens.revokedAt,
})
.from(apiTokens)
.where(eq(apiTokens.userId, session.user.id))
.orderBy(desc(apiTokens.createdAt));
return ( return (
<div className="w-full"> <div className="w-full">

View File

@@ -1,13 +1,13 @@
"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(),
@@ -15,7 +15,8 @@ const taskSchema = z.object({
completed: z.boolean(), completed: z.boolean(),
}); });
const noteBaseSchema = z.object({ const noteBaseSchema = z
.object({
title: z title: z
.string({ message: "Informe o título da anotação." }) .string({ message: "Informe o título da anotação." })
.trim() .trim()
@@ -31,7 +32,8 @@ const noteBaseSchema = z.object({
message: "O tipo deve ser 'nota' ou 'tarefa'.", message: "O tipo deve ser 'nota' ou 'tarefa'.",
}), }),
tasks: z.array(taskSchema).optional().default([]), tasks: z.array(taskSchema).optional().default([]),
}).refine( })
.refine(
(data) => { (data) => {
// Se for nota, a descrição é obrigatória // Se for nota, a descrição é obrigatória
if (data.type === "nota") { if (data.type === "nota") {
@@ -44,14 +46,17 @@ const noteBaseSchema = z.object({
return true; 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(
z.object({
id: uuidSchema("Anotação"), id: uuidSchema("Anotação"),
})); }),
);
const deleteNoteSchema = z.object({ const deleteNoteSchema = z.object({
id: uuidSchema("Anotação"), id: uuidSchema("Anotação"),
}); });
@@ -61,7 +66,7 @@ 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();
@@ -71,7 +76,8 @@ export async function createNoteAction(
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 ? JSON.stringify(data.tasks) : null,
userId: user.id, userId: user.id,
}); });
@@ -84,7 +90,7 @@ export async function createNoteAction(
} }
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();
@@ -96,7 +102,10 @@ export async function updateNoteAction(
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
? JSON.stringify(data.tasks)
: null,
}) })
.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 });
@@ -117,7 +126,7 @@ export async function updateNoteAction(
} }
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();
@@ -151,7 +160,7 @@ const arquivarNoteSchema = z.object({
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();
@@ -178,7 +187,7 @@ export async function arquivarAnotacaoAction(
success: true, success: true,
message: data.arquivada message: data.arquivada
? "Anotação arquivada com sucesso." ? "Anotação arquivada com sucesso."
: "Anotação desarquivada com sucesso." : "Anotação desarquivada com sucesso.",
}; };
} catch (error) { } catch (error) {
return handleActionError(error); return handleActionError(error);

View File

@@ -1,6 +1,6 @@
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;
@@ -21,7 +21,10 @@ export type NoteData = {
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) => {
@@ -49,10 +52,15 @@ export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
}); });
} }
export async function fetchArquivadasForUser(userId: string): Promise<NoteData[]> { export async function fetchArquivadasForUser(
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, true)), where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, true)),
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) => {

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -17,10 +17,7 @@ export default function AnotacoesLoading() {
{/* 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}
className="rounded-2xl border p-4 space-y-3"
>
{/* Título */} {/* Título */}
<Skeleton className="h-6 w-3/4 rounded-2xl bg-foreground/10" /> <Skeleton className="h-6 w-3/4 rounded-2xl bg-foreground/10" />

View File

@@ -1,4 +1,9 @@
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 {
@@ -8,12 +13,6 @@ import {
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";
@@ -59,8 +58,7 @@ export const fetchCalendarData = async ({
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),
@@ -69,20 +67,20 @@ export const fetchCalendarData = async ({
// 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,
@@ -112,7 +110,7 @@ export const fetchCalendarData = async ({
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,
); );
} }
@@ -164,7 +162,7 @@ export const fetchCalendarData = async ({
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({

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -1,3 +1,5 @@
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 {
@@ -5,10 +7,7 @@ import {
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>;

View File

@@ -1,5 +1,8 @@
"use server"; "use server";
import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { import {
cartoes, cartoes,
categorias, categorias,
@@ -8,29 +11,24 @@ import {
pagadores, pagadores,
} from "@/db/schema"; } from "@/db/schema";
import { buildInvoicePaymentNote } from "@/lib/accounts/constants"; import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { import {
INVOICE_PAYMENT_STATUS, INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES, INVOICE_STATUS_VALUES,
PERIOD_FORMAT_REGEX,
type InvoicePaymentStatus, type InvoicePaymentStatus,
PERIOD_FORMAT_REGEX,
} from "@/lib/faturas"; } from "@/lib/faturas";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { parseLocalDateString } from "@/lib/utils/date"; import { parseLocalDateString } from "@/lib/utils/date";
import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
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." })
.uuid("Cartão inválido."),
period: z period: z
.string({ message: "Período inválido." }) .string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."), .regex(PERIOD_FORMAT_REGEX, "Período inválido."),
status: z.enum( status: z.enum(
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]] INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
), ),
paymentDate: z.string().optional(), paymentDate: z.string().optional(),
}); });
@@ -52,7 +50,7 @@ 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();
@@ -75,7 +73,7 @@ export async function updateInvoicePaymentStatusAction(
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),
), ),
}); });
@@ -104,8 +102,8 @@ export async function updateInvoicePaymentStatusAction(
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);
@@ -132,8 +130,8 @@ export async function updateInvoicePaymentStatusAction(
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));
@@ -143,7 +141,7 @@ export async function updateInvoicePaymentStatusAction(
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),
), ),
}); });
@@ -151,7 +149,7 @@ export async function updateInvoicePaymentStatusAction(
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"),
), ),
}); });
@@ -182,7 +180,7 @@ export async function updateInvoicePaymentStatusAction(
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),
), ),
}); });
@@ -202,8 +200,8 @@ export async function updateInvoicePaymentStatusAction(
.where( .where(
and( and(
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote) eq(lancamentos.note, invoiceNote),
) ),
); );
} }
}); });
@@ -229,9 +227,7 @@ export async function updateInvoicePaymentStatusAction(
} }
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." })
.uuid("Cartão inválido."),
period: z period: z
.string({ message: "Período inválido." }) .string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."), .regex(PERIOD_FORMAT_REGEX, "Período inválido."),
@@ -241,7 +237,7 @@ const updatePaymentDateSchema = z.object({
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();
@@ -263,7 +259,7 @@ export async function updatePaymentDateAction(
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),
), ),
}); });

View File

@@ -1,3 +1,4 @@
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";
@@ -5,7 +6,6 @@ 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") {
@@ -41,7 +41,7 @@ export async function fetchCardData(userId: string, cartaoId: string) {
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;
@@ -57,7 +57,7 @@ export async function fetchInvoiceData(
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
@@ -67,14 +67,14 @@ export async function fetchInvoiceData(
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);
@@ -92,7 +92,7 @@ export async function fetchInvoiceData(
}, },
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
@@ -102,3 +102,16 @@ export async function fetchInvoiceData(
return { totalAmount, invoiceStatus, paymentDate }; return { totalAmount, invoiceStatus, paymentDate };
} }
export async function fetchCardLancamentos(filters: SQL[]) {
return db.query.lancamentos.findMany({
where: and(...filters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
}

View File

@@ -1,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,8 +7,7 @@ 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,
@@ -21,10 +22,7 @@ import {
} 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>;
@@ -53,12 +51,8 @@ export default async function Page({ params, searchParams }: PageProps) {
notFound(); notFound();
} }
const [ const [filterSources, logoOptions, invoiceData, estabelecimentos] =
filterSources, await Promise.all([
logoOptions,
invoiceData,
estabelecimentos,
] = await Promise.all([
fetchLancamentoFilterSources(userId), fetchLancamentoFilterSources(userId),
loadLogoOptions(), loadLogoOptions(),
fetchInvoiceData(userId, cartaoId, selectedPeriod), fetchInvoiceData(userId, cartaoId, selectedPeriod),
@@ -75,16 +69,7 @@ export default async function Page({ params, searchParams }: PageProps) {
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);
@@ -137,7 +122,7 @@ export default async function Page({ params, searchParams }: PageProps) {
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 (

View File

@@ -1,8 +1,15 @@
"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 {
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 { import {
dayOfMonthSchema, dayOfMonthSchema,
noteSchema, noteSchema,
@@ -11,10 +18,6 @@ import {
} 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
@@ -64,7 +67,7 @@ async function assertAccountOwnership(userId: string, contaId: string) {
} }
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();
@@ -96,7 +99,7 @@ export async function createCardAction(
} }
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();
@@ -138,7 +141,7 @@ export async function updateCardAction(
} }
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();

View File

@@ -32,8 +32,14 @@ export async function fetchCardsForUser(userId: string): Promise<{
}> { }> {
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,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(card.name)],
where: and(
eq(cartoes.userId, userId),
not(ilike(cartoes.status, "inativo")),
),
with: { with: {
conta: { conta: {
columns: { columns: {
@@ -44,7 +50,10 @@ export async function fetchCardsForUser(userId: string): Promise<{
}, },
}), }),
db.query.contas.findMany({ db.query.contas.findMany({
orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)], orderBy: (
account: typeof contas.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(account.name)],
where: eq(contas.userId, userId), where: eq(contas.userId, userId),
columns: { columns: {
id: true, id: true,
@@ -62,17 +71,19 @@ export async function fetchCardsForUser(userId: string): Promise<{
.where( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)) or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
) ),
) )
.groupBy(lancamentos.cartaoId), .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(
(row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return; if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0)); usageMap.set(row.cartaoId, Number(row.total ?? 0));
}); },
);
const cards = cardRows.map((card) => ({ const cards = cardRows.map((card) => ({
id: card.id, id: card.id,
@@ -116,7 +127,10 @@ export async function fetchInativosForUser(userId: string): Promise<{
}> { }> {
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: (
card: typeof cartoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(card.name)],
where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")), where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")),
with: { with: {
conta: { conta: {
@@ -128,7 +142,10 @@ export async function fetchInativosForUser(userId: string): Promise<{
}, },
}), }),
db.query.contas.findMany({ db.query.contas.findMany({
orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)], orderBy: (
account: typeof contas.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(account.name)],
where: eq(contas.userId, userId), where: eq(contas.userId, userId),
columns: { columns: {
id: true, id: true,
@@ -146,17 +163,19 @@ export async function fetchInativosForUser(userId: string): Promise<{
.where( .where(
and( and(
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)) or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
) ),
) )
.groupBy(lancamentos.cartaoId), .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(
(row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return; if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0)); usageMap.set(row.cartaoId, Number(row.total ?? 0));
}); },
);
const cards = cardRows.map((card) => ({ const cards = cardRows.map((card) => ({
id: card.id, id: card.id,

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -1,8 +1,5 @@
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">

View File

@@ -1,16 +1,16 @@
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>>;
@@ -21,11 +21,11 @@ type PageProps = {
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) {
@@ -36,8 +36,7 @@ export default async function Page({ params, searchParams }: PageProps) {
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(),

View File

@@ -1,5 +1,7 @@
"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,
@@ -11,8 +13,6 @@ 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
@@ -43,7 +43,7 @@ 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();
@@ -65,7 +65,7 @@ export async function createCategoryAction(
} }
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();
@@ -123,7 +123,7 @@ export async function updateCategoryAction(
} }
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();

View File

@@ -1,7 +1,7 @@
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;
@@ -11,7 +11,7 @@ export type CategoryData = {
}; };
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),

View File

@@ -1,5 +1,5 @@
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 (

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -28,10 +28,7 @@ export default function CategoriasLoading() {
{/* 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}
className="rounded-2xl border p-6 space-y-4"
>
{/* Ícone + Nome */} {/* Ícone + Nome */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-2xl bg-foreground/10" /> <Skeleton className="size-12 rounded-2xl bg-foreground/10" />

View File

@@ -1,8 +1,8 @@
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;
@@ -31,7 +31,7 @@ export async function fetchAccountData(userId: string, contaId: string) {
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({
@@ -79,8 +79,8 @@ export async function fetchAccountSummary(
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
@@ -105,8 +105,8 @@ export async function fetchAccountSummary(
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);
@@ -129,3 +129,23 @@ export async function fetchAccountSummary(
totalExpenses, totalExpenses,
}; };
} }
export async function fetchAccountLancamentos(
filters: SQL[],
settledOnly = true,
) {
const allFilters = settledOnly
? [...filters, eq(lancamentos.isSettled, true)]
: filters;
return db.query.lancamentos.findMany({
where: and(...allFilters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
}

View File

@@ -1,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,8 +7,6 @@ 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,
@@ -21,10 +21,11 @@ import {
} 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>;
@@ -56,12 +57,8 @@ export default async function Page({ params, searchParams }: PageProps) {
notFound(); notFound();
} }
const [ const [filterSources, logoOptions, accountSummary, estabelecimentos] =
filterSources, await Promise.all([
logoOptions,
accountSummary,
estabelecimentos,
] = await Promise.all([
fetchLancamentoFilterSources(userId), fetchLancamentoFilterSources(userId),
loadLogoOptions(), loadLogoOptions(),
fetchAccountSummary(userId, contaId, selectedPeriod), fetchAccountSummary(userId, contaId, selectedPeriod),
@@ -78,18 +75,7 @@ export default async function Page({ params, searchParams }: PageProps) {
accountId: account.id, accountId: account.id,
}); });
filters.push(eq(lancamentos.isSettled, true)); const lancamentoRows = await fetchAccountLancamentos(filters);
const lancamentoRows = await db.query.lancamentos.findMany({
where: and(...filters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
const lancamentosData = mapLancamentosData(lancamentoRows); const lancamentosData = mapLancamentosData(lancamentoRows);

View File

@@ -1,5 +1,7 @@
"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,
@@ -8,23 +10,24 @@ import {
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,
import { noteSchema, uuidSchema } from "@/lib/schemas/common"; handleActionError,
import { formatDecimalForDbRequired } from "@/lib/utils/currency"; revalidateForEntity,
import { getTodayInfo } from "@/lib/utils/date"; } from "@/lib/actions/helpers";
import { normalizeFilePath } from "@/lib/utils/string";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
import { import {
TRANSFER_CATEGORY_NAME, TRANSFER_CATEGORY_NAME,
TRANSFER_CONDITION, TRANSFER_CONDITION,
TRANSFER_ESTABLISHMENT, TRANSFER_ESTABLISHMENT,
TRANSFER_PAYMENT_METHOD, TRANSFER_PAYMENT_METHOD,
} from "@/lib/transferencias/constants"; } from "@/lib/transferencias/constants";
import { and, eq } from "drizzle-orm"; import { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { z } from "zod"; import { getTodayInfo } from "@/lib/utils/date";
import { normalizeFilePath } from "@/lib/utils/string";
const accountBaseSchema = z.object({ const accountBaseSchema = z.object({
name: z name: z
@@ -50,7 +53,7 @@ const accountBaseSchema = z.object({
.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
@@ -74,7 +77,7 @@ 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();
@@ -114,27 +117,27 @@ export async function createAccountAction(
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.",
); );
} }
@@ -169,7 +172,7 @@ export async function createAccountAction(
} }
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();
@@ -211,7 +214,7 @@ export async function updateAccountAction(
} }
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();
@@ -250,7 +253,7 @@ const transferSchema = z.object({
.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."),
@@ -264,7 +267,7 @@ const transferSchema = z.object({
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();
@@ -288,14 +291,14 @@ export async function transferBetweenAccountsAction(
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),
), ),
}), }),
]); ]);
@@ -313,13 +316,13 @@ export async function transferBetweenAccountsAction(
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.`,
); );
} }
@@ -328,13 +331,13 @@ export async function transferBetweenAccountsAction(
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.",
); );
} }

View File

@@ -1,9 +1,9 @@
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;
@@ -19,7 +19,7 @@ export type AccountData = {
}; };
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
@@ -51,16 +51,16 @@ export async function fetchAccountsForUser(
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,
@@ -71,7 +71,7 @@ export async function fetchAccountsForUser(
contas.logo, contas.logo,
contas.initialBalance, contas.initialBalance,
contas.excludeFromBalance, contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome contas.excludeInitialBalanceFromIncome,
), ),
loadLogoOptions(), loadLogoOptions(),
]); ]);
@@ -95,7 +95,7 @@ export async function fetchAccountsForUser(
} }
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
@@ -127,16 +127,16 @@ export async function fetchInativosForUser(
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,
@@ -147,7 +147,7 @@ export async function fetchInativosForUser(
contas.logo, contas.logo,
contas.initialBalance, contas.initialBalance,
contas.excludeFromBalance, contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome contas.excludeInitialBalanceFromIncome,
), ),
loadLogoOptions(), loadLogoOptions(),
]); ]);

View File

@@ -8,7 +8,11 @@ export default async function InativosPage() {
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
accounts={accounts}
logoOptions={logoOptions}
isInativos={true}
/>
</main> </main>
); );
} }

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -4,8 +4,6 @@ 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 ( return (

View File

@@ -1,5 +1,5 @@
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",

View File

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

View File

@@ -4,9 +4,8 @@ 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>>;
@@ -29,20 +28,12 @@ export default async function Page({ searchParams }: PageProps) {
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">

View File

@@ -1,5 +1,12 @@
"use server"; "use server";
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import { openai } from "@ai-sdk/openai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { generateObject } from "ai";
import { getDay } from "date-fns";
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
import { import {
cartoes, cartoes,
categorias, categorias,
@@ -10,21 +17,14 @@ import {
savedInsights, savedInsights,
} from "@/db/schema"; } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { import {
InsightsResponseSchema,
type InsightsResponse, type InsightsResponse,
InsightsResponseSchema,
} from "@/lib/schemas/insights"; } from "@/lib/schemas/insights";
import { getPreviousPeriod } from "@/lib/utils/period"; import { getPreviousPeriod } from "@/lib/utils/period";
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import { openai } from "@ai-sdk/openai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { generateObject } from "ai";
import { getDay } from "date-fns";
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
import { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "./data"; import { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "./data";
const TRANSFERENCIA = "Transferência"; const TRANSFERENCIA = "Transferência";
@@ -54,7 +54,12 @@ async function aggregateMonthData(userId: string, period: string) {
const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo); const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo);
// Buscar métricas de receitas e despesas dos últimos 3 meses // Buscar métricas de receitas e despesas dos últimos 3 meses
const [currentPeriodRows, previousPeriodRows, twoMonthsAgoRows, threeMonthsAgoRows] = await Promise.all([ const [
currentPeriodRows,
previousPeriodRows,
twoMonthsAgoRows,
threeMonthsAgoRows,
] = await Promise.all([
db db
.select({ .select({
transactionType: lancamentos.transactionType, transactionType: lancamentos.transactionType,
@@ -72,9 +77,9 @@ async function aggregateMonthData(userId: string, period: string) {
isNull(lancamentos.note), isNull(lancamentos.note),
sql`${ sql`${
lancamentos.note lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
) ),
) ),
) )
.groupBy(lancamentos.transactionType), .groupBy(lancamentos.transactionType),
db db
@@ -94,9 +99,9 @@ async function aggregateMonthData(userId: string, period: string) {
isNull(lancamentos.note), isNull(lancamentos.note),
sql`${ sql`${
lancamentos.note lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
) ),
) ),
) )
.groupBy(lancamentos.transactionType), .groupBy(lancamentos.transactionType),
db db
@@ -116,9 +121,9 @@ async function aggregateMonthData(userId: string, period: string) {
isNull(lancamentos.note), isNull(lancamentos.note),
sql`${ sql`${
lancamentos.note lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
) ),
) ),
) )
.groupBy(lancamentos.transactionType), .groupBy(lancamentos.transactionType),
db db
@@ -138,9 +143,9 @@ async function aggregateMonthData(userId: string, period: string) {
isNull(lancamentos.note), isNull(lancamentos.note),
sql`${ sql`${
lancamentos.note lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
) ),
) ),
) )
.groupBy(lancamentos.transactionType), .groupBy(lancamentos.transactionType),
]); ]);
@@ -199,9 +204,9 @@ async function aggregateMonthData(userId: string, period: string) {
isNull(lancamentos.note), isNull(lancamentos.note),
sql`${ sql`${
lancamentos.note lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
) ),
) ),
) )
.groupBy(categorias.name) .groupBy(categorias.name)
.orderBy(sql`sum(${lancamentos.amount}) ASC`) .orderBy(sql`sum(${lancamentos.amount}) ASC`)
@@ -222,8 +227,8 @@ async function aggregateMonthData(userId: string, period: string) {
eq(lancamentos.categoriaId, categorias.id), eq(lancamentos.categoriaId, categorias.id),
eq(lancamentos.period, period), eq(lancamentos.period, period),
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.transactionType, "Despesa") eq(lancamentos.transactionType, "Despesa"),
) ),
) )
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))) .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period)))
.groupBy(categorias.name, orcamentos.amount); .groupBy(categorias.name, orcamentos.amount);
@@ -248,8 +253,8 @@ async function aggregateMonthData(userId: string, period: string) {
and( and(
eq(contas.userId, userId), eq(contas.userId, userId),
eq(contas.status, "ativa"), eq(contas.status, "ativa"),
eq(contas.excludeFromBalance, false) eq(contas.excludeFromBalance, false),
) ),
); );
// Calcular ticket médio das transações // Calcular ticket médio das transações
@@ -265,8 +270,8 @@ async function aggregateMonthData(userId: string, period: string) {
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.period, period), eq(lancamentos.period, period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN), eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA) ne(lancamentos.transactionType, TRANSFERENCIA),
) ),
); );
// Buscar gastos por dia da semana // Buscar gastos por dia da semana
@@ -282,8 +287,8 @@ async function aggregateMonthData(userId: string, period: string) {
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.period, period), eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"), eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN) eq(pagadores.role, PAGADOR_ROLE_ADMIN),
) ),
); );
// Agregar por dia da semana // Agregar por dia da semana
@@ -308,8 +313,8 @@ async function aggregateMonthData(userId: string, period: string) {
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.period, period), eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"), eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN) eq(pagadores.role, PAGADOR_ROLE_ADMIN),
) ),
) )
.groupBy(lancamentos.paymentMethod); .groupBy(lancamentos.paymentMethod);
@@ -333,13 +338,16 @@ async function aggregateMonthData(userId: string, period: string) {
sql`${lancamentos.period} IN (${period}, ${previousPeriod}, ${twoMonthsAgo})`, sql`${lancamentos.period} IN (${period}, ${previousPeriod}, ${twoMonthsAgo})`,
eq(lancamentos.transactionType, "Despesa"), eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN), eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA) ne(lancamentos.transactionType, TRANSFERENCIA),
) ),
) )
.orderBy(lancamentos.name); .orderBy(lancamentos.name);
// Análise de recorrência // Análise de recorrência
const transactionsByName = new Map<string, Array<{ period: string; amount: number }>>(); const transactionsByName = new Map<
string,
Array<{ period: string; amount: number }>
>();
for (const tx of last3MonthsTransactions) { for (const tx of last3MonthsTransactions) {
const key = tx.name.toLowerCase().trim(); const key = tx.name.toLowerCase().trim();
@@ -356,13 +364,18 @@ async function aggregateMonthData(userId: string, period: string) {
} }
// Identificar gastos recorrentes (aparece em 2+ meses com valor similar) // Identificar gastos recorrentes (aparece em 2+ meses com valor similar)
const recurringExpenses: Array<{ name: string; avgAmount: number; frequency: number }> = []; const recurringExpenses: Array<{
name: string;
avgAmount: number;
frequency: number;
}> = [];
let totalRecurring = 0; let totalRecurring = 0;
for (const [name, occurrences] of transactionsByName.entries()) { for (const [name, occurrences] of transactionsByName.entries()) {
if (occurrences.length >= 2) { if (occurrences.length >= 2) {
const amounts = occurrences.map(o => o.amount); const amounts = occurrences.map((o) => o.amount);
const avgAmount = amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length; const avgAmount =
amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length;
const maxDiff = Math.max(...amounts) - Math.min(...amounts); const maxDiff = Math.max(...amounts) - Math.min(...amounts);
// Considerar recorrente se variação <= 20% da média // Considerar recorrente se variação <= 20% da média
@@ -374,7 +387,9 @@ async function aggregateMonthData(userId: string, period: string) {
}); });
// Somar apenas os do mês atual // Somar apenas os do mês atual
const currentMonthOccurrence = occurrences.find(o => o.period === period); const currentMonthOccurrence = occurrences.find(
(o) => o.period === period,
);
if (currentMonthOccurrence) { if (currentMonthOccurrence) {
totalRecurring += currentMonthOccurrence.amount; totalRecurring += currentMonthOccurrence.amount;
} }
@@ -384,12 +399,15 @@ async function aggregateMonthData(userId: string, period: string) {
// Análise de gastos parcelados // Análise de gastos parcelados
const installmentTransactions = last3MonthsTransactions.filter( const installmentTransactions = last3MonthsTransactions.filter(
tx => tx.condition === "Parcelado" && tx.installmentCount && tx.installmentCount > 1 (tx) =>
tx.condition === "Parcelado" &&
tx.installmentCount &&
tx.installmentCount > 1,
); );
const installmentData = installmentTransactions const installmentData = installmentTransactions
.filter(tx => tx.period === period) .filter((tx) => tx.period === period)
.map(tx => ({ .map((tx) => ({
name: tx.name, name: tx.name,
currentInstallment: tx.currentInstallment ?? 1, currentInstallment: tx.currentInstallment ?? 1,
totalInstallments: tx.installmentCount ?? 1, totalInstallments: tx.installmentCount ?? 1,
@@ -397,10 +415,13 @@ async function aggregateMonthData(userId: string, period: string) {
category: tx.categoryName ?? "Outros", category: tx.categoryName ?? "Outros",
})); }));
const totalInstallmentAmount = installmentData.reduce((sum, tx) => sum + tx.amount, 0); const totalInstallmentAmount = installmentData.reduce(
(sum, tx) => sum + tx.amount,
0,
);
const futureCommitment = installmentData.reduce((sum, tx) => { const futureCommitment = installmentData.reduce((sum, tx) => {
const remaining = (tx.totalInstallments - tx.currentInstallment); const remaining = tx.totalInstallments - tx.currentInstallment;
return sum + (tx.amount * remaining); return sum + tx.amount * remaining;
}, 0); }, 0);
// Montar dados agregados e anonimizados // Montar dados agregados e anonimizados
@@ -413,13 +434,36 @@ async function aggregateMonthData(userId: string, period: string) {
// Tendência de 3 meses // Tendência de 3 meses
threeMonthTrend: { threeMonthTrend: {
periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period], periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period],
incomes: [threeMonthsAgoIncome, twoMonthsAgoIncome, previousIncome, currentIncome], incomes: [
expenses: [threeMonthsAgoExpense, twoMonthsAgoExpense, previousExpense, currentExpense], threeMonthsAgoIncome,
avgIncome: (threeMonthsAgoIncome + twoMonthsAgoIncome + previousIncome + currentIncome) / 4, twoMonthsAgoIncome,
avgExpense: (threeMonthsAgoExpense + twoMonthsAgoExpense + previousExpense + currentExpense) / 4, previousIncome,
trend: currentExpense > previousExpense && previousExpense > twoMonthsAgoExpense currentIncome,
],
expenses: [
threeMonthsAgoExpense,
twoMonthsAgoExpense,
previousExpense,
currentExpense,
],
avgIncome:
(threeMonthsAgoIncome +
twoMonthsAgoIncome +
previousIncome +
currentIncome) /
4,
avgExpense:
(threeMonthsAgoExpense +
twoMonthsAgoExpense +
previousExpense +
currentExpense) /
4,
trend:
currentExpense > previousExpense &&
previousExpense > twoMonthsAgoExpense
? "crescente" ? "crescente"
: currentExpense < previousExpense && previousExpense < twoMonthsAgoExpense : currentExpense < previousExpense &&
previousExpense < twoMonthsAgoExpense
? "decrescente" ? "decrescente"
: "estável", : "estável",
}, },
@@ -446,7 +490,7 @@ async function aggregateMonthData(userId: string, period: string) {
currentExpense > 0 currentExpense > 0
? (Math.abs(toNumber(cat.total)) / currentExpense) * 100 ? (Math.abs(toNumber(cat.total)) / currentExpense) * 100
: 0, : 0,
}) }),
), ),
budgets: budgetsData.map( budgets: budgetsData.map(
(b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({ (b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({
@@ -457,7 +501,7 @@ async function aggregateMonthData(userId: string, period: string) {
toNumber(b.budgetAmount) > 0 toNumber(b.budgetAmount) > 0
? (Math.abs(toNumber(b.spent)) / toNumber(b.budgetAmount)) * 100 ? (Math.abs(toNumber(b.spent)) / toNumber(b.budgetAmount)) * 100
: 0, : 0,
}) }),
), ),
creditCards: { creditCards: {
totalLimit: toNumber(cardsData[0]?.totalLimit ?? 0), totalLimit: toNumber(cardsData[0]?.totalLimit ?? 0),
@@ -480,35 +524,40 @@ async function aggregateMonthData(userId: string, period: string) {
total: toNumber(pm.total), total: toNumber(pm.total),
percentage: percentage:
currentExpense > 0 ? (toNumber(pm.total) / currentExpense) * 100 : 0, currentExpense > 0 ? (toNumber(pm.total) / currentExpense) * 100 : 0,
}) }),
), ),
// Análise de recorrência // Análise de recorrência
recurringExpenses: { recurringExpenses: {
count: recurringExpenses.length, count: recurringExpenses.length,
total: totalRecurring, total: totalRecurring,
percentageOfTotal: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0, percentageOfTotal:
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
topRecurring: recurringExpenses topRecurring: recurringExpenses
.sort((a, b) => b.avgAmount - a.avgAmount) .sort((a, b) => b.avgAmount - a.avgAmount)
.slice(0, 5) .slice(0, 5)
.map(r => ({ .map((r) => ({
name: r.name, name: r.name,
avgAmount: r.avgAmount, avgAmount: r.avgAmount,
frequency: r.frequency, frequency: r.frequency,
})), })),
predictability: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0, predictability:
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
}, },
// Análise de parcelamentos // Análise de parcelamentos
installments: { installments: {
currentMonthInstallments: installmentData.length, currentMonthInstallments: installmentData.length,
totalInstallmentAmount, totalInstallmentAmount,
percentageOfExpenses: currentExpense > 0 ? (totalInstallmentAmount / currentExpense) * 100 : 0, percentageOfExpenses:
currentExpense > 0
? (totalInstallmentAmount / currentExpense) * 100
: 0,
futureCommitment, futureCommitment,
topInstallments: installmentData topInstallments: installmentData
.sort((a, b) => b.amount - a.amount) .sort((a, b) => b.amount - a.amount)
.slice(0, 5) .slice(0, 5)
.map(i => ({ .map((i) => ({
name: i.name, name: i.name,
current: i.currentInstallment, current: i.currentInstallment,
total: i.totalInstallments, total: i.totalInstallments,
@@ -527,7 +576,7 @@ async function aggregateMonthData(userId: string, period: string) {
*/ */
export async function generateInsightsAction( export async function generateInsightsAction(
period: string, period: string,
modelId: string modelId: string,
): Promise<ActionResult<InsightsResponse>> { ): Promise<ActionResult<InsightsResponse>> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -555,7 +604,8 @@ export async function generateInsightsAction(
if (!apiKey) { if (!apiKey) {
return { return {
success: false, success: false,
error: "OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env", error:
"OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env",
}; };
} }
@@ -639,7 +689,7 @@ Responda APENAS com um JSON válido seguindo exatamente o schema especificado.`,
export async function saveInsightsAction( export async function saveInsightsAction(
period: string, period: string,
modelId: string, modelId: string,
data: InsightsResponse data: InsightsResponse,
): Promise<ActionResult<{ id: string; createdAt: Date }>> { ): Promise<ActionResult<{ id: string; createdAt: Date }>> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -649,7 +699,10 @@ export async function saveInsightsAction(
.select() .select()
.from(savedInsights) .from(savedInsights)
.where( .where(
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period)) and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
) )
.limit(1); .limit(1);
@@ -665,10 +718,13 @@ export async function saveInsightsAction(
.where( .where(
and( and(
eq(savedInsights.userId, user.id), eq(savedInsights.userId, user.id),
eq(savedInsights.period, period) eq(savedInsights.period, period),
),
) )
) .returning({
.returning({ id: savedInsights.id, createdAt: savedInsights.createdAt }); id: savedInsights.id,
createdAt: savedInsights.createdAt,
});
const updatedRecord = updated[0]; const updatedRecord = updated[0];
if (!updatedRecord) { if (!updatedRecord) {
@@ -728,9 +784,7 @@ export async function saveInsightsAction(
/** /**
* Carrega insights salvos do banco de dados * Carrega insights salvos do banco de dados
*/ */
export async function loadSavedInsightsAction( export async function loadSavedInsightsAction(period: string): Promise<
period: string
): Promise<
ActionResult<{ ActionResult<{
insights: InsightsResponse; insights: InsightsResponse;
modelId: string; modelId: string;
@@ -744,7 +798,10 @@ export async function loadSavedInsightsAction(
.select() .select()
.from(savedInsights) .from(savedInsights)
.where( .where(
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period)) and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
) )
.limit(1); .limit(1);
@@ -789,7 +846,7 @@ export async function loadSavedInsightsAction(
* Remove insights salvos do banco de dados * Remove insights salvos do banco de dados
*/ */
export async function deleteSavedInsightsAction( export async function deleteSavedInsightsAction(
period: string period: string,
): Promise<ActionResult<void>> { ): Promise<ActionResult<void>> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -797,7 +854,10 @@ export async function deleteSavedInsightsAction(
await db await db
.delete(savedInsights) .delete(savedInsights)
.where( .where(
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period)) and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
); );
return { return {

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -16,10 +16,7 @@ export default function InsightsLoading() {
{/* 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}
className="rounded-2xl border p-6 space-y-4"
>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="space-y-2 flex-1"> <div className="space-y-2 flex-1">
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" /> <Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />

View File

@@ -10,11 +10,11 @@ type PageProps = {
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) {

View File

@@ -1,6 +1,15 @@
"use server"; "use server";
import { contas, lancamentos, pagadores, categorias, cartoes } from "@/db/schema"; import { randomUUID } from "node:crypto";
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { z } from "zod";
import {
cartoes,
categorias,
contas,
lancamentos,
pagadores,
} from "@/db/schema";
import { import {
INITIAL_BALANCE_CONDITION, INITIAL_BALANCE_CONDITION,
INITIAL_BALANCE_NOTE, INITIAL_BALANCE_NOTE,
@@ -9,8 +18,8 @@ import {
} from "@/lib/accounts/constants"; } from "@/lib/accounts/constants";
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 {
LANCAMENTO_CONDITIONS, LANCAMENTO_CONDITIONS,
LANCAMENTO_PAYMENT_METHODS, LANCAMENTO_PAYMENT_METHODS,
@@ -22,14 +31,7 @@ import {
} from "@/lib/pagadores/notifications"; } from "@/lib/pagadores/notifications";
import { noteSchema, uuidSchema } from "@/lib/schemas/common"; import { noteSchema, uuidSchema } from "@/lib/schemas/common";
import { formatDecimalForDbRequired } from "@/lib/utils/currency"; import { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { import { getTodayDate, parseLocalDateString } from "@/lib/utils/date";
getTodayDate,
getTodayDateString,
parseLocalDateString,
} from "@/lib/utils/date";
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { z } from "zod";
// ============================================================================ // ============================================================================
// Authorization Validation Functions // Authorization Validation Functions
@@ -37,15 +39,12 @@ import { z } from "zod";
async function validatePagadorOwnership( async function validatePagadorOwnership(
userId: string, userId: string,
pagadorId: string | null | undefined pagadorId: string | null | undefined,
): Promise<boolean> { ): Promise<boolean> {
if (!pagadorId) return true; // Se não tem pagadorId, não precisa validar if (!pagadorId) return true; // Se não tem pagadorId, não precisa validar
const pagador = await db.query.pagadores.findFirst({ const pagador = await db.query.pagadores.findFirst({
where: and( where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, userId)),
eq(pagadores.id, pagadorId),
eq(pagadores.userId, userId)
),
}); });
return !!pagador; return !!pagador;
@@ -53,15 +52,12 @@ async function validatePagadorOwnership(
async function validateCategoriaOwnership( async function validateCategoriaOwnership(
userId: string, userId: string,
categoriaId: string | null | undefined categoriaId: string | null | undefined,
): Promise<boolean> { ): Promise<boolean> {
if (!categoriaId) return true; if (!categoriaId) return true;
const categoria = await db.query.categorias.findFirst({ const categoria = await db.query.categorias.findFirst({
where: and( where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)),
eq(categorias.id, categoriaId),
eq(categorias.userId, userId)
),
}); });
return !!categoria; return !!categoria;
@@ -69,15 +65,12 @@ async function validateCategoriaOwnership(
async function validateContaOwnership( async function validateContaOwnership(
userId: string, userId: string,
contaId: string | null | undefined contaId: string | null | undefined,
): Promise<boolean> { ): Promise<boolean> {
if (!contaId) return true; if (!contaId) return true;
const conta = await db.query.contas.findFirst({ const conta = await db.query.contas.findFirst({
where: and( where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
eq(contas.id, contaId),
eq(contas.userId, userId)
),
}); });
return !!conta; return !!conta;
@@ -85,15 +78,12 @@ async function validateContaOwnership(
async function validateCartaoOwnership( async function validateCartaoOwnership(
userId: string, userId: string,
cartaoId: string | null | undefined cartaoId: string | null | undefined,
): Promise<boolean> { ): Promise<boolean> {
if (!cartaoId) return true; if (!cartaoId) return true;
const cartao = await db.query.cartoes.findFirst({ const cartao = await db.query.cartoes.findFirst({
where: and( where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)),
eq(cartoes.id, cartaoId),
eq(cartoes.userId, userId)
),
}); });
return !!cartao; return !!cartao;
@@ -188,7 +178,7 @@ const baseFields = z.object({
const refineLancamento = ( const refineLancamento = (
data: z.infer<typeof baseFields> & { id?: string }, data: z.infer<typeof baseFields> & { id?: string },
ctx: z.RefinementCtx ctx: z.RefinementCtx,
) => { ) => {
if (data.condition === "Parcelado") { if (data.condition === "Parcelado") {
if (!data.installmentCount) { if (!data.installmentCount) {
@@ -316,7 +306,7 @@ const splitAmount = (totalCents: number, parts: number) => {
return Array.from( return Array.from(
{ length: parts }, { length: parts },
(_, index) => base + (index < remainder ? 1 : 0) (_, index) => base + (index < remainder ? 1 : 0),
); );
}; };
@@ -347,7 +337,7 @@ const addMonthsToDate = (value: Date, offset: number) => {
const lastDay = new Date( const lastDay = new Date(
result.getFullYear(), result.getFullYear(),
result.getMonth() + 1, result.getMonth() + 1,
0 0,
).getDate(); ).getDate();
result.setDate(Math.min(originalDay, lastDay)); result.setDate(Math.min(originalDay, lastDay));
@@ -445,7 +435,7 @@ const buildLancamentoRecords = ({
if (data.condition === "Parcelado") { if (data.condition === "Parcelado") {
const installmentTotal = data.installmentCount ?? 0; const installmentTotal = data.installmentCount ?? 0;
const amountsByShare = shares.map((share) => const amountsByShare = shares.map((share) =>
splitAmount(share.amountCents, installmentTotal) splitAmount(share.amountCents, installmentTotal),
); );
for ( for (
@@ -534,7 +524,7 @@ const buildLancamentoRecords = ({
}; };
export async function createLancamentoAction( export async function createLancamentoAction(
input: CreateInput input: CreateInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -544,19 +534,31 @@ export async function createLancamentoAction(
if (data.pagadorId) { if (data.pagadorId) {
const isValid = await validatePagadorOwnership(user.id, data.pagadorId); const isValid = await validatePagadorOwnership(user.id, data.pagadorId);
if (!isValid) { if (!isValid) {
return { success: false, error: "Pagador não encontrado ou sem permissão." }; return {
success: false,
error: "Pagador não encontrado ou sem permissão.",
};
} }
} }
if (data.secondaryPagadorId) { if (data.secondaryPagadorId) {
const isValid = await validatePagadorOwnership(user.id, data.secondaryPagadorId); const isValid = await validatePagadorOwnership(
user.id,
data.secondaryPagadorId,
);
if (!isValid) { if (!isValid) {
return { success: false, error: "Pagador secundário não encontrado ou sem permissão." }; return {
success: false,
error: "Pagador secundário não encontrado ou sem permissão.",
};
} }
} }
if (data.categoriaId) { if (data.categoriaId) {
const isValid = await validateCategoriaOwnership(user.id, data.categoriaId); const isValid = await validateCategoriaOwnership(
user.id,
data.categoriaId,
);
if (!isValid) { if (!isValid) {
return { success: false, error: "Categoria não encontrada." }; return { success: false, error: "Categoria não encontrada." };
} }
@@ -634,7 +636,7 @@ export async function createLancamentoAction(
purchaseDate: record.purchaseDate ?? null, purchaseDate: record.purchaseDate ?? null,
period: record.period ?? null, period: record.period ?? null,
note: record.note ?? null, note: record.note ?? null,
})) })),
); );
if (notificationEntries.size > 0) { if (notificationEntries.size > 0) {
@@ -654,7 +656,7 @@ export async function createLancamentoAction(
} }
export async function updateLancamentoAction( export async function updateLancamentoAction(
input: UpdateInput input: UpdateInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -664,19 +666,31 @@ export async function updateLancamentoAction(
if (data.pagadorId) { if (data.pagadorId) {
const isValid = await validatePagadorOwnership(user.id, data.pagadorId); const isValid = await validatePagadorOwnership(user.id, data.pagadorId);
if (!isValid) { if (!isValid) {
return { success: false, error: "Pagador não encontrado ou sem permissão." }; return {
success: false,
error: "Pagador não encontrado ou sem permissão.",
};
} }
} }
if (data.secondaryPagadorId) { if (data.secondaryPagadorId) {
const isValid = await validatePagadorOwnership(user.id, data.secondaryPagadorId); const isValid = await validatePagadorOwnership(
user.id,
data.secondaryPagadorId,
);
if (!isValid) { if (!isValid) {
return { success: false, error: "Pagador secundário não encontrado ou sem permissão." }; return {
success: false,
error: "Pagador secundário não encontrado ou sem permissão.",
};
} }
} }
if (data.categoriaId) { if (data.categoriaId) {
const isValid = await validateCategoriaOwnership(user.id, data.categoriaId); const isValid = await validateCategoriaOwnership(
user.id,
data.categoriaId,
);
if (!isValid) { if (!isValid) {
return { success: false, error: "Categoria não encontrada." }; return { success: false, error: "Categoria não encontrada." };
} }
@@ -740,7 +754,7 @@ export async function updateLancamentoAction(
const normalizedSettled = const normalizedSettled =
data.paymentMethod === "Cartão de crédito" data.paymentMethod === "Cartão de crédito"
? null ? null
: data.isSettled ?? false; : (data.isSettled ?? false);
const shouldSetBoletoPaymentDate = const shouldSetBoletoPaymentDate =
data.paymentMethod === "Boleto" && Boolean(normalizedSettled); data.paymentMethod === "Boleto" && Boolean(normalizedSettled);
const boletoPaymentDateValue = shouldSetBoletoPaymentDate const boletoPaymentDateValue = shouldSetBoletoPaymentDate
@@ -774,13 +788,13 @@ export async function updateLancamentoAction(
if (isInitialBalanceLancamento(existing) && existing?.contaId) { if (isInitialBalanceLancamento(existing) && existing?.contaId) {
const updatedInitialBalance = formatDecimalForDbRequired( const updatedInitialBalance = formatDecimalForDbRequired(
Math.abs(data.amount ?? 0) Math.abs(data.amount ?? 0),
); );
await db await db
.update(contas) .update(contas)
.set({ initialBalance: updatedInitialBalance }) .set({ initialBalance: updatedInitialBalance })
.where( .where(
and(eq(contas.id, existing.contaId), eq(contas.userId, user.id)) and(eq(contas.id, existing.contaId), eq(contas.userId, user.id)),
); );
} }
@@ -793,7 +807,7 @@ export async function updateLancamentoAction(
} }
export async function deleteLancamentoAction( export async function deleteLancamentoAction(
input: DeleteInput input: DeleteInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -875,7 +889,7 @@ export async function deleteLancamentoAction(
} }
export async function toggleLancamentoSettlementAction( export async function toggleLancamentoSettlementAction(
input: ToggleSettlementInput input: ToggleSettlementInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -935,7 +949,7 @@ const deleteBulkSchema = z.object({
type DeleteBulkInput = z.infer<typeof deleteBulkSchema>; type DeleteBulkInput = z.infer<typeof deleteBulkSchema>;
export async function deleteLancamentoBulkAction( export async function deleteLancamentoBulkAction(
input: DeleteBulkInput input: DeleteBulkInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -967,7 +981,7 @@ export async function deleteLancamentoBulkAction(
await db await db
.delete(lancamentos) .delete(lancamentos)
.where( .where(
and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)) and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)),
); );
revalidate(); revalidate();
@@ -981,8 +995,8 @@ export async function deleteLancamentoBulkAction(
and( and(
eq(lancamentos.seriesId, existing.seriesId), eq(lancamentos.seriesId, existing.seriesId),
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
sql`${lancamentos.period} >= ${existing.period}` sql`${lancamentos.period} >= ${existing.period}`,
) ),
); );
revalidate(); revalidate();
@@ -998,8 +1012,8 @@ export async function deleteLancamentoBulkAction(
.where( .where(
and( and(
eq(lancamentos.seriesId, existing.seriesId), eq(lancamentos.seriesId, existing.seriesId),
eq(lancamentos.userId, user.id) eq(lancamentos.userId, user.id),
) ),
); );
revalidate(); revalidate();
@@ -1054,7 +1068,7 @@ const updateBulkSchema = z.object({
type UpdateBulkInput = z.infer<typeof updateBulkSchema>; type UpdateBulkInput = z.infer<typeof updateBulkSchema>;
export async function updateLancamentoBulkAction( export async function updateLancamentoBulkAction(
input: UpdateBulkInput input: UpdateBulkInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -1141,7 +1155,7 @@ export async function updateLancamentoBulkAction(
}; };
const applyUpdates = async ( const applyUpdates = async (
records: Array<{ id: string; purchaseDate: Date | null }> records: Array<{ id: string; purchaseDate: Date | null }>,
) => { ) => {
if (records.length === 0) { if (records.length === 0) {
return; return;
@@ -1168,8 +1182,8 @@ export async function updateLancamentoBulkAction(
.where( .where(
and( and(
eq(lancamentos.id, record.id), eq(lancamentos.id, record.id),
eq(lancamentos.userId, user.id) eq(lancamentos.userId, user.id),
) ),
); );
} }
}); });
@@ -1196,7 +1210,7 @@ export async function updateLancamentoBulkAction(
where: and( where: and(
eq(lancamentos.seriesId, existing.seriesId), eq(lancamentos.seriesId, existing.seriesId),
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
sql`${lancamentos.period} >= ${existing.period}` sql`${lancamentos.period} >= ${existing.period}`,
), ),
orderBy: asc(lancamentos.purchaseDate), orderBy: asc(lancamentos.purchaseDate),
}); });
@@ -1205,7 +1219,7 @@ export async function updateLancamentoBulkAction(
futureLancamentos.map((item) => ({ futureLancamentos.map((item) => ({
id: item.id, id: item.id,
purchaseDate: item.purchaseDate ?? null, purchaseDate: item.purchaseDate ?? null,
})) })),
); );
revalidate(); revalidate();
@@ -1223,7 +1237,7 @@ export async function updateLancamentoBulkAction(
}, },
where: and( where: and(
eq(lancamentos.seriesId, existing.seriesId), eq(lancamentos.seriesId, existing.seriesId),
eq(lancamentos.userId, user.id) eq(lancamentos.userId, user.id),
), ),
orderBy: asc(lancamentos.purchaseDate), orderBy: asc(lancamentos.purchaseDate),
}); });
@@ -1232,7 +1246,7 @@ export async function updateLancamentoBulkAction(
allLancamentos.map((item) => ({ allLancamentos.map((item) => ({
id: item.id, id: item.id,
purchaseDate: item.purchaseDate ?? null, purchaseDate: item.purchaseDate ?? null,
})) })),
); );
revalidate(); revalidate();
@@ -1290,7 +1304,7 @@ const massAddSchema = z.object({
type MassAddInput = z.infer<typeof massAddSchema>; type MassAddInput = z.infer<typeof massAddSchema>;
export async function createMassLancamentosAction( export async function createMassLancamentosAction(
input: MassAddInput input: MassAddInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -1298,14 +1312,20 @@ export async function createMassLancamentosAction(
// Validar campos fixos // Validar campos fixos
if (data.fixedFields.contaId) { if (data.fixedFields.contaId) {
const isValid = await validateContaOwnership(user.id, data.fixedFields.contaId); const isValid = await validateContaOwnership(
user.id,
data.fixedFields.contaId,
);
if (!isValid) { if (!isValid) {
return { success: false, error: "Conta não encontrada." }; return { success: false, error: "Conta não encontrada." };
} }
} }
if (data.fixedFields.cartaoId) { if (data.fixedFields.cartaoId) {
const isValid = await validateCartaoOwnership(user.id, data.fixedFields.cartaoId); const isValid = await validateCartaoOwnership(
user.id,
data.fixedFields.cartaoId,
);
if (!isValid) { if (!isValid) {
return { success: false, error: "Cartão não encontrado." }; return { success: false, error: "Cartão não encontrado." };
} }
@@ -1316,21 +1336,27 @@ export async function createMassLancamentosAction(
const transaction = data.transactions[i]; const transaction = data.transactions[i];
if (transaction.pagadorId) { if (transaction.pagadorId) {
const isValid = await validatePagadorOwnership(user.id, transaction.pagadorId); const isValid = await validatePagadorOwnership(
user.id,
transaction.pagadorId,
);
if (!isValid) { if (!isValid) {
return { return {
success: false, success: false,
error: `Pagador não encontrado na transação ${i + 1}.` error: `Pagador não encontrado na transação ${i + 1}.`,
}; };
} }
} }
if (transaction.categoriaId) { if (transaction.categoriaId) {
const isValid = await validateCategoriaOwnership(user.id, transaction.categoriaId); const isValid = await validateCategoriaOwnership(
user.id,
transaction.categoriaId,
);
if (!isValid) { if (!isValid) {
return { return {
success: false, success: false,
error: `Categoria não encontrada na transação ${i + 1}.` error: `Categoria não encontrada na transação ${i + 1}.`,
}; };
} }
} }
@@ -1365,10 +1391,10 @@ export async function createMassLancamentosAction(
const contaId = const contaId =
paymentMethod === "Cartão de crédito" paymentMethod === "Cartão de crédito"
? null ? null
: data.fixedFields.contaId ?? null; : (data.fixedFields.contaId ?? null);
const cartaoId = const cartaoId =
paymentMethod === "Cartão de crédito" paymentMethod === "Cartão de crédito"
? data.fixedFields.cartaoId ?? null ? (data.fixedFields.cartaoId ?? null)
: null; : null;
const categoriaId = transaction.categoriaId ?? null; const categoriaId = transaction.categoriaId ?? null;
@@ -1463,7 +1489,7 @@ const deleteMultipleSchema = z.object({
type DeleteMultipleInput = z.infer<typeof deleteMultipleSchema>; type DeleteMultipleInput = z.infer<typeof deleteMultipleSchema>;
export async function deleteMultipleLancamentosAction( export async function deleteMultipleLancamentosAction(
input: DeleteMultipleInput input: DeleteMultipleInput,
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const user = await getUser(); const user = await getUser();
@@ -1485,7 +1511,7 @@ export async function deleteMultipleLancamentosAction(
}, },
where: and( where: and(
inArray(lancamentos.id, data.ids), inArray(lancamentos.id, data.ids),
eq(lancamentos.userId, user.id) eq(lancamentos.userId, user.id),
), ),
}); });
@@ -1497,17 +1523,17 @@ export async function deleteMultipleLancamentosAction(
await db await db
.delete(lancamentos) .delete(lancamentos)
.where( .where(
and(inArray(lancamentos.id, data.ids), eq(lancamentos.userId, user.id)) and(inArray(lancamentos.id, data.ids), eq(lancamentos.userId, user.id)),
); );
// Send notifications // Send notifications
const notificationData = existing const notificationData = existing
.filter( .filter(
( (
item item,
): item is typeof item & { ): item is typeof item & {
pagadorId: NonNullable<typeof item.pagadorId>; pagadorId: NonNullable<typeof item.pagadorId>;
} => Boolean(item.pagadorId) } => Boolean(item.pagadorId),
) )
.map((item) => ({ .map((item) => ({
pagadorId: item.pagadorId, pagadorId: item.pagadorId,
@@ -1561,8 +1587,8 @@ export async function getRecentEstablishmentsAction(): Promise<string[]> {
.where( .where(
and( and(
eq(lancamentos.userId, user.id), eq(lancamentos.userId, user.id),
gte(lancamentos.purchaseDate, threeMonthsAgo) gte(lancamentos.purchaseDate, threeMonthsAgo),
) ),
) )
.orderBy(desc(lancamentos.purchaseDate)); .orderBy(desc(lancamentos.purchaseDate));
@@ -1575,9 +1601,9 @@ export async function getRecentEstablishmentsAction(): Promise<string[]> {
(name): name is string => (name): 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"),
) ),
) ),
); );
// Return top 50 most recent unique establishments // Return top 50 most recent unique establishments

View File

@@ -1,17 +1,18 @@
"use server"; "use server";
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { import {
categorias, categorias,
installmentAnticipations, installmentAnticipations,
lancamentos, lancamentos,
pagadores, pagadores,
type InstallmentAnticipation,
type Lancamento,
} from "@/db/schema"; } 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 { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { import {
generateAnticipationDescription, generateAnticipationDescription,
generateAnticipationNote, generateAnticipationNote,
@@ -24,9 +25,6 @@ import type {
} from "@/lib/installments/anticipation-types"; } from "@/lib/installments/anticipation-types";
import { uuidSchema } from "@/lib/schemas/common"; import { uuidSchema } from "@/lib/schemas/common";
import { formatDecimalForDbRequired } from "@/lib/utils/currency"; import { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
/** /**
* Schema de validação para criar antecipação * Schema de validação para criar antecipação
@@ -63,7 +61,7 @@ const cancelAnticipationSchema = z.object({
* 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();
@@ -79,7 +77,7 @@ export async function getEligibleInstallmentsAction(
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: {
@@ -124,7 +122,7 @@ export async function getEligibleInstallmentsAction(
* 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();
@@ -137,7 +135,7 @@ export async function createInstallmentAnticipationAction(
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),
), ),
}); });
@@ -158,7 +156,7 @@ export async function createInstallmentAnticipationAction(
// 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);
@@ -175,7 +173,8 @@ export async function createInstallmentAnticipationAction(
} }
// 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 < 0
? totalAmount + discount // Despesa: -1000 + 20 = -980 ? totalAmount + discount // Despesa: -1000 + 20 = -980
: totalAmount - discount; // Receita: 1000 - 20 = 980 : totalAmount - discount; // Receita: 1000 - 20 = 980
@@ -190,7 +189,7 @@ export async function createInstallmentAnticipationAction(
.values({ .values({
name: generateAnticipationDescription( name: generateAnticipationDescription(
firstInstallment.name, firstInstallment.name,
installments.length installments.length,
), ),
condition: "À vista", condition: "À vista",
transactionType: firstInstallment.transactionType, transactionType: firstInstallment.transactionType,
@@ -219,7 +218,7 @@ export async function createInstallmentAnticipationAction(
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,
@@ -270,7 +269,9 @@ export async function createInstallmentAnticipationAction(
return { return {
success: true, success: true,
message: `${installments.length} ${ message: `${installments.length} ${
installments.length === 1 ? "parcela antecipada" : "parcelas antecipadas" installments.length === 1
? "parcela antecipada"
: "parcelas antecipadas"
} com sucesso!`, } com sucesso!`,
}; };
} catch (error) { } catch (error) {
@@ -282,7 +283,7 @@ export async function createInstallmentAnticipationAction(
* 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();
@@ -297,7 +298,8 @@ export async function getInstallmentAnticipationsAction(
seriesId: installmentAnticipations.seriesId, seriesId: installmentAnticipations.seriesId,
anticipationPeriod: installmentAnticipations.anticipationPeriod, anticipationPeriod: installmentAnticipations.anticipationPeriod,
anticipationDate: installmentAnticipations.anticipationDate, anticipationDate: installmentAnticipations.anticipationDate,
anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds, anticipatedInstallmentIds:
installmentAnticipations.anticipatedInstallmentIds,
totalAmount: installmentAnticipations.totalAmount, totalAmount: installmentAnticipations.totalAmount,
installmentCount: installmentAnticipations.installmentCount, installmentCount: installmentAnticipations.installmentCount,
discount: installmentAnticipations.discount, discount: installmentAnticipations.discount,
@@ -313,14 +315,20 @@ export async function getInstallmentAnticipationsAction(
categoria: categorias, categoria: categorias,
}) })
.from(installmentAnticipations) .from(installmentAnticipations)
.leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id)) .leftJoin(
lancamentos,
eq(installmentAnticipations.lancamentoId, lancamentos.id),
)
.leftJoin(pagadores, eq(installmentAnticipations.pagadorId, pagadores.id)) .leftJoin(pagadores, eq(installmentAnticipations.pagadorId, pagadores.id))
.leftJoin(categorias, eq(installmentAnticipations.categoriaId, categorias.id)) .leftJoin(
categorias,
eq(installmentAnticipations.categoriaId, categorias.id),
)
.where( .where(
and( and(
eq(installmentAnticipations.seriesId, validatedSeriesId), eq(installmentAnticipations.seriesId, validatedSeriesId),
eq(installmentAnticipations.userId, user.id) eq(installmentAnticipations.userId, user.id),
) ),
) )
.orderBy(desc(installmentAnticipations.createdAt)); .orderBy(desc(installmentAnticipations.createdAt));
@@ -338,7 +346,7 @@ 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();
@@ -352,7 +360,8 @@ export async function cancelInstallmentAnticipationAction(
seriesId: installmentAnticipations.seriesId, seriesId: installmentAnticipations.seriesId,
anticipationPeriod: installmentAnticipations.anticipationPeriod, anticipationPeriod: installmentAnticipations.anticipationPeriod,
anticipationDate: installmentAnticipations.anticipationDate, anticipationDate: installmentAnticipations.anticipationDate,
anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds, anticipatedInstallmentIds:
installmentAnticipations.anticipatedInstallmentIds,
totalAmount: installmentAnticipations.totalAmount, totalAmount: installmentAnticipations.totalAmount,
installmentCount: installmentAnticipations.installmentCount, installmentCount: installmentAnticipations.installmentCount,
discount: installmentAnticipations.discount, discount: installmentAnticipations.discount,
@@ -365,12 +374,15 @@ export async function cancelInstallmentAnticipationAction(
lancamento: lancamentos, lancamento: lancamentos,
}) })
.from(installmentAnticipations) .from(installmentAnticipations)
.leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id)) .leftJoin(
lancamentos,
eq(installmentAnticipations.lancamentoId, lancamentos.id),
)
.where( .where(
and( and(
eq(installmentAnticipations.id, data.anticipationId), eq(installmentAnticipations.id, data.anticipationId),
eq(installmentAnticipations.userId, user.id) eq(installmentAnticipations.userId, user.id),
) ),
) )
.limit(1); .limit(1);
@@ -383,7 +395,7 @@ export async function cancelInstallmentAnticipationAction(
// 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.",
); );
} }
@@ -403,8 +415,8 @@ export async function cancelInstallmentAnticipationAction(
.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
@@ -434,7 +446,7 @@ export async function cancelInstallmentAnticipationAction(
* 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();
@@ -445,7 +457,7 @@ export async function getAnticipationDetailsAction(
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,

View File

@@ -1,7 +1,13 @@
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
@@ -24,9 +30,9 @@ export async function fetchLancamentos(filters: SQL[]) {
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));

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -1,5 +1,5 @@
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,
@@ -13,8 +13,8 @@ import {
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>;

View File

@@ -1,20 +1,20 @@
"use server"; "use server";
import { and, eq, ne } from "drizzle-orm";
import { z } from "zod";
import { categorias, orcamentos } from "@/db/schema"; import { categorias, orcamentos } from "@/db/schema";
import { import {
type ActionResult, type ActionResult,
handleActionError, handleActionError,
revalidateForEntity, revalidateForEntity,
} from "@/lib/actions/helpers"; } from "@/lib/actions/helpers";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { periodSchema, uuidSchema } from "@/lib/schemas/common"; import { periodSchema, uuidSchema } from "@/lib/schemas/common";
import { import {
formatDecimalForDbRequired, formatDecimalForDbRequired,
normalizeDecimalInput, normalizeDecimalInput,
} from "@/lib/utils/currency"; } from "@/lib/utils/currency";
import { and, eq, ne } from "drizzle-orm";
import { z } from "zod";
const budgetBaseSchema = z.object({ const budgetBaseSchema = z.object({
categoriaId: uuidSchema("Categoria"), categoriaId: uuidSchema("Categoria"),
@@ -26,12 +26,12 @@ const budgetBaseSchema = z.object({
.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.",
), ),
}); });
@@ -66,7 +66,7 @@ const ensureCategory = async (userId: string, categoriaId: string) => {
}; };
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();
@@ -109,7 +109,7 @@ export async function createBudgetAction(
} }
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();
@@ -163,7 +163,7 @@ export async function updateBudgetAction(
} }
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();
@@ -193,12 +193,10 @@ 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();
@@ -218,7 +216,7 @@ export async function duplicatePreviousMonthBudgetsAction(
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),
), ),
}); });
@@ -233,17 +231,17 @@ export async function duplicatePreviousMonthBudgetsAction(
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) {
@@ -261,7 +259,7 @@ export async function duplicatePreviousMonthBudgetsAction(
period: data.period, period: data.period,
userId: user.id, userId: user.id,
categoriaId: b.categoriaId!, categoriaId: b.categoriaId!,
})) })),
); );
revalidateForEntity("orcamentos"); revalidateForEntity("orcamentos");

View File

@@ -1,11 +1,11 @@
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;
@@ -37,7 +37,7 @@ export type CategoryOption = {
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[];
@@ -46,7 +46,7 @@ export async function fetchBudgetsForUser(
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,
@@ -81,16 +81,18 @@ export async function fetchBudgetsForUser(
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: string | null; totalAmount: string | null }) => [
row.categoriaId ?? "", row.categoriaId ?? "",
Math.abs(toNumber(row.totalAmount)), Math.abs(toNumber(row.totalAmount)),
]) ],
),
); );
} }
@@ -112,7 +114,7 @@ export async function fetchBudgetsForUser(
.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) => ({

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -23,10 +23,7 @@ export default function OrcamentosLoading() {
{/* 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}
className="rounded-2xl border p-6 space-y-4"
>
{/* Categoria com ícone */} {/* Categoria com ícone */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" /> <Skeleton className="size-10 rounded-2xl bg-foreground/10" />

View File

@@ -12,11 +12,11 @@ type PageProps = {
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) =>
@@ -35,7 +35,10 @@ export default async function Page({ searchParams }: PageProps) {
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">

View File

@@ -1,8 +1,12 @@
"use server"; "use server";
import { and, desc, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { Resend } from "resend";
import { z } from "zod";
import { lancamentos, pagadores } from "@/db/schema"; import { lancamentos, pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { import {
fetchPagadorBoletoStats, fetchPagadorBoletoStats,
fetchPagadorCardUsage, fetchPagadorCardUsage,
@@ -10,10 +14,6 @@ import {
fetchPagadorMonthlyBreakdown, fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details"; } from "@/lib/pagadores/details";
import { displayPeriod } from "@/lib/utils/period"; import { displayPeriod } from "@/lib/utils/period";
import { and, desc, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { Resend } from "resend";
import { z } from "zod";
const inputSchema = z.object({ const inputSchema = z.object({
pagadorId: z.string().uuid("Pagador inválido."), pagadorId: z.string().uuid("Pagador inválido."),
@@ -122,7 +122,7 @@ const buildSummaryHtml = ({
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;">
@@ -130,7 +130,7 @@ const buildSummaryHtml = ({
<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>
@@ -146,12 +146,12 @@ const buildSummaryHtml = ({
(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>`;
@@ -163,15 +163,15 @@ const buildSummaryHtml = ({
(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>`;
@@ -183,7 +183,7 @@ const buildSummaryHtml = ({
(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"
@@ -195,9 +195,9 @@ const buildSummaryHtml = ({
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>`;
@@ -209,7 +209,7 @@ const buildSummaryHtml = ({
(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"
@@ -218,12 +218,12 @@ const buildSummaryHtml = ({
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>`;
@@ -237,7 +237,7 @@ 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>
@@ -246,7 +246,7 @@ 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>
@@ -258,26 +258,26 @@ 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>
@@ -305,7 +305,7 @@ 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>
@@ -333,7 +333,7 @@ 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>
@@ -396,7 +396,7 @@ 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);
@@ -471,8 +471,8 @@ export async function sendPagadorSummaryAction(
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
@@ -490,8 +490,8 @@ export async function sendPagadorSummaryAction(
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
@@ -509,8 +509,8 @@ export async function sendPagadorSummaryAction(
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)),
]); ]);
@@ -530,7 +530,7 @@ export async function sendPagadorSummaryAction(
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) => {
@@ -573,7 +573,7 @@ export async function sendPagadorSummaryAction(
.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}`);

View File

@@ -1,6 +1,14 @@
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;
@@ -11,7 +19,7 @@ export type ShareData = {
}; };
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({
@@ -22,10 +30,7 @@ export async function fetchPagadorShares(
userEmail: usersTable.email, userEmail: usersTable.email,
}) })
.from(pagadorShares) .from(pagadorShares)
.innerJoin( .innerJoin(usersTable, eq(pagadorShares.sharedWithUserId, usersTable.id))
usersTable,
eq(pagadorShares.sharedWithUserId, usersTable.id)
)
.where(eq(pagadorShares.pagadorId, pagadorId)); .where(eq(pagadorShares.pagadorId, pagadorId));
return shareRows.map((share) => ({ return shareRows.map((share) => ({
@@ -39,7 +44,7 @@ export async function fetchPagadorShares(
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: {
@@ -48,7 +53,7 @@ export async function fetchCurrentUserShare(
}, },
where: and( where: and(
eq(pagadorShares.pagadorId, pagadorId), eq(pagadorShares.pagadorId, pagadorId),
eq(pagadorShares.sharedWithUserId, userId) eq(pagadorShares.sharedWithUserId, userId),
), ),
}); });

View File

@@ -1,11 +1,5 @@
import { notFound } from "next/navigation";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card";
import { PagadorBoletoCard } from "@/components/pagadores/details/pagador-payment-method-cards";
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page"; import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import type { import type {
ContaCartaoFilterOption, ContaCartaoFilterOption,
@@ -14,8 +8,15 @@ import type {
SelectOption, SelectOption,
} from "@/components/lancamentos/types"; } from "@/components/lancamentos/types";
import MonthNavigation from "@/components/month-picker/month-navigation"; import MonthNavigation from "@/components/month-picker/month-navigation";
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card";
import { PagadorBoletoCard } from "@/components/pagadores/details/pagador-payment-method-cards";
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
import { 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,
@@ -25,22 +26,25 @@ import {
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 { parsePeriodParam } from "@/lib/utils/period";
import { import {
fetchPagadorBoletoStats, fetchPagadorBoletoStats,
fetchPagadorCardUsage, fetchPagadorCardUsage,
fetchPagadorHistory, fetchPagadorHistory,
fetchPagadorMonthlyBreakdown, fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details"; } from "@/lib/pagadores/details";
import { notFound } from "next/navigation"; import { parsePeriodParam } from "@/lib/utils/period";
import { fetchPagadorLancamentos, fetchPagadorShares, fetchCurrentUserShare } from "./data"; import {
fetchCurrentUserShare,
fetchPagadorLancamentos,
fetchPagadorShares,
} from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>; type PageSearchParams = Promise<ResolvedSearchParams>;
@@ -212,7 +216,9 @@ export default async function Page({ params, searchParams }: PageProps) {
// 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(
loggedUserFilterSources,
);
loggedUserOptionSets = buildOptionSets({ loggedUserOptionSets = buildOptionSets({
...loggedUserSluggedFilters, ...loggedUserSluggedFilters,
pagadorRows: loggedUserFilterSources.pagadorRows, pagadorRows: loggedUserFilterSources.pagadorRows,
@@ -222,12 +228,12 @@ export default async function Page({ params, searchParams }: PageProps) {
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;
@@ -334,7 +340,9 @@ export default async function Page({ params, searchParams }: PageProps) {
estabelecimentos={estabelecimentos} estabelecimentos={estabelecimentos}
allowCreate={canEdit} allowCreate={canEdit}
importPagadorOptions={loggedUserOptionSets?.pagadorOptions} importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
importSplitPagadorOptions={loggedUserOptionSets?.splitPagadorOptions} importSplitPagadorOptions={
loggedUserOptionSets?.splitPagadorOptions
}
importDefaultPagadorId={loggedUserOptionSets?.defaultPagadorId} importDefaultPagadorId={loggedUserOptionSets?.defaultPagadorId}
importContaOptions={loggedUserOptionSets?.contaOptions} importContaOptions={loggedUserOptionSets?.contaOptions}
importCartaoOptions={loggedUserOptionSets?.cartaoOptions} importCartaoOptions={loggedUserOptionSets?.cartaoOptions}
@@ -349,12 +357,12 @@ export default async function Page({ params, searchParams }: PageProps) {
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[] = [
@@ -405,7 +413,7 @@ function buildReadOnlyOptionSets(
(option) => ({ (option) => ({
slug: option.value, slug: option.value,
label: option.label, label: option.label,
}) }),
); );
const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [ const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [

View File

@@ -1,10 +1,14 @@
"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,
@@ -14,10 +18,6 @@ import {
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: () => ({
@@ -83,7 +83,7 @@ const generateShareCode = () => {
}; };
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();
@@ -110,14 +110,17 @@ export async function createPagadorAction(
} }
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) {
@@ -139,7 +142,9 @@ export async function updatePagadorAction(
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) {
@@ -160,7 +165,7 @@ export async function updatePagadorAction(
} }
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();
@@ -197,7 +202,7 @@ export async function deletePagadorAction(
} }
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();
@@ -221,7 +226,7 @@ export async function joinPagadorByShareCodeAction(
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),
), ),
}); });
@@ -248,7 +253,7 @@ export async function joinPagadorByShareCodeAction(
} }
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();
@@ -271,16 +276,18 @@ export async function deletePagadorShareAction(
}); });
// 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 (
!existing ||
(existing.pagador.userId !== user.id &&
existing.sharedWithUserId !== user.id)
) {
return { return {
success: false, success: false,
error: "Compartilhamento não encontrado.", 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}`);
@@ -292,7 +299,7 @@ export async function deletePagadorShareAction(
} }
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();
@@ -300,7 +307,10 @@ export async function regeneratePagadorShareCodeAction(
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) {
@@ -314,7 +324,12 @@ export async function regeneratePagadorShareCodeAction(
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}`);

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -1,14 +1,14 @@
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"]);
@@ -36,7 +36,7 @@ async function loadAvatarOptions() {
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];
}; };
@@ -64,7 +64,7 @@ export default async function Page() {
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

View File

@@ -1,13 +1,13 @@
"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"),

View File

@@ -2,19 +2,28 @@
* 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 {
cartoes,
categorias,
contas,
inboxItems,
lancamentos,
} from "@/db/schema";
import { db } from "@/lib/db";
import { import {
fetchLancamentoFilterSources,
buildSluggedFilters,
buildOptionSets, 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()
@@ -27,7 +36,7 @@ export async function fetchInboxItems(
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()
@@ -40,7 +49,7 @@ export async function fetchInboxItemById(
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 })
@@ -48,14 +57,16 @@ export async function fetchCategoriasForSelect(
.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(
userId: string,
): Promise<SelectOption[]> {
const items = await db const items = await db
.select({ id: contas.id, name: contas.name }) .select({ id: contas.id, name: contas.name })
.from(contas) .from(contas)
@@ -66,7 +77,7 @@ export async function fetchContasForSelect(userId: string): Promise<SelectOption
} }
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 })
@@ -81,7 +92,9 @@ 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;
} }
@@ -123,8 +136,8 @@ export async function fetchInboxDialogData(userId: string): Promise<{
.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));
@@ -135,11 +148,11 @@ export async function fetchInboxDialogData(userId: string): Promise<{
(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 {

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -1,3 +1,4 @@
import { RiBankCard2Line } from "@remixicon/react";
import MonthNavigation from "@/components/month-picker/month-navigation"; import MonthNavigation from "@/components/month-picker/month-navigation";
import { CardCategoryBreakdown } from "@/components/relatorios/cartoes/card-category-breakdown"; import { CardCategoryBreakdown } from "@/components/relatorios/cartoes/card-category-breakdown";
import { CardInvoiceStatus } from "@/components/relatorios/cartoes/card-invoice-status"; import { CardInvoiceStatus } from "@/components/relatorios/cartoes/card-invoice-status";
@@ -7,7 +8,6 @@ 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>>;

View File

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

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -1,18 +1,17 @@
import { redirect } from "next/navigation";
import { CategoryReportPage } from "@/components/relatorios/category-report-page"; 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 { import type {
CategoryOption, CategoryOption,
FilterState, FilterState,
} from "@/components/relatorios/types"; } from "@/components/relatorios/types";
import { db } from "@/lib/db"; import type { Categoria } from "@/db/schema";
import { categorias, type Categoria } from "@/db/schema"; import { getUserId } from "@/lib/auth/server";
import { eq, asc } from "drizzle-orm"; import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
import { redirect } from "next/navigation"; 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>>;
@@ -22,11 +21,11 @@ type PageProps = {
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) {
@@ -59,15 +58,12 @@ export default async function Page({ searchParams }: PageProps) {
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(
@@ -76,7 +72,7 @@ export default async function Page({ searchParams }: PageProps) {
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
@@ -95,7 +91,7 @@ export default async function Page({ searchParams }: PageProps) {
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

View File

@@ -1,5 +1,5 @@
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",

View File

@@ -1,9 +1,3 @@
import { AnimatedThemeToggler } from "@/components/animated-theme-toggler";
import { Logo } from "@/components/logo";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getOptionalUserSession } from "@/lib/auth/server";
import { import {
RiArrowRightSLine, RiArrowRightSLine,
RiBankCard2Line, RiBankCard2Line,
@@ -12,23 +6,29 @@ import {
RiCodeSSlashLine, RiCodeSSlashLine,
RiDatabase2Line, RiDatabase2Line,
RiDeviceLine, RiDeviceLine,
RiDownloadCloudLine,
RiEyeOffLine,
RiFileTextLine,
RiFlashlightLine,
RiGithubFill, RiGithubFill,
RiLineChartLine, RiLineChartLine,
RiLockLine, RiLockLine,
RiPercentLine,
RiPieChartLine, RiPieChartLine,
RiRobot2Line,
RiShieldCheckLine, RiShieldCheckLine,
RiTeamLine,
RiTimeLine, RiTimeLine,
RiWalletLine, RiWalletLine,
RiRobot2Line,
RiTeamLine,
RiFileTextLine,
RiDownloadCloudLine,
RiEyeOffLine,
RiFlashlightLine,
RiPercentLine,
} from "@remixicon/react"; } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { AnimatedThemeToggler } from "@/components/animated-theme-toggler";
import { Logo } from "@/components/logo";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getOptionalUserSession } from "@/lib/auth/server";
export default async function Page() { export default async function Page() {
const session = await getOptionalUserSession(); const session = await getOptionalUserSession();

View File

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

View File

@@ -5,11 +5,16 @@
* 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 {
@@ -20,7 +25,7 @@ export async function POST(request: Request) {
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 },
); );
} }
@@ -30,7 +35,7 @@ export async function POST(request: Request) {
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 },
); );
} }
@@ -39,14 +44,14 @@ export async function POST(request: Request) {
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 },
); );
} }
@@ -56,7 +61,7 @@ export async function POST(request: Request) {
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 },
); );
} }
@@ -66,7 +71,9 @@ export async function POST(request: Request) {
.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:
request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip"),
expiresAt: result.expiresAt, expiresAt: result.expiresAt,
}) })
.where(eq(apiTokens.id, payload.tokenId)); .where(eq(apiTokens.id, payload.tokenId));
@@ -79,7 +86,7 @@ export async function POST(request: Request) {
console.error("[API] Error refreshing device token:", error); console.error("[API] Error refreshing device token:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erro ao renovar token" }, { error: "Erro ao renovar token" },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@@ -5,13 +5,17 @@
* 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"),
@@ -24,10 +28,7 @@ export async function POST(request: Request) {
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
@@ -37,7 +38,7 @@ export async function POST(request: Request) {
// 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
@@ -58,22 +59,20 @@ export async function POST(request: Request) {
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) { } 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 device token:", error); console.error("[API] Error creating device token:", error);
return NextResponse.json( return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
{ error: "Erro ao criar token" },
{ status: 500 }
);
} }
} }

View File

@@ -5,18 +5,18 @@
* 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;
@@ -24,24 +24,21 @@ export async function DELETE(request: Request, { params }: RouteParams) {
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 },
); );
} }
@@ -59,7 +56,7 @@ export async function DELETE(request: Request, { params }: RouteParams) {
console.error("[API] Error revoking device token:", error); console.error("[API] Error revoking device token:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erro ao revogar token" }, { error: "Erro ao revogar token" },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@@ -5,12 +5,12 @@
* 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 {
@@ -18,10 +18,7 @@ export async function GET() {
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
@@ -40,14 +37,16 @@ export async function GET() {
.orderBy(desc(apiTokens.createdAt)); .orderBy(desc(apiTokens.createdAt));
// Separar tokens ativos e revogados // Separar tokens ativos e revogados
const activeTokens = tokens.filter((t) => !t.expiresAt || new Date(t.expiresAt) > new Date()); const activeTokens = tokens.filter(
(t) => !t.expiresAt || new Date(t.expiresAt) > new Date(),
);
return NextResponse.json({ tokens: activeTokens }); return NextResponse.json({ tokens: activeTokens });
} catch (error) { } catch (error) {
console.error("[API] Error listing device tokens:", error); console.error("[API] Error listing device tokens:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Erro ao listar tokens" }, { error: "Erro ao listar tokens" },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@@ -7,11 +7,11 @@
* 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 {
@@ -22,7 +22,7 @@ export async function POST(request: Request) {
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 },
); );
} }
@@ -30,7 +30,7 @@ export async function POST(request: Request) {
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 },
); );
} }
@@ -41,21 +41,22 @@ export async function POST(request: Request) {
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)
@@ -75,7 +76,7 @@ export async function POST(request: Request) {
console.error("[API] Error verifying device token:", error); console.error("[API] Error verifying device token:", error);
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "Erro ao validar token" }, { valid: false, error: "Erro ao validar token" },
{ status: 500 } { status: 500 },
); );
} }
} }

View File

@@ -24,7 +24,7 @@ export async function GET() {
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)
@@ -36,9 +36,10 @@ export async function GET() {
name: "OpenSheets", name: "OpenSheets",
version: APP_VERSION, version: APP_VERSION,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
message: error instanceof Error ? error.message : "Database connection failed", message:
error instanceof Error ? error.message : "Database connection failed",
}, },
{ status: 503 } { status: 503 },
); );
} }
} }

View File

@@ -5,13 +5,13 @@
* Requer autenticação via API token (formato os_xxx). * Requer autenticação via API token (formato os_xxx).
*/ */
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema"; import { apiTokens, inboxItems } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token"; import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { inboxBatchSchema } from "@/lib/schemas/inbox"; import { inboxBatchSchema } from "@/lib/schemas/inbox";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
// Rate limiting simples em memória // Rate limiting simples em memória
const rateLimitMap = new Map<string, { count: number; resetAt: number }>(); const rateLimitMap = new Map<string, { count: number; resetAt: number }>();

View File

@@ -5,13 +5,13 @@
* Requer autenticação via API token (formato os_xxx). * Requer autenticação via API token (formato os_xxx).
*/ */
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema"; import { apiTokens, inboxItems } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token"; import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { inboxItemSchema } from "@/lib/schemas/inbox"; import { inboxItemSchema } from "@/lib/schemas/inbox";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
// Rate limiting simples em memória (em produção, use Redis) // Rate limiting simples em memória (em produção, use Redis)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>(); const rateLimitMap = new Map<string, { count: number; resetAt: number }>();

View File

@@ -1,9 +1,9 @@
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { main_font } from "@/public/fonts/font_index";
import { Analytics } from "@vercel/analytics/next"; import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next"; import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { main_font } from "@/public/fonts/font_index";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { RiFileSearchLine } from "@remixicon/react"; import { RiFileSearchLine } from "@remixicon/react";
import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {

67
biome.json Normal file
View File

@@ -0,0 +1,67 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noArrayIndexKey": "off",
"noExplicitAny": "warn",
"noImplicitAnyLet": "warn",
"noShadowRestrictedNames": "warn",
"noDocumentCookie": "off",
"useIterableCallbackReturn": "off"
},
"style": {
"noNonNullAssertion": "warn"
},
"a11y": {
"noLabelWithoutControl": "off",
"useFocusableInteractive": "off",
"useSemanticElements": "off",
"noSvgWithoutTitle": "off",
"useButtonType": "off",
"useAriaPropsSupportedByRole": "off"
},
"correctness": {
"noUnusedVariables": "warn",
"noUnusedFunctionParameters": "off",
"noInvalidUseBeforeDeclaration": "warn",
"useExhaustiveDependencies": "warn",
"useHookAtTopLevel": "warn"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"performance": {
"noImgElement": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

View File

@@ -1,18 +1,20 @@
"use client"; "use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { import {
Dialog, RiAddLine,
DialogContent, RiAlertLine,
DialogDescription, RiCheckLine,
DialogFooter, RiDeleteBinLine,
DialogHeader, RiFileCopyLine,
DialogTitle, RiSmartphoneLine,
DialogTrigger, } from "@remixicon/react";
} from "@/components/ui/dialog"; import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useState } from "react";
import {
createApiTokenAction,
revokeApiTokenAction,
} from "@/app/(dashboard)/ajustes/actions";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -24,18 +26,19 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
RiSmartphoneLine, Dialog,
RiDeleteBinLine, DialogContent,
RiAddLine, DialogDescription,
RiFileCopyLine, DialogFooter,
RiCheckLine, DialogHeader,
RiAlertLine, DialogTitle,
} from "@remixicon/react"; DialogTrigger,
import { formatDistanceToNow } from "date-fns"; } from "@/components/ui/dialog";
import { ptBR } from "date-fns/locale"; import { Input } from "@/components/ui/input";
import { createApiTokenAction, revokeApiTokenAction } from "@/app/(dashboard)/ajustes/actions"; import { Label } from "@/components/ui/label";
interface ApiToken { interface ApiToken {
id: string; id: string;
@@ -138,13 +141,17 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
<div> <div>
<h3 className="font-medium">Dispositivos conectados</h3> <h3 className="font-medium">Dispositivos conectados</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Gerencie os dispositivos que podem enviar notificações para o OpenSheets. Gerencie os dispositivos que podem enviar notificações para o
OpenSheets.
</p> </p>
</div> </div>
<Dialog open={isCreateOpen} onOpenChange={(open) => { <Dialog
open={isCreateOpen}
onOpenChange={(open) => {
if (!open) handleCloseCreate(); if (!open) handleCloseCreate();
else setIsCreateOpen(true); else setIsCreateOpen(true);
}}> }}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"> <Button size="sm">
<RiAddLine className="h-4 w-4 mr-1" /> <RiAddLine className="h-4 w-4 mr-1" />
@@ -157,7 +164,8 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
<DialogHeader> <DialogHeader>
<DialogTitle>Criar Token de API</DialogTitle> <DialogTitle>Criar Token de API</DialogTitle>
<DialogDescription> <DialogDescription>
Crie um token para conectar o OpenSheets Companion no seu dispositivo Android. Crie um token para conectar o OpenSheets Companion no seu
dispositivo Android.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
@@ -184,7 +192,10 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
<Button variant="outline" onClick={handleCloseCreate}> <Button variant="outline" onClick={handleCloseCreate}>
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleCreate} disabled={isCreating || !tokenName.trim()}> <Button
onClick={handleCreate}
disabled={isCreating || !tokenName.trim()}
>
{isCreating ? "Criando..." : "Criar Token"} {isCreating ? "Criando..." : "Criar Token"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -194,7 +205,8 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
<DialogHeader> <DialogHeader>
<DialogTitle>Token Criado</DialogTitle> <DialogTitle>Token Criado</DialogTitle>
<DialogDescription> <DialogDescription>
Copie o token abaixo e cole no app OpenSheets Companion. Este token Copie o token abaixo e cole no app OpenSheets Companion.
Este token
<strong> não será exibido novamente</strong>. <strong> não será exibido novamente</strong>.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -309,17 +321,22 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
)} )}
{/* Revoke Confirmation Dialog */} {/* Revoke Confirmation Dialog */}
<AlertDialog open={!!revokeId} onOpenChange={(open) => !open && setRevokeId(null)}> <AlertDialog
open={!!revokeId}
onOpenChange={(open) => !open && setRevokeId(null)}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Revogar token?</AlertDialogTitle> <AlertDialogTitle>Revogar token?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
O dispositivo associado a este token será desconectado e não poderá mais O dispositivo associado a este token será desconectado e não
enviar notificações. Esta ação não pode ser desfeita. poderá mais enviar notificações. Esta ação não pode ser desfeita.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isRevoking}>Cancelar</AlertDialogCancel> <AlertDialogCancel disabled={isRevoking}>
Cancelar
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleRevoke} onClick={handleRevoke}
disabled={isRevoking} disabled={isRevoking}

View File

@@ -1,4 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions"; import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -12,9 +15,6 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth/client"; import { authClient } from "@/lib/auth/client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
export function DeleteAccountForm() { export function DeleteAccountForm() {
const router = useRouter(); const router = useRouter();

View File

@@ -1,21 +1,18 @@
"use client"; "use client";
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface PreferencesFormProps { interface PreferencesFormProps {
disableMagnetlines: boolean; disableMagnetlines: boolean;
} }
export function PreferencesForm({ export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
disableMagnetlines,
}: PreferencesFormProps) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [magnetlinesDisabled, setMagnetlinesDisabled] = const [magnetlinesDisabled, setMagnetlinesDisabled] =
@@ -44,10 +41,7 @@ export function PreferencesForm({
}; };
return ( return (
<form <form onSubmit={handleSubmit} className="flex flex-col space-y-6">
onSubmit={handleSubmit}
className="flex flex-col space-y-6"
>
<div className="space-y-4 max-w-md"> <div className="space-y-4 max-w-md">
<div className="flex items-center justify-between rounded-lg border border-dashed p-4"> <div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">

View File

@@ -1,19 +1,27 @@
"use client"; "use client";
import {
RiCheckLine,
RiCloseLine,
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { updateEmailAction } from "@/app/(dashboard)/ajustes/actions"; import { updateEmailAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RiCheckLine, RiCloseLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react";
import { useState, useTransition, useMemo } from "react";
import { toast } from "sonner";
type UpdateEmailFormProps = { type UpdateEmailFormProps = {
currentEmail: string; currentEmail: string;
authProvider?: string; // 'google' | 'credential' | undefined authProvider?: string; // 'google' | 'credential' | undefined
}; };
export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormProps) { export function UpdateEmailForm({
currentEmail,
authProvider,
}: UpdateEmailFormProps) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [newEmail, setNewEmail] = useState(""); const [newEmail, setNewEmail] = useState("");
@@ -139,12 +147,19 @@ export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormP
aria-required="true" aria-required="true"
aria-describedby="new-email-help" aria-describedby="new-email-help"
aria-invalid={!isEmailDifferent} aria-invalid={!isEmailDifferent}
className={!isEmailDifferent ? "border-red-500 focus-visible:ring-red-500" : ""} className={
!isEmailDifferent
? "border-red-500 focus-visible:ring-red-500"
: ""
}
/> />
{!isEmailDifferent && newEmail && ( {!isEmailDifferent && newEmail && (
<p className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert"> <p
<RiCloseLine className="h-3.5 w-3.5" /> className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
O novo e-mail deve ser diferente do atual role="alert"
>
<RiCloseLine className="h-3.5 w-3.5" />O novo e-mail deve ser
diferente do atual
</p> </p>
)} )}
{!newEmail && ( {!newEmail && (
@@ -183,22 +198,35 @@ export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormP
{emailsMatch !== null && ( {emailsMatch !== null && (
<div className="absolute right-3 top-1/2 -translate-y-1/2"> <div className="absolute right-3 top-1/2 -translate-y-1/2">
{emailsMatch ? ( {emailsMatch ? (
<RiCheckLine className="h-5 w-5 text-green-500" aria-label="Os e-mails coincidem" /> <RiCheckLine
className="h-5 w-5 text-green-500"
aria-label="Os e-mails coincidem"
/>
) : ( ) : (
<RiCloseLine className="h-5 w-5 text-red-500" aria-label="Os e-mails não coincidem" /> <RiCloseLine
className="h-5 w-5 text-red-500"
aria-label="Os e-mails não coincidem"
/>
)} )}
</div> </div>
)} )}
</div> </div>
{/* Mensagem de erro em tempo real */} {/* Mensagem de erro em tempo real */}
{emailsMatch === false && ( {emailsMatch === false && (
<p id="confirm-email-help" className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1" role="alert"> <p
id="confirm-email-help"
className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1"
role="alert"
>
<RiCloseLine className="h-3.5 w-3.5" /> <RiCloseLine className="h-3.5 w-3.5" />
Os e-mails não coincidem Os e-mails não coincidem
</p> </p>
)} )}
{emailsMatch === true && ( {emailsMatch === true && (
<p id="confirm-email-help" className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"> <p
id="confirm-email-help"
className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
>
<RiCheckLine className="h-3.5 w-3.5" /> <RiCheckLine className="h-3.5 w-3.5" />
Os e-mails coincidem Os e-mails coincidem
</p> </p>

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { updateNameAction } from "@/app/(dashboard)/ajustes/actions"; import { updateNameAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useState, useTransition } from "react";
import { toast } from "sonner";
type UpdateNameFormProps = { type UpdateNameFormProps = {
currentName: string; currentName: string;

View File

@@ -1,19 +1,19 @@
"use client"; "use client";
import {
RiAlertLine,
RiCheckLine,
RiCloseLine,
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { updatePasswordAction } from "@/app/(dashboard)/ajustes/actions"; import { updatePasswordAction } from "@/app/(dashboard)/ajustes/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import {
RiEyeLine,
RiEyeOffLine,
RiCheckLine,
RiCloseLine,
RiAlertLine,
} from "@remixicon/react";
import { useState, useTransition, useMemo } from "react";
import { toast } from "sonner";
interface PasswordValidation { interface PasswordValidation {
hasLowercase: boolean; hasLowercase: boolean;
@@ -29,7 +29,7 @@ function validatePassword(password: string): PasswordValidation {
const hasLowercase = /[a-z]/.test(password); const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password); const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password); const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password); const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
const hasMinLength = password.length >= 7; const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23; const hasMaxLength = password.length <= 23;
@@ -55,7 +55,9 @@ function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
<div <div
className={cn( className={cn(
"flex items-center gap-1.5 text-xs transition-colors", "flex items-center gap-1.5 text-xs transition-colors",
met ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground" met
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)} )}
> >
{met ? ( {met ? (
@@ -93,7 +95,7 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
// Validação de requisitos da senha // Validação de requisitos da senha
const passwordValidation = useMemo( const passwordValidation = useMemo(
() => validatePassword(newPassword), () => validatePassword(newPassword),
[newPassword] [newPassword],
); );
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {

View File

@@ -1,4 +1,5 @@
"use client"; "use client";
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
@@ -8,7 +9,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
interface AnimatedThemeTogglerProps interface AnimatedThemeTogglerProps
extends React.ComponentPropsWithoutRef<"button"> { extends React.ComponentPropsWithoutRef<"button"> {
@@ -57,7 +57,7 @@ export const AnimatedThemeToggler = ({
const y = top + height / 2; const y = top + height / 2;
const maxRadius = Math.hypot( const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left), Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top) Math.max(top, window.innerHeight - top),
); );
document.documentElement.animate( document.documentElement.animate(
@@ -71,7 +71,7 @@ export const AnimatedThemeToggler = ({
duration, duration,
easing: "ease-in-out", easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)", pseudoElement: "::view-transition-new(root)",
} },
); );
}, [isDark, duration]); }, [isDark, duration]);
@@ -88,7 +88,7 @@ export const AnimatedThemeToggler = ({
"group relative text-muted-foreground transition-all duration-200", "group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40", "hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border", "data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
className className,
)} )}
{...props} {...props}
> >

View File

@@ -1,7 +1,5 @@
"use client"; "use client";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { import {
RiArchiveLine, RiArchiveLine,
RiCheckLine, RiCheckLine,
@@ -11,6 +9,8 @@ import {
RiPencilLine, RiPencilLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { useMemo } from "react"; import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import type { Note } from "./types"; import type { Note } from "./types";
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import { RiCheckLine } from "@remixicon/react";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -11,8 +13,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { RiCheckLine } from "@remixicon/react";
import { useMemo } from "react";
import { Card } from "../ui/card"; import { Card } from "../ui/card";
import type { Note } from "./types"; import type { Note } from "./types";

View File

@@ -1,5 +1,15 @@
"use client"; "use client";
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import { import {
createNoteAction, createNoteAction,
updateNoteAction, updateNoteAction,
@@ -20,16 +30,6 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useControlledState } from "@/hooks/use-controlled-state"; import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state"; import { useFormState } from "@/hooks/use-form-state";
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import { Card } from "../ui/card"; import { Card } from "../ui/card";
import type { Note, NoteFormValues, Task } from "./types"; import type { Note, NoteFormValues, Task } from "./types";
@@ -76,7 +76,7 @@ export function NoteDialog({
const [dialogOpen, setDialogOpen] = useControlledState( const [dialogOpen, setDialogOpen] = useControlledState(
open, open,
false, false,
onOpenChange onOpenChange,
); );
const initialState = buildInitialValues(note); const initialState = buildInitialValues(note);
@@ -126,7 +126,7 @@ export function NoteDialog({
setDialogOpen(v); setDialogOpen(v);
if (!v) setErrorMessage(null); if (!v) setErrorMessage(null);
}, },
[setDialogOpen] [setDialogOpen],
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
@@ -135,7 +135,7 @@ export function NoteDialog({
(e.currentTarget as HTMLFormElement).requestSubmit(); (e.currentTarget as HTMLFormElement).requestSubmit();
if (e.key === "Escape") handleOpenChange(false); if (e.key === "Escape") handleOpenChange(false);
}, },
[handleOpenChange] [handleOpenChange],
); );
const handleAddTask = useCallback(() => { const handleAddTask = useCallback(() => {
@@ -157,10 +157,10 @@ export function NoteDialog({
(taskId: string) => { (taskId: string) => {
updateField( updateField(
"tasks", "tasks",
(formState.tasks || []).filter((t) => t.id !== taskId) (formState.tasks || []).filter((t) => t.id !== taskId),
); );
}, },
[formState.tasks, updateField] [formState.tasks, updateField],
); );
const handleToggleTask = useCallback( const handleToggleTask = useCallback(
@@ -168,11 +168,11 @@ export function NoteDialog({
updateField( updateField(
"tasks", "tasks",
(formState.tasks || []).map((t) => (formState.tasks || []).map((t) =>
t.id === taskId ? { ...t, completed: !t.completed } : t t.id === taskId ? { ...t, completed: !t.completed } : t,
) ),
); );
}, },
[formState.tasks, updateField] [formState.tasks, updateField],
); );
const handleSubmit = useCallback( const handleSubmit = useCallback(
@@ -240,7 +240,7 @@ export function NoteDialog({
onlySpaces, onlySpaces,
unchanged, unchanged,
invalidLen, invalidLen,
] ],
); );
return ( return (

View File

@@ -1,12 +1,15 @@
"use client"; "use client";
import { arquivarAnotacaoAction, deleteNoteAction } from "@/app/(dashboard)/anotacoes/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button";
import { RiAddCircleLine, RiTodoLine } from "@remixicon/react"; import { RiAddCircleLine, RiTodoLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import {
arquivarAnotacaoAction,
deleteNoteAction,
} from "@/app/(dashboard)/anotacoes/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button";
import { Card } from "../ui/card"; import { Card } from "../ui/card";
import { NoteCard } from "./note-card"; import { NoteCard } from "./note-card";
import { NoteDetailsDialog } from "./note-details-dialog"; import { NoteDetailsDialog } from "./note-details-dialog";
@@ -33,9 +36,9 @@ export function NotesPage({ notes, isArquivadas = false }: NotesPageProps) {
() => () =>
[...notes].sort( [...notes].sort(
(a, b) => (a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
), ),
[notes] [notes],
); );
const handleCreateOpenChange = useCallback((open: boolean) => { const handleCreateOpenChange = useCallback((open: boolean) => {

View File

@@ -1,5 +1,5 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { RiTerminalLine } from "@remixicon/react"; import { RiTerminalLine } from "@remixicon/react";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface AuthErrorAlertProps { interface AuthErrorAlertProps {
error: string; error: string;

View File

@@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button";
import { RiLoader4Line } from "@remixicon/react"; import { RiLoader4Line } from "@remixicon/react";
import { Button } from "@/components/ui/button";
interface GoogleAuthButtonProps { interface GoogleAuthButtonProps {
onClick: () => void; onClick: () => void;

View File

@@ -1,4 +1,8 @@
"use client"; "use client";
import { RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { type FormEvent, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
@@ -11,10 +15,6 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { authClient, googleSignInAvailable } from "@/lib/auth/client"; import { authClient, googleSignInAvailable } from "@/lib/auth/client";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { toast } from "sonner";
import { Logo } from "../logo"; import { Logo } from "../logo";
import { AuthErrorAlert } from "./auth-error-alert"; import { AuthErrorAlert } from "./auth-error-alert";
import { AuthHeader } from "./auth-header"; import { AuthHeader } from "./auth-header";
@@ -55,14 +55,19 @@ export function LoginForm({ className, ...props }: DivProps) {
router.replace("/dashboard"); router.replace("/dashboard");
}, },
onError: (ctx) => { onError: (ctx) => {
if (ctx.error.status === 500 && ctx.error.statusText === "Internal Server Error") { if (
toast.error("Ocorreu uma falha na requisição. Tente novamente mais tarde."); ctx.error.status === 500 &&
ctx.error.statusText === "Internal Server Error"
) {
toast.error(
"Ocorreu uma falha na requisição. Tente novamente mais tarde.",
);
} }
setError(ctx.error.message); setError(ctx.error.message);
setLoadingEmail(false); setLoadingEmail(false);
}, },
} },
); );
} }
@@ -88,7 +93,7 @@ export function LoginForm({ className, ...props }: DivProps) {
setError(ctx.error.message); setError(ctx.error.message);
setLoadingGoogle(false); setLoadingGoogle(false);
}, },
} },
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
@@ -8,7 +8,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { authClient } from "@/lib/auth/client"; import { authClient } from "@/lib/auth/client";
import { useRouter } from "next/navigation";
import { Spinner } from "../ui/spinner"; import { Spinner } from "../ui/spinner";
export default function LogoutButton() { export default function LogoutButton() {

View File

@@ -1,4 +1,8 @@
"use client"; "use client";
import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { type FormEvent, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
@@ -11,16 +15,11 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { authClient, googleSignInAvailable } from "@/lib/auth/client"; import { authClient, googleSignInAvailable } from "@/lib/auth/client";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useState, useMemo, type FormEvent } from "react";
import { toast } from "sonner";
import { Logo } from "../logo"; import { Logo } from "../logo";
import { AuthErrorAlert } from "./auth-error-alert"; import { AuthErrorAlert } from "./auth-error-alert";
import { AuthHeader } from "./auth-header"; import { AuthHeader } from "./auth-header";
import AuthSidebar from "./auth-sidebar"; import AuthSidebar from "./auth-sidebar";
import { GoogleAuthButton } from "./google-auth-button"; import { GoogleAuthButton } from "./google-auth-button";
import { RiCheckLine, RiCloseLine } from "@remixicon/react";
interface PasswordValidation { interface PasswordValidation {
hasLowercase: boolean; hasLowercase: boolean;
@@ -36,7 +35,7 @@ function validatePassword(password: string): PasswordValidation {
const hasLowercase = /[a-z]/.test(password); const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password); const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password); const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password); const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
const hasMinLength = password.length >= 7; const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23; const hasMaxLength = password.length <= 23;
@@ -57,18 +56,14 @@ function validatePassword(password: string): PasswordValidation {
}; };
} }
function PasswordRequirement({ function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
met,
label,
}: {
met: boolean;
label: string;
}) {
return ( return (
<div <div
className={cn( className={cn(
"flex items-center gap-1.5 text-xs transition-colors", "flex items-center gap-1.5 text-xs transition-colors",
met ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground" met
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)} )}
> >
{met ? ( {met ? (
@@ -97,7 +92,7 @@ export function SignupForm({ className, ...props }: DivProps) {
const passwordValidation = useMemo( const passwordValidation = useMemo(
() => validatePassword(password), () => validatePassword(password),
[password] [password],
); );
async function handleSubmit(e: FormEvent<HTMLFormElement>) { async function handleSubmit(e: FormEvent<HTMLFormElement>) {
@@ -128,7 +123,7 @@ export function SignupForm({ className, ...props }: DivProps) {
setError(ctx.error.message); setError(ctx.error.message);
setLoadingEmail(false); setLoadingEmail(false);
}, },
} },
); );
} }
@@ -154,7 +149,7 @@ export function SignupForm({ className, ...props }: DivProps) {
setError(ctx.error.message); setError(ctx.error.message);
setLoadingGoogle(false); setLoadingGoogle(false);
}, },
} },
); );
} }
@@ -211,7 +206,10 @@ export function SignupForm({ className, ...props }: DivProps) {
placeholder="Crie uma senha forte" placeholder="Crie uma senha forte"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!error || (password.length > 0 && !passwordValidation.isValid)} aria-invalid={
!!error ||
(password.length > 0 && !passwordValidation.isValid)
}
maxLength={23} maxLength={23}
/> />
{password.length > 0 && ( {password.length > 0 && (
@@ -247,7 +245,11 @@ export function SignupForm({ className, ...props }: DivProps) {
<Field> <Field>
<Button <Button
type="submit" type="submit"
disabled={loadingEmail || loadingGoogle || (password.length > 0 && !passwordValidation.isValid)} disabled={
loadingEmail ||
loadingGoogle ||
(password.length > 0 && !passwordValidation.isValid)
}
className="w-full" className="w-full"
> >
{loadingEmail ? ( {loadingEmail ? (

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import { RiCalculatorFill, RiCalculatorLine } from "@remixicon/react";
import * as React from "react";
import Calculator from "@/components/calculadora/calculator"; import Calculator from "@/components/calculadora/calculator";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { import {
@@ -15,8 +17,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { RiCalculatorFill, RiCalculatorLine } from "@remixicon/react";
import * as React from "react";
type Variant = React.ComponentProps<typeof Button>["variant"]; type Variant = React.ComponentProps<typeof Button>["variant"];
type Size = React.ComponentProps<typeof Button>["size"]; type Size = React.ComponentProps<typeof Button>["size"];
@@ -55,13 +55,13 @@ export function CalculatorDialogButton({
"group relative text-muted-foreground transition-all duration-200", "group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40", "hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border", "data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
className className,
)} )}
> >
<RiCalculatorLine <RiCalculatorLine
className={cn( className={cn(
"size-4 transition-transform duration-200", "size-4 transition-transform duration-200",
open ? "scale-90" : "scale-100" open ? "scale-90" : "scale-100",
)} )}
/> />
<span className="sr-only">Calculadora</span> <span className="sr-only">Calculadora</span>

View File

@@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button";
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react"; import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
import { Button } from "@/components/ui/button";
export type CalculatorDisplayProps = { export type CalculatorDisplayProps = {
history: string | null; history: string | null;

View File

@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { CalculatorButtonConfig } from "@/hooks/use-calculator-state";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { type CalculatorButtonConfig } from "@/hooks/use-calculator-state";
type CalculatorKeypadProps = { type CalculatorKeypadProps = {
buttons: CalculatorButtonConfig[][]; buttons: CalculatorButtonConfig[][];

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { cn } from "@/lib/utils/ui"; import { DayCell } from "@/components/calendario/day-cell";
import type { CalendarDay } from "@/components/calendario/types"; import type { CalendarDay } from "@/components/calendario/types";
import { WEEK_DAYS_SHORT } from "@/components/calendario/utils"; import { WEEK_DAYS_SHORT } from "@/components/calendario/utils";
import { DayCell } from "@/components/calendario/day-cell"; import { cn } from "@/lib/utils/ui";
type CalendarGridProps = { type CalendarGridProps = {
days: CalendarDay[]; days: CalendarDay[];

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { RiAddLine } from "@remixicon/react";
import type { KeyboardEvent, MouseEvent } from "react";
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types"; import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers"; import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { RiAddLine } from "@remixicon/react";
import type { KeyboardEvent, MouseEvent } from "react";
type DayCellProps = { type DayCellProps = {
day: CalendarDay; day: CalendarDay;
@@ -103,7 +103,7 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
<div <div
className={cn( className={cn(
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs", "flex w-full items-center justify-between gap-2 rounded p-1 text-xs",
style.wrapper style.wrapper,
)} )}
> >
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
@@ -145,7 +145,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
className={cn( className={cn(
"flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10", "flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10",
!day.isCurrentMonth && "opacity-60", !day.isCurrentMonth && "opacity-60",
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary" day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
)} )}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@@ -154,7 +154,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
"text-sm font-semibold leading-none", "text-sm font-semibold leading-none",
day.isToday day.isToday
? "text-orange-100 bg-primary size-5 rounded-full flex items-center justify-center" ? "text-orange-100 bg-primary size-5 rounded-full flex items-center justify-center"
: "text-foreground/90" : "text-foreground/90",
)} )}
> >
{day.label} {day.label}

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import type { ReactNode } from "react";
import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell"; import { EVENT_TYPE_STYLES } from "@/components/calendario/day-cell";
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types"; import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -13,7 +14,6 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { friendlyDate, parseLocalDateString } from "@/lib/utils/date"; import { friendlyDate, parseLocalDateString } from "@/lib/utils/date";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import type { ReactNode } from "react";
import MoneyValues from "../money-values"; import MoneyValues from "../money-values";
import { Badge } from "../ui/badge"; import { Badge } from "../ui/badge";
import { Card } from "../ui/card"; import { Card } from "../ui/card";
@@ -49,7 +49,7 @@ const EventCard = ({
}; };
const renderLancamento = ( const renderLancamento = (
event: Extract<CalendarEvent, { type: "lancamento" }> event: Extract<CalendarEvent, { type: "lancamento" }>,
) => { ) => {
const isReceita = event.lancamento.transactionType === "Receita"; const isReceita = event.lancamento.transactionType === "Receita";
const isPagamentoFatura = const isPagamentoFatura =
@@ -76,7 +76,9 @@ const renderLancamento = (
<span <span
className={cn( className={cn(
"text-sm font-semibold whitespace-nowrap", "text-sm font-semibold whitespace-nowrap",
isReceita ? "text-green-600 dark:text-green-400" : "text-foreground" isReceita
? "text-green-600 dark:text-green-400"
: "text-foreground",
)} )}
> >
<MoneyValues <MoneyValues

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