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

View File

@@ -1,14 +1,14 @@
"use server";
import { apiTokens, pagadores } from "@/db/schema";
import { auth } from "@/lib/auth/config";
import { db, schema } from "@/lib/db";
import { apiTokens, pagadores } from "@/db/schema";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { eq, and, ne, isNull } from "drizzle-orm";
import { headers } from "next/headers";
import { and, eq, isNull, ne } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { createHash, randomBytes } from "node:crypto";
import { z } from "zod";
import { createHash, randomBytes } from "crypto";
type ActionResponse<T = void> = {
success: boolean;
@@ -58,7 +58,7 @@ const updatePreferencesSchema = z.object({
// Actions
export async function updateNameAction(
data: z.infer<typeof updateNameSchema>
data: z.infer<typeof updateNameSchema>,
): Promise<ActionResponse> {
try {
const session = await auth.api.getSession({
@@ -88,8 +88,8 @@ export async function updateNameAction(
.where(
and(
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
@@ -117,7 +117,7 @@ export async function updateNameAction(
}
export async function updatePasswordAction(
data: z.infer<typeof updatePasswordSchema>
data: z.infer<typeof updatePasswordSchema>,
): Promise<ActionResponse> {
try {
const session = await auth.api.getSession({
@@ -137,14 +137,15 @@ export async function updatePasswordAction(
const userAccount = await db.query.account.findFirst({
where: and(
eq(schema.account.userId, session.user.id),
eq(schema.account.providerId, "google")
eq(schema.account.providerId, "google"),
),
});
if (userAccount) {
return {
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);
// 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 {
success: false,
error: "Senha atual incorreta",
@@ -175,7 +179,8 @@ export async function updatePasswordAction(
return {
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) {
@@ -195,7 +200,7 @@ export async function updatePasswordAction(
}
export async function updateEmailAction(
data: z.infer<typeof updateEmailSchema>
data: z.infer<typeof updateEmailSchema>,
): Promise<ActionResponse> {
try {
const session = await auth.api.getSession({
@@ -215,7 +220,7 @@ export async function updateEmailAction(
const userAccount = await db.query.account.findFirst({
where: and(
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({
where: and(
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(
data: z.infer<typeof deleteAccountSchema>
data: z.infer<typeof deleteAccountSchema>,
): Promise<ActionResponse> {
try {
const session = await auth.api.getSession({
@@ -349,7 +354,7 @@ export async function deleteAccountAction(
}
export async function updatePreferencesAction(
data: z.infer<typeof updatePreferencesSchema>
data: z.infer<typeof updatePreferencesSchema>,
): Promise<ActionResponse> {
try {
const session = await auth.api.getSession({
@@ -435,7 +440,7 @@ function hashToken(token: string): string {
}
export async function createApiTokenAction(
data: z.infer<typeof createApiTokenSchema>
data: z.infer<typeof createApiTokenSchema>,
): Promise<ActionResponse<{ token: string; tokenId: string }>> {
try {
const session = await auth.api.getSession({
@@ -495,7 +500,7 @@ export async function createApiTokenAction(
}
export async function revokeApiTokenAction(
data: z.infer<typeof revokeApiTokenSchema>
data: z.infer<typeof revokeApiTokenSchema>,
): Promise<ActionResponse> {
try {
const session = await auth.api.getSession({
@@ -519,8 +524,8 @@ export async function revokeApiTokenAction(
and(
eq(apiTokens.id, validated.tokenId),
eq(apiTokens.userId, session.user.id),
isNull(apiTokens.revokedAt)
)
isNull(apiTokens.revokedAt),
),
)
.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 PageDescription from "@/components/page-description";
export const metadata = {
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 { DeleteAccountForm } from "@/components/ajustes/delete-account-form";
import { PreferencesForm } from "@/components/ajustes/preferences-form";
import { UpdateEmailForm } from "@/components/ajustes/update-email-form";
import { UpdateNameForm } from "@/components/ajustes/update-name-form";
import { UpdatePasswordForm } from "@/components/ajustes/update-password-form";
import { PreferencesForm } from "@/components/ajustes/preferences-form";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { auth } from "@/lib/auth/config";
import { db, schema } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { fetchAjustesPageData } from "./data";
export default async function Page() {
const session = await auth.api.getSession({
@@ -25,40 +25,8 @@ export default async function Page() {
const userName = session.user.name || "";
const userEmail = session.user.email || "";
// Detectar método de autenticação (Google OAuth vs E-mail/Senha)
const userAccount = await db.query.account.findFirst({
where: eq(schema.account.userId, session.user.id),
});
// 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));
const { authProvider, userPreferences, userApiTokens } =
await fetchAjustesPageData(session.user.id);
return (
<div className="w-full">

View File

@@ -1,13 +1,13 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { anotacoes } from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { uuidSchema } from "@/lib/schemas/common";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { uuidSchema } from "@/lib/schemas/common";
const taskSchema = z.object({
id: z.string(),
@@ -15,7 +15,8 @@ const taskSchema = z.object({
completed: z.boolean(),
});
const noteBaseSchema = z.object({
const noteBaseSchema = z
.object({
title: z
.string({ message: "Informe o título da anotação." })
.trim()
@@ -31,7 +32,8 @@ const noteBaseSchema = z.object({
message: "O tipo deve ser 'nota' ou 'tarefa'.",
}),
tasks: z.array(taskSchema).optional().default([]),
}).refine(
})
.refine(
(data) => {
// Se for nota, a descrição é obrigatória
if (data.type === "nota") {
@@ -44,14 +46,17 @@ const noteBaseSchema = z.object({
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 updateNoteSchema = noteBaseSchema.and(z.object({
const updateNoteSchema = noteBaseSchema.and(
z.object({
id: uuidSchema("Anotação"),
}));
}),
);
const deleteNoteSchema = z.object({
id: uuidSchema("Anotação"),
});
@@ -61,7 +66,7 @@ type NoteUpdateInput = z.infer<typeof updateNoteSchema>;
type NoteDeleteInput = z.infer<typeof deleteNoteSchema>;
export async function createNoteAction(
input: NoteCreateInput
input: NoteCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -71,7 +76,8 @@ export async function createNoteAction(
title: data.title,
description: data.description,
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,
});
@@ -84,7 +90,7 @@ export async function createNoteAction(
}
export async function updateNoteAction(
input: NoteUpdateInput
input: NoteUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -96,7 +102,10 @@ export async function updateNoteAction(
title: data.title,
description: data.description,
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)))
.returning({ id: anotacoes.id });
@@ -117,7 +126,7 @@ export async function updateNoteAction(
}
export async function deleteNoteAction(
input: NoteDeleteInput
input: NoteDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -151,7 +160,7 @@ const arquivarNoteSchema = z.object({
type NoteArquivarInput = z.infer<typeof arquivarNoteSchema>;
export async function arquivarAnotacaoAction(
input: NoteArquivarInput
input: NoteArquivarInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -178,7 +187,7 @@ export async function arquivarAnotacaoAction(
success: true,
message: data.arquivada
? "Anotação arquivada com sucesso."
: "Anotação desarquivada com sucesso."
: "Anotação desarquivada com sucesso.",
};
} catch (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 { type Anotacao, anotacoes } from "@/db/schema";
import { db } from "@/lib/db";
export type Task = {
id: string;
@@ -21,7 +21,10 @@ export type NoteData = {
export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({
where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)),
orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)],
orderBy: (
note: typeof anotacoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(note.createdAt)],
});
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({
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) => {

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiTodoLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Anotações | Opensheets",

View File

@@ -17,10 +17,7 @@ export default function AnotacoesLoading() {
{/* Grid de cards de notas */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border p-4 space-y-3"
>
<div key={i} className="rounded-2xl border p-4 space-y-3">
{/* Título */}
<Skeleton className="h-6 w-3/4 rounded-2xl bg-foreground/10" />

View File

@@ -1,4 +1,9 @@
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import type {
CalendarData,
CalendarEvent,
} from "@/components/calendario/types";
import { cartoes, lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
import {
@@ -8,12 +13,6 @@ import {
mapLancamentosData,
} from "@/lib/lancamentos/page-helpers";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, gte, lte, ne, or } from "drizzle-orm";
import type {
CalendarData,
CalendarEvent,
} from "@/components/calendario/types";
const PAYMENT_METHOD_BOLETO = "Boleto";
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
@@ -59,8 +58,7 @@ export const fetchCalendarData = async ({
const rangeStartKey = toDateKey(rangeStart);
const rangeEndKey = toDateKey(rangeEnd);
const [lancamentoRows, cardRows, filterSources] =
await Promise.all([
const [lancamentoRows, cardRows, filterSources] = await Promise.all([
db.query.lancamentos.findMany({
where: and(
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
and(
gte(lancamentos.purchaseDate, rangeStart),
lte(lancamentos.purchaseDate, rangeEnd)
lte(lancamentos.purchaseDate, rangeEnd),
),
// Boletos cuja data de vencimento esteja no período do calendário
and(
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
gte(lancamentos.dueDate, rangeStart),
lte(lancamentos.dueDate, rangeEnd)
lte(lancamentos.dueDate, rangeEnd),
),
// Lançamentos de cartão do período (para calcular totais de vencimento)
and(
eq(lancamentos.period, period),
ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO)
)
)
ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
),
),
),
with: {
pagador: true,
@@ -112,7 +110,7 @@ export const fetchCalendarData = async ({
const amount = Math.abs(item.amount ?? 0);
cardTotals.set(
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 dueDateKey = toDateKey(
new Date(Date.UTC(year, monthIndex, normalizedDay))
new Date(Date.UTC(year, monthIndex, normalizedDay)),
);
events.push({

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiCalendarEventLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
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 { getUserId } from "@/lib/auth/server";
import {
@@ -5,10 +7,7 @@ import {
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { parsePeriodParam } from "@/lib/utils/period";
import { MonthlyCalendar } from "@/components/calendario/monthly-calendar";
import { fetchCalendarData } from "./data";
import type { CalendarPeriod } from "@/components/calendario/types";
type PageSearchParams = Promise<ResolvedSearchParams>;

View File

@@ -1,5 +1,8 @@
"use server";
import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
cartoes,
categorias,
@@ -8,29 +11,24 @@ import {
pagadores,
} from "@/db/schema";
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
PERIOD_FORMAT_REGEX,
type InvoicePaymentStatus,
PERIOD_FORMAT_REGEX,
} from "@/lib/faturas";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { parseLocalDateString } from "@/lib/utils/date";
import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const updateInvoicePaymentStatusSchema = z.object({
cartaoId: z
.string({ message: "Cartão inválido." })
.uuid("Cartão inválido."),
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
status: z.enum(
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]]
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
),
paymentDate: z.string().optional(),
});
@@ -52,7 +50,7 @@ const formatDecimal = (value: number) =>
(Math.round(value * 100) / 100).toFixed(2);
export async function updateInvoicePaymentStatusAction(
input: UpdateInvoicePaymentStatusInput
input: UpdateInvoicePaymentStatusInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -75,7 +73,7 @@ export async function updateInvoicePaymentStatusAction(
where: and(
eq(faturas.cartaoId, data.cartaoId),
eq(faturas.userId, user.id),
eq(faturas.period, data.period)
eq(faturas.period, data.period),
),
});
@@ -104,8 +102,8 @@ export async function updateInvoicePaymentStatusAction(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period)
)
eq(lancamentos.period, data.period),
),
);
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
@@ -132,8 +130,8 @@ export async function updateInvoicePaymentStatusAction(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
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));
@@ -143,7 +141,7 @@ export async function updateInvoicePaymentStatusAction(
columns: { id: true },
where: and(
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 },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, "Pagamentos")
eq(categorias.name, "Pagamentos"),
),
});
@@ -182,7 +180,7 @@ export async function updateInvoicePaymentStatusAction(
columns: { id: true },
where: and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote)
eq(lancamentos.note, invoiceNote),
),
});
@@ -202,8 +200,8 @@ export async function updateInvoicePaymentStatusAction(
.where(
and(
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({
cartaoId: z
.string({ message: "Cartão inválido." })
.uuid("Cartão inválido."),
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
@@ -241,7 +237,7 @@ const updatePaymentDateSchema = z.object({
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;
export async function updatePaymentDateAction(
input: UpdatePaymentDateInput
input: UpdatePaymentDateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -263,7 +259,7 @@ export async function updatePaymentDateAction(
columns: { id: true },
where: and(
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 { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
@@ -5,7 +6,6 @@ import {
INVOICE_PAYMENT_STATUS,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { and, eq, sum } from "drizzle-orm";
const toNumber = (value: string | number | null | undefined) => {
if (typeof value === "number") {
@@ -41,7 +41,7 @@ export async function fetchCardData(userId: string, cartaoId: string) {
export async function fetchInvoiceData(
userId: string,
cartaoId: string,
selectedPeriod: string
selectedPeriod: string,
): Promise<{
totalAmount: number;
invoiceStatus: InvoicePaymentStatus;
@@ -57,7 +57,7 @@ export async function fetchInvoiceData(
where: and(
eq(faturas.cartaoId, cartaoId),
eq(faturas.userId, userId),
eq(faturas.period, selectedPeriod)
eq(faturas.period, selectedPeriod),
),
}),
db
@@ -67,14 +67,14 @@ export async function fetchInvoiceData(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cartaoId),
eq(lancamentos.period, selectedPeriod)
)
eq(lancamentos.period, selectedPeriod),
),
),
]);
const totalAmount = toNumber(totalRow[0]?.totalAmount);
const isInvoiceStatus = (
value: string | null | undefined
value: string | null | undefined,
): value is InvoicePaymentStatus =>
!!value && ["pendente", "pago"].includes(value);
@@ -92,7 +92,7 @@ export async function fetchInvoiceData(
},
where: and(
eq(lancamentos.userId, userId),
eq(lancamentos.note, invoiceNote)
eq(lancamentos.note, invoiceNote),
),
});
paymentDate = paymentLancamento?.purchaseDate
@@ -102,3 +102,16 @@ export async function fetchInvoiceData(
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 { CardDialog } from "@/components/cartoes/card-dialog";
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 MonthNavigation from "@/components/month-picker/month-navigation";
import { Button } from "@/components/ui/button";
import { lancamentos, type Conta } from "@/db/schema";
import { db } from "@/lib/db";
import type { Conta } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
@@ -21,10 +22,7 @@ import {
} from "@/lib/lancamentos/page-helpers";
import { loadLogoOptions } from "@/lib/logo/options";
import { parsePeriodParam } from "@/lib/utils/period";
import { RiPencilLine } from "@remixicon/react";
import { and, desc } from "drizzle-orm";
import { notFound } from "next/navigation";
import { fetchCardData, fetchInvoiceData } from "./data";
import { fetchCardData, fetchCardLancamentos, fetchInvoiceData } from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;
@@ -53,12 +51,8 @@ export default async function Page({ params, searchParams }: PageProps) {
notFound();
}
const [
filterSources,
logoOptions,
invoiceData,
estabelecimentos,
] = await Promise.all([
const [filterSources, logoOptions, invoiceData, estabelecimentos] =
await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchInvoiceData(userId, cartaoId, selectedPeriod),
@@ -75,16 +69,7 @@ export default async function Page({ params, searchParams }: PageProps) {
cardId: card.id,
});
const lancamentoRows = await db.query.lancamentos.findMany({
where: and(...filters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
const lancamentoRows = await fetchCardLancamentos(filters);
const lancamentosData = mapLancamentosData(lancamentoRows);
@@ -137,7 +122,7 @@ export default async function Page({ params, searchParams }: PageProps) {
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
1
1,
)} de ${year}`;
return (

View File

@@ -1,8 +1,15 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { cartoes, contas } from "@/db/schema";
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
import { revalidateForEntity } from "@/lib/actions/helpers";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
dayOfMonthSchema,
noteSchema,
@@ -11,10 +18,6 @@ import {
} from "@/lib/schemas/common";
import { formatDecimalForDb } from "@/lib/utils/currency";
import { normalizeFilePath } from "@/lib/utils/string";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
const cardBaseSchema = z.object({
name: z
@@ -64,7 +67,7 @@ async function assertAccountOwnership(userId: string, contaId: string) {
}
export async function createCardAction(
input: CardCreateInput
input: CardCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -96,7 +99,7 @@ export async function createCardAction(
}
export async function updateCardAction(
input: CardUpdateInput
input: CardUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -138,7 +141,7 @@ export async function updateCardAction(
}
export async function deleteCardAction(
input: CardDeleteInput
input: CardDeleteInput,
): Promise<ActionResult> {
try {
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([
db.query.cartoes.findMany({
orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)],
where: and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))),
orderBy: (
card: typeof cartoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(card.name)],
where: and(
eq(cartoes.userId, userId),
not(ilike(cartoes.status, "inativo")),
),
with: {
conta: {
columns: {
@@ -44,7 +50,10 @@ export async function fetchCardsForUser(userId: string): Promise<{
},
}),
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),
columns: {
id: true,
@@ -62,17 +71,19 @@ export async function fetchCardsForUser(userId: string): Promise<{
.where(
and(
eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false))
)
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
),
)
.groupBy(lancamentos.cartaoId),
]);
const usageMap = new Map<string, number>();
usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => {
usageRows.forEach(
(row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0));
});
},
);
const cards = cardRows.map((card) => ({
id: card.id,
@@ -116,7 +127,10 @@ export async function fetchInativosForUser(userId: string): Promise<{
}> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({
orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)],
orderBy: (
card: typeof cartoes.$inferSelect,
{ desc }: { desc: (field: unknown) => unknown },
) => [desc(card.name)],
where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")),
with: {
conta: {
@@ -128,7 +142,10 @@ export async function fetchInativosForUser(userId: string): Promise<{
},
}),
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),
columns: {
id: true,
@@ -146,17 +163,19 @@ export async function fetchInativosForUser(userId: string): Promise<{
.where(
and(
eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false))
)
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)),
),
)
.groupBy(lancamentos.cartaoId),
]);
const usageMap = new Map<string, number>();
usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => {
usageRows.forEach(
(row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0));
});
},
);
const cards = cardRows.map((card) => ({
id: card.id,

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiBankCard2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Cartões | Opensheets",

View File

@@ -1,8 +1,5 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state para a página de cartões
*/
export default function CartoesLoading() {
return (
<main className="flex flex-col gap-6">

View File

@@ -1,16 +1,16 @@
import { notFound } from "next/navigation";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { CategoryDetailHeader } from "@/components/categorias/category-detail-header";
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details";
import { getUserId } from "@/lib/auth/server";
import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details";
import {
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
} from "@/lib/lancamentos/page-helpers";
import { displayPeriod, parsePeriodParam } from "@/lib/utils/period";
import { notFound } from "next/navigation";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -21,11 +21,11 @@ type PageProps = {
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string
key: string,
) => {
const value = params?.[key];
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) {
@@ -36,8 +36,7 @@ export default async function Page({ params, searchParams }: PageProps) {
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [detail, filterSources, estabelecimentos] =
await Promise.all([
const [detail, filterSources, estabelecimentos] = await Promise.all([
fetchCategoryDetails(userId, categoryId, selectedPeriod),
fetchLancamentoFilterSources(userId),
getRecentEstablishmentsAction(),

View File

@@ -1,5 +1,7 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { categorias } from "@/db/schema";
import {
type ActionResult,
@@ -11,8 +13,6 @@ import { CATEGORY_TYPES } from "@/lib/categorias/constants";
import { db } from "@/lib/db";
import { uuidSchema } from "@/lib/schemas/common";
import { normalizeIconInput } from "@/lib/utils/string";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
const categoryBaseSchema = z.object({
name: z
@@ -43,7 +43,7 @@ type CategoryUpdateInput = z.infer<typeof updateCategorySchema>;
type CategoryDeleteInput = z.infer<typeof deleteCategorySchema>;
export async function createCategoryAction(
input: CategoryCreateInput
input: CategoryCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -65,7 +65,7 @@ export async function createCategoryAction(
}
export async function updateCategoryAction(
input: CategoryUpdateInput
input: CategoryUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -123,7 +123,7 @@ export async function updateCategoryAction(
}
export async function deleteCategoryAction(
input: CategoryDeleteInput
input: CategoryDeleteInput,
): Promise<ActionResult> {
try {
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 type { CategoryType } from "@/components/categorias/types";
import { type Categoria, categorias } from "@/db/schema";
import { db } from "@/lib/db";
export type CategoryData = {
id: string;
@@ -11,7 +11,7 @@ export type CategoryData = {
};
export async function fetchCategoriesForUser(
userId: string
userId: string,
): Promise<CategoryData[]> {
const categoryRows = await db.query.categorias.findMany({
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 { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiPriceTag3Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Categorias | Opensheets",

View File

@@ -28,10 +28,7 @@ export default function CategoriasLoading() {
{/* Grid de cards de categorias */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border p-6 space-y-4"
>
<div key={i} className="rounded-2xl border p-6 space-y-4">
{/* Ícone + Nome */}
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-2xl bg-foreground/10" />

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 { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, lt, sql } from "drizzle-orm";
export type AccountSummaryData = {
openingBalance: number;
@@ -31,7 +31,7 @@ export async function fetchAccountData(userId: string, contaId: string) {
export async function fetchAccountSummary(
userId: string,
contaId: string,
selectedPeriod: string
selectedPeriod: string,
): Promise<AccountSummaryData> {
const [periodSummary] = await db
.select({
@@ -79,8 +79,8 @@ export async function fetchAccountSummary(
eq(lancamentos.contaId, contaId),
eq(lancamentos.period, selectedPeriod),
eq(lancamentos.isSettled, true),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
)
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
);
const [previousRow] = await db
@@ -105,8 +105,8 @@ export async function fetchAccountSummary(
eq(lancamentos.contaId, contaId),
lt(lancamentos.period, selectedPeriod),
eq(lancamentos.isSettled, true),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
)
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
);
const account = await fetchAccountData(userId, contaId);
@@ -129,3 +129,23 @@ export async function fetchAccountSummary(
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 { AccountDialog } from "@/components/contas/account-dialog";
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 MonthNavigation from "@/components/month-picker/month-navigation";
import { Button } from "@/components/ui/button";
import { lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
@@ -21,10 +21,11 @@ import {
} from "@/lib/lancamentos/page-helpers";
import { loadLogoOptions } from "@/lib/logo/options";
import { parsePeriodParam } from "@/lib/utils/period";
import { RiPencilLine } from "@remixicon/react";
import { and, desc, eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { fetchAccountData, fetchAccountSummary } from "./data";
import {
fetchAccountData,
fetchAccountLancamentos,
fetchAccountSummary,
} from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;
@@ -56,12 +57,8 @@ export default async function Page({ params, searchParams }: PageProps) {
notFound();
}
const [
filterSources,
logoOptions,
accountSummary,
estabelecimentos,
] = await Promise.all([
const [filterSources, logoOptions, accountSummary, estabelecimentos] =
await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchAccountSummary(userId, contaId, selectedPeriod),
@@ -78,18 +75,7 @@ export default async function Page({ params, searchParams }: PageProps) {
accountId: account.id,
});
filters.push(eq(lancamentos.isSettled, true));
const lancamentoRows = await db.query.lancamentos.findMany({
where: and(...filters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
const lancamentoRows = await fetchAccountLancamentos(filters);
const lancamentosData = mapLancamentosData(lancamentoRows);

View File

@@ -1,5 +1,7 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
import {
INITIAL_BALANCE_CATEGORY_NAME,
@@ -8,23 +10,24 @@ import {
INITIAL_BALANCE_PAYMENT_METHOD,
INITIAL_BALANCE_TRANSACTION_TYPE,
} from "@/lib/accounts/constants";
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
import { revalidateForEntity } from "@/lib/actions/helpers";
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { getTodayInfo } from "@/lib/utils/date";
import { normalizeFilePath } from "@/lib/utils/string";
import { db } from "@/lib/db";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
import {
TRANSFER_CATEGORY_NAME,
TRANSFER_CONDITION,
TRANSFER_ESTABLISHMENT,
TRANSFER_PAYMENT_METHOD,
} from "@/lib/transferencias/constants";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { getTodayInfo } from "@/lib/utils/date";
import { normalizeFilePath } from "@/lib/utils/string";
const accountBaseSchema = z.object({
name: z
@@ -50,7 +53,7 @@ const accountBaseSchema = z.object({
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um saldo inicial válido."
"Informe um saldo inicial válido.",
)
.transform((value) => Number.parseFloat(value)),
excludeFromBalance: z
@@ -74,7 +77,7 @@ type AccountUpdateInput = z.infer<typeof updateAccountSchema>;
type AccountDeleteInput = z.infer<typeof deleteAccountSchema>;
export async function createAccountAction(
input: AccountCreateInput
input: AccountCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -114,27 +117,27 @@ export async function createAccountAction(
columns: { id: true },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME)
eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME),
),
}),
tx.query.pagadores.findFirst({
columns: { id: true },
where: and(
eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
}),
]);
if (!category) {
throw new Error(
'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.'
'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.',
);
}
if (!adminPagador) {
throw new Error(
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial."
"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(
input: AccountUpdateInput
input: AccountUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -211,7 +214,7 @@ export async function updateAccountAction(
}
export async function deleteAccountAction(
input: AccountDeleteInput
input: AccountDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -250,7 +253,7 @@ const transferSchema = z.object({
.transform((value) => (value.length === 0 ? "0" : value.replace(",", ".")))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um valor válido."
"Informe um valor válido.",
)
.transform((value) => Number.parseFloat(value))
.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>;
export async function transferBetweenAccountsAction(
input: TransferInput
input: TransferInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -288,14 +291,14 @@ export async function transferBetweenAccountsAction(
columns: { id: true, name: true },
where: and(
eq(contas.id, data.fromAccountId),
eq(contas.userId, user.id)
eq(contas.userId, user.id),
),
}),
tx.query.contas.findFirst({
columns: { id: true, name: true },
where: and(
eq(contas.id, data.toAccountId),
eq(contas.userId, user.id)
eq(contas.userId, user.id),
),
}),
]);
@@ -313,13 +316,13 @@ export async function transferBetweenAccountsAction(
columns: { id: true },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, TRANSFER_CATEGORY_NAME)
eq(categorias.name, TRANSFER_CATEGORY_NAME),
),
});
if (!transferCategory) {
throw new Error(
`Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`
`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 },
where: and(
eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
});
if (!adminPagador) {
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 { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { loadLogoOptions } from "@/lib/logo/options";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, ilike, not, sql } from "drizzle-orm";
export type AccountData = {
id: string;
@@ -19,7 +19,7 @@ export type AccountData = {
};
export async function fetchAccountsForUser(
userId: string
userId: string,
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
const [accountRows, logoOptions] = await Promise.all([
db
@@ -51,16 +51,16 @@ export async function fetchAccountsForUser(
and(
eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true)
)
eq(lancamentos.isSettled, true),
),
)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(contas.userId, userId),
not(ilike(contas.status, "inativa")),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
)
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
),
)
.groupBy(
contas.id,
@@ -71,7 +71,7 @@ export async function fetchAccountsForUser(
contas.logo,
contas.initialBalance,
contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome
contas.excludeInitialBalanceFromIncome,
),
loadLogoOptions(),
]);
@@ -95,7 +95,7 @@ export async function fetchAccountsForUser(
}
export async function fetchInativosForUser(
userId: string
userId: string,
): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> {
const [accountRows, logoOptions] = await Promise.all([
db
@@ -127,16 +127,16 @@ export async function fetchInativosForUser(
and(
eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true)
)
eq(lancamentos.isSettled, true),
),
)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(contas.userId, userId),
ilike(contas.status, "inativa"),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
)
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`,
),
)
.groupBy(
contas.id,
@@ -147,7 +147,7 @@ export async function fetchInativosForUser(
contas.logo,
contas.initialBalance,
contas.excludeFromBalance,
contas.excludeInitialBalanceFromIncome
contas.excludeInitialBalanceFromIncome,
),
loadLogoOptions(),
]);

View File

@@ -8,7 +8,11 @@ export default async function InativosPage() {
return (
<main className="flex flex-col items-start gap-6">
<AccountsPage accounts={accounts} logoOptions={logoOptions} isInativos={true} />
<AccountsPage
accounts={accounts}
logoOptions={logoOptions}
isInativos={true}
/>
</main>
);
}

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiBankLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Contas | Opensheets",

View File

@@ -4,8 +4,6 @@ import { fetchAccountsForUser } from "./data";
export default async function Page() {
const userId = await getUserId();
const now = new Date();
const { accounts, logoOptions } = await fetchAccountsForUser(userId);
return (

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiSecurePaymentLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Análise de Parcelas | Opensheets",

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 { getUser } from "@/lib/auth/server";
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
import { db, schema } from "@/lib/db";
import { parsePeriodParam } from "@/lib/utils/period";
import { eq } from "drizzle-orm";
import { fetchUserDashboardPreferences } from "./data";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -29,20 +28,12 @@ export default async function Page({ searchParams }: PageProps) {
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [data, preferencesResult] = await Promise.all([
const [data, preferences] = await Promise.all([
fetchDashboardData(user.id, selectedPeriod),
db
.select({
disableMagnetlines: schema.userPreferences.disableMagnetlines,
dashboardWidgets: schema.userPreferences.dashboardWidgets,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, user.id))
.limit(1),
fetchUserDashboardPreferences(user.id),
]);
const disableMagnetlines = preferencesResult[0]?.disableMagnetlines ?? false;
const dashboardWidgets = preferencesResult[0]?.dashboardWidgets ?? null;
const { disableMagnetlines, dashboardWidgets } = preferences;
return (
<main className="flex flex-col gap-4 px-6">

View File

@@ -1,5 +1,12 @@
"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 {
cartoes,
categorias,
@@ -10,21 +17,14 @@ import {
savedInsights,
} from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import {
InsightsResponseSchema,
type InsightsResponse,
InsightsResponseSchema,
} from "@/lib/schemas/insights";
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";
const TRANSFERENCIA = "Transferência";
@@ -54,7 +54,12 @@ async function aggregateMonthData(userId: string, period: string) {
const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo);
// 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
.select({
transactionType: lancamentos.transactionType,
@@ -72,9 +77,9 @@ async function aggregateMonthData(userId: string, period: string) {
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(lancamentos.transactionType),
db
@@ -94,9 +99,9 @@ async function aggregateMonthData(userId: string, period: string) {
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(lancamentos.transactionType),
db
@@ -116,9 +121,9 @@ async function aggregateMonthData(userId: string, period: string) {
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(lancamentos.transactionType),
db
@@ -138,9 +143,9 @@ async function aggregateMonthData(userId: string, period: string) {
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(lancamentos.transactionType),
]);
@@ -199,9 +204,9 @@ async function aggregateMonthData(userId: string, period: string) {
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(categorias.name)
.orderBy(sql`sum(${lancamentos.amount}) ASC`)
@@ -222,8 +227,8 @@ async function aggregateMonthData(userId: string, period: string) {
eq(lancamentos.categoriaId, categorias.id),
eq(lancamentos.period, period),
eq(lancamentos.userId, userId),
eq(lancamentos.transactionType, "Despesa")
)
eq(lancamentos.transactionType, "Despesa"),
),
)
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period)))
.groupBy(categorias.name, orcamentos.amount);
@@ -248,8 +253,8 @@ async function aggregateMonthData(userId: string, period: string) {
and(
eq(contas.userId, userId),
eq(contas.status, "ativa"),
eq(contas.excludeFromBalance, false)
)
eq(contas.excludeFromBalance, false),
),
);
// Calcular ticket médio das transações
@@ -265,8 +270,8 @@ async function aggregateMonthData(userId: string, period: string) {
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA)
)
ne(lancamentos.transactionType, TRANSFERENCIA),
),
);
// Buscar gastos por dia da semana
@@ -282,8 +287,8 @@ async function aggregateMonthData(userId: string, period: string) {
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
)
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
);
// Agregar por dia da semana
@@ -308,8 +313,8 @@ async function aggregateMonthData(userId: string, period: string) {
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
)
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
),
)
.groupBy(lancamentos.paymentMethod);
@@ -333,13 +338,16 @@ async function aggregateMonthData(userId: string, period: string) {
sql`${lancamentos.period} IN (${period}, ${previousPeriod}, ${twoMonthsAgo})`,
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA)
)
ne(lancamentos.transactionType, TRANSFERENCIA),
),
)
.orderBy(lancamentos.name);
// 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) {
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)
const recurringExpenses: Array<{ name: string; avgAmount: number; frequency: number }> = [];
const recurringExpenses: Array<{
name: string;
avgAmount: number;
frequency: number;
}> = [];
let totalRecurring = 0;
for (const [name, occurrences] of transactionsByName.entries()) {
if (occurrences.length >= 2) {
const amounts = occurrences.map(o => o.amount);
const avgAmount = amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length;
const amounts = occurrences.map((o) => o.amount);
const avgAmount =
amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length;
const maxDiff = Math.max(...amounts) - Math.min(...amounts);
// 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
const currentMonthOccurrence = occurrences.find(o => o.period === period);
const currentMonthOccurrence = occurrences.find(
(o) => o.period === period,
);
if (currentMonthOccurrence) {
totalRecurring += currentMonthOccurrence.amount;
}
@@ -384,12 +399,15 @@ async function aggregateMonthData(userId: string, period: string) {
// Análise de gastos parcelados
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
.filter(tx => tx.period === period)
.map(tx => ({
.filter((tx) => tx.period === period)
.map((tx) => ({
name: tx.name,
currentInstallment: tx.currentInstallment ?? 1,
totalInstallments: tx.installmentCount ?? 1,
@@ -397,10 +415,13 @@ async function aggregateMonthData(userId: string, period: string) {
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 remaining = (tx.totalInstallments - tx.currentInstallment);
return sum + (tx.amount * remaining);
const remaining = tx.totalInstallments - tx.currentInstallment;
return sum + tx.amount * remaining;
}, 0);
// Montar dados agregados e anonimizados
@@ -413,13 +434,36 @@ async function aggregateMonthData(userId: string, period: string) {
// Tendência de 3 meses
threeMonthTrend: {
periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period],
incomes: [threeMonthsAgoIncome, twoMonthsAgoIncome, previousIncome, currentIncome],
expenses: [threeMonthsAgoExpense, twoMonthsAgoExpense, previousExpense, currentExpense],
avgIncome: (threeMonthsAgoIncome + twoMonthsAgoIncome + previousIncome + currentIncome) / 4,
avgExpense: (threeMonthsAgoExpense + twoMonthsAgoExpense + previousExpense + currentExpense) / 4,
trend: currentExpense > previousExpense && previousExpense > twoMonthsAgoExpense
incomes: [
threeMonthsAgoIncome,
twoMonthsAgoIncome,
previousIncome,
currentIncome,
],
expenses: [
threeMonthsAgoExpense,
twoMonthsAgoExpense,
previousExpense,
currentExpense,
],
avgIncome:
(threeMonthsAgoIncome +
twoMonthsAgoIncome +
previousIncome +
currentIncome) /
4,
avgExpense:
(threeMonthsAgoExpense +
twoMonthsAgoExpense +
previousExpense +
currentExpense) /
4,
trend:
currentExpense > previousExpense &&
previousExpense > twoMonthsAgoExpense
? "crescente"
: currentExpense < previousExpense && previousExpense < twoMonthsAgoExpense
: currentExpense < previousExpense &&
previousExpense < twoMonthsAgoExpense
? "decrescente"
: "estável",
},
@@ -446,7 +490,7 @@ async function aggregateMonthData(userId: string, period: string) {
currentExpense > 0
? (Math.abs(toNumber(cat.total)) / currentExpense) * 100
: 0,
})
}),
),
budgets: budgetsData.map(
(b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({
@@ -457,7 +501,7 @@ async function aggregateMonthData(userId: string, period: string) {
toNumber(b.budgetAmount) > 0
? (Math.abs(toNumber(b.spent)) / toNumber(b.budgetAmount)) * 100
: 0,
})
}),
),
creditCards: {
totalLimit: toNumber(cardsData[0]?.totalLimit ?? 0),
@@ -480,35 +524,40 @@ async function aggregateMonthData(userId: string, period: string) {
total: toNumber(pm.total),
percentage:
currentExpense > 0 ? (toNumber(pm.total) / currentExpense) * 100 : 0,
})
}),
),
// Análise de recorrência
recurringExpenses: {
count: recurringExpenses.length,
total: totalRecurring,
percentageOfTotal: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
percentageOfTotal:
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
topRecurring: recurringExpenses
.sort((a, b) => b.avgAmount - a.avgAmount)
.slice(0, 5)
.map(r => ({
.map((r) => ({
name: r.name,
avgAmount: r.avgAmount,
frequency: r.frequency,
})),
predictability: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
predictability:
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
},
// Análise de parcelamentos
installments: {
currentMonthInstallments: installmentData.length,
totalInstallmentAmount,
percentageOfExpenses: currentExpense > 0 ? (totalInstallmentAmount / currentExpense) * 100 : 0,
percentageOfExpenses:
currentExpense > 0
? (totalInstallmentAmount / currentExpense) * 100
: 0,
futureCommitment,
topInstallments: installmentData
.sort((a, b) => b.amount - a.amount)
.slice(0, 5)
.map(i => ({
.map((i) => ({
name: i.name,
current: i.currentInstallment,
total: i.totalInstallments,
@@ -527,7 +576,7 @@ async function aggregateMonthData(userId: string, period: string) {
*/
export async function generateInsightsAction(
period: string,
modelId: string
modelId: string,
): Promise<ActionResult<InsightsResponse>> {
try {
const user = await getUser();
@@ -555,7 +604,8 @@ export async function generateInsightsAction(
if (!apiKey) {
return {
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(
period: string,
modelId: string,
data: InsightsResponse
data: InsightsResponse,
): Promise<ActionResult<{ id: string; createdAt: Date }>> {
try {
const user = await getUser();
@@ -649,7 +699,10 @@ export async function saveInsightsAction(
.select()
.from(savedInsights)
.where(
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period))
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
)
.limit(1);
@@ -665,10 +718,13 @@ export async function saveInsightsAction(
.where(
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period)
eq(savedInsights.period, period),
),
)
)
.returning({ id: savedInsights.id, createdAt: savedInsights.createdAt });
.returning({
id: savedInsights.id,
createdAt: savedInsights.createdAt,
});
const updatedRecord = updated[0];
if (!updatedRecord) {
@@ -728,9 +784,7 @@ export async function saveInsightsAction(
/**
* Carrega insights salvos do banco de dados
*/
export async function loadSavedInsightsAction(
period: string
): Promise<
export async function loadSavedInsightsAction(period: string): Promise<
ActionResult<{
insights: InsightsResponse;
modelId: string;
@@ -744,7 +798,10 @@ export async function loadSavedInsightsAction(
.select()
.from(savedInsights)
.where(
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period))
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
)
.limit(1);
@@ -789,7 +846,7 @@ export async function loadSavedInsightsAction(
* Remove insights salvos do banco de dados
*/
export async function deleteSavedInsightsAction(
period: string
period: string,
): Promise<ActionResult<void>> {
try {
const user = await getUser();
@@ -797,7 +854,10 @@ export async function deleteSavedInsightsAction(
await db
.delete(savedInsights)
.where(
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period))
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
);
return {

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiSparklingLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Insights | Opensheets",

View File

@@ -16,10 +16,7 @@ export default function InsightsLoading() {
{/* Grid de insights */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border p-6 space-y-4"
>
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />

View File

@@ -10,11 +10,11 @@ type PageProps = {
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string
key: string,
) => {
const value = params?.[key];
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) {

View File

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

View File

@@ -1,17 +1,18 @@
"use server";
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
categorias,
installmentAnticipations,
lancamentos,
pagadores,
type InstallmentAnticipation,
type Lancamento,
} from "@/db/schema";
import { handleActionError } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
generateAnticipationDescription,
generateAnticipationNote,
@@ -24,9 +25,6 @@ import type {
} from "@/lib/installments/anticipation-types";
import { uuidSchema } from "@/lib/schemas/common";
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
/**
* 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
*/
export async function getEligibleInstallmentsAction(
seriesId: string
seriesId: string,
): Promise<ActionResult<EligibleInstallment[]>> {
try {
const user = await getUser();
@@ -79,7 +77,7 @@ export async function getEligibleInstallmentsAction(
eq(lancamentos.condition, "Parcelado"),
// Apenas parcelas não pagas e não antecipadas
or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)),
eq(lancamentos.isAnticipated, false)
eq(lancamentos.isAnticipated, false),
),
orderBy: [asc(lancamentos.currentInstallment)],
columns: {
@@ -124,7 +122,7 @@ export async function getEligibleInstallmentsAction(
* Cria uma antecipação de parcelas
*/
export async function createInstallmentAnticipationAction(
input: CreateAnticipationInput
input: CreateAnticipationInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -137,7 +135,7 @@ export async function createInstallmentAnticipationAction(
eq(lancamentos.userId, user.id),
eq(lancamentos.seriesId, data.seriesId),
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
const totalAmountCents = installments.reduce(
(sum, inst) => sum + Number(inst.amount) * 100,
0
0,
);
const totalAmount = totalAmountCents / 100;
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)
const finalAmount = totalAmount < 0
const finalAmount =
totalAmount < 0
? totalAmount + discount // Despesa: -1000 + 20 = -980
: totalAmount - discount; // Receita: 1000 - 20 = 980
@@ -190,7 +189,7 @@ export async function createInstallmentAnticipationAction(
.values({
name: generateAnticipationDescription(
firstInstallment.name,
installments.length
installments.length,
),
condition: "À vista",
transactionType: firstInstallment.transactionType,
@@ -219,7 +218,7 @@ export async function createInstallmentAnticipationAction(
paymentMethod: inst.paymentMethod,
categoriaId: inst.categoriaId,
pagadorId: inst.pagadorId,
}))
})),
),
userId: user.id,
installmentCount: null,
@@ -270,7 +269,9 @@ export async function createInstallmentAnticipationAction(
return {
success: true,
message: `${installments.length} ${
installments.length === 1 ? "parcela antecipada" : "parcelas antecipadas"
installments.length === 1
? "parcela antecipada"
: "parcelas antecipadas"
} com sucesso!`,
};
} catch (error) {
@@ -282,7 +283,7 @@ export async function createInstallmentAnticipationAction(
* Busca histórico de antecipações de uma série
*/
export async function getInstallmentAnticipationsAction(
seriesId: string
seriesId: string,
): Promise<ActionResult<InstallmentAnticipationWithRelations[]>> {
try {
const user = await getUser();
@@ -297,7 +298,8 @@ export async function getInstallmentAnticipationsAction(
seriesId: installmentAnticipations.seriesId,
anticipationPeriod: installmentAnticipations.anticipationPeriod,
anticipationDate: installmentAnticipations.anticipationDate,
anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds,
anticipatedInstallmentIds:
installmentAnticipations.anticipatedInstallmentIds,
totalAmount: installmentAnticipations.totalAmount,
installmentCount: installmentAnticipations.installmentCount,
discount: installmentAnticipations.discount,
@@ -313,14 +315,20 @@ export async function getInstallmentAnticipationsAction(
categoria: categorias,
})
.from(installmentAnticipations)
.leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id))
.leftJoin(
lancamentos,
eq(installmentAnticipations.lancamentoId, lancamentos.id),
)
.leftJoin(pagadores, eq(installmentAnticipations.pagadorId, pagadores.id))
.leftJoin(categorias, eq(installmentAnticipations.categoriaId, categorias.id))
.leftJoin(
categorias,
eq(installmentAnticipations.categoriaId, categorias.id),
)
.where(
and(
eq(installmentAnticipations.seriesId, validatedSeriesId),
eq(installmentAnticipations.userId, user.id)
)
eq(installmentAnticipations.userId, user.id),
),
)
.orderBy(desc(installmentAnticipations.createdAt));
@@ -338,7 +346,7 @@ export async function getInstallmentAnticipationsAction(
* Remove o lançamento de antecipação e restaura as parcelas originais
*/
export async function cancelInstallmentAnticipationAction(
input: CancelAnticipationInput
input: CancelAnticipationInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -352,7 +360,8 @@ export async function cancelInstallmentAnticipationAction(
seriesId: installmentAnticipations.seriesId,
anticipationPeriod: installmentAnticipations.anticipationPeriod,
anticipationDate: installmentAnticipations.anticipationDate,
anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds,
anticipatedInstallmentIds:
installmentAnticipations.anticipatedInstallmentIds,
totalAmount: installmentAnticipations.totalAmount,
installmentCount: installmentAnticipations.installmentCount,
discount: installmentAnticipations.discount,
@@ -365,12 +374,15 @@ export async function cancelInstallmentAnticipationAction(
lancamento: lancamentos,
})
.from(installmentAnticipations)
.leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id))
.leftJoin(
lancamentos,
eq(installmentAnticipations.lancamentoId, lancamentos.id),
)
.where(
and(
eq(installmentAnticipations.id, data.anticipationId),
eq(installmentAnticipations.userId, user.id)
)
eq(installmentAnticipations.userId, user.id),
),
)
.limit(1);
@@ -383,7 +395,7 @@ export async function cancelInstallmentAnticipationAction(
// 2. Verificar se o lançamento já foi pago
if (anticipation.lancamento?.isSettled === true) {
throw new Error(
"Não é possível cancelar uma antecipação já paga. Remova o pagamento primeiro."
"Não é possível cancelar uma antecipação já paga. Remova o pagamento primeiro.",
);
}
@@ -403,8 +415,8 @@ export async function cancelInstallmentAnticipationAction(
.where(
inArray(
lancamentos.id,
anticipation.anticipatedInstallmentIds as string[]
)
anticipation.anticipatedInstallmentIds as string[],
),
);
// 5. Deletar lançamento de antecipação
@@ -434,7 +446,7 @@ export async function cancelInstallmentAnticipationAction(
* Busca detalhes de uma antecipação específica
*/
export async function getAnticipationDetailsAction(
anticipationId: string
anticipationId: string,
): Promise<ActionResult<InstallmentAnticipationWithRelations>> {
try {
const user = await getUser();
@@ -445,7 +457,7 @@ export async function getAnticipationDetailsAction(
const anticipation = await db.query.installmentAnticipations.findFirst({
where: and(
eq(installmentAnticipations.id, validatedId),
eq(installmentAnticipations.userId, user.id)
eq(installmentAnticipations.userId, user.id),
),
with: {
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 { db } from "@/lib/db";
import { and, desc, eq, isNull, ne, or, type SQL } from "drizzle-orm";
export async function fetchLancamentos(filters: SQL[]) {
const lancamentoRows = await db
@@ -24,9 +30,9 @@ export async function fetchLancamentos(filters: SQL[]) {
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.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 PageDescription from "@/components/page-description";
export const metadata = {
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 MonthNavigation from "@/components/month-picker/month-navigation";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
@@ -13,8 +13,8 @@ import {
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { parsePeriodParam } from "@/lib/utils/period";
import { fetchLancamentos } from "./data";
import { getRecentEstablishmentsAction } from "./actions";
import { fetchLancamentos } from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;

View File

@@ -1,20 +1,20 @@
"use server";
import { and, eq, ne } from "drizzle-orm";
import { z } from "zod";
import { categorias, orcamentos } from "@/db/schema";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { periodSchema, uuidSchema } from "@/lib/schemas/common";
import {
formatDecimalForDbRequired,
normalizeDecimalInput,
} from "@/lib/utils/currency";
import { and, eq, ne } from "drizzle-orm";
import { z } from "zod";
const budgetBaseSchema = z.object({
categoriaId: uuidSchema("Categoria"),
@@ -26,12 +26,12 @@ const budgetBaseSchema = z.object({
.transform((value) => normalizeDecimalInput(value))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um valor limite válido."
"Informe um valor limite válido.",
)
.transform((value) => Number.parseFloat(value))
.refine(
(value) => value >= 0,
"O valor limite deve ser maior ou igual a zero."
"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(
input: BudgetCreateInput
input: BudgetCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -109,7 +109,7 @@ export async function createBudgetAction(
}
export async function updateBudgetAction(
input: BudgetUpdateInput
input: BudgetUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -163,7 +163,7 @@ export async function updateBudgetAction(
}
export async function deleteBudgetAction(
input: BudgetDeleteInput
input: BudgetDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -193,12 +193,10 @@ const duplicatePreviousMonthSchema = z.object({
period: periodSchema,
});
type DuplicatePreviousMonthInput = z.infer<
typeof duplicatePreviousMonthSchema
>;
type DuplicatePreviousMonthInput = z.infer<typeof duplicatePreviousMonthSchema>;
export async function duplicatePreviousMonthBudgetsAction(
input: DuplicatePreviousMonthInput
input: DuplicatePreviousMonthInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -218,7 +216,7 @@ export async function duplicatePreviousMonthBudgetsAction(
const previousBudgets = await db.query.orcamentos.findMany({
where: and(
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({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period)
eq(orcamentos.period, data.period),
),
});
// Filtrar para evitar duplicatas
const existingCategoryIds = new Set(
currentBudgets.map((b) => b.categoriaId)
currentBudgets.map((b) => b.categoriaId),
);
const budgetsToCopy = previousBudgets.filter(
(b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId)
(b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId),
);
if (budgetsToCopy.length === 0) {
@@ -261,7 +259,7 @@ export async function duplicatePreviousMonthBudgetsAction(
period: data.period,
userId: user.id,
categoriaId: b.categoriaId!,
}))
})),
);
revalidateForEntity("orcamentos");

View File

@@ -1,11 +1,11 @@
import { and, asc, eq, inArray, sum } from "drizzle-orm";
import {
categorias,
lancamentos,
orcamentos,
type Orcamento,
orcamentos,
} from "@/db/schema";
import { db } from "@/lib/db";
import { and, asc, eq, inArray, sum } from "drizzle-orm";
const toNumber = (value: string | number | null | undefined) => {
if (typeof value === "number") return value;
@@ -37,7 +37,7 @@ export type CategoryOption = {
export async function fetchBudgetsForUser(
userId: string,
selectedPeriod: string
selectedPeriod: string,
): Promise<{
budgets: BudgetData[];
categoriesOptions: CategoryOption[];
@@ -46,7 +46,7 @@ export async function fetchBudgetsForUser(
db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, userId),
eq(orcamentos.period, selectedPeriod)
eq(orcamentos.period, selectedPeriod),
),
with: {
categoria: true,
@@ -81,16 +81,18 @@ export async function fetchBudgetsForUser(
eq(lancamentos.userId, userId),
eq(lancamentos.period, selectedPeriod),
eq(lancamentos.transactionType, "Despesa"),
inArray(lancamentos.categoriaId, categoryIds)
)
inArray(lancamentos.categoriaId, categoryIds),
),
)
.groupBy(lancamentos.categoriaId);
totalsByCategory = new Map(
totals.map((row: { categoriaId: string | null; totalAmount: string | null }) => [
totals.map(
(row: { categoriaId: string | null; totalAmount: string | null }) => [
row.categoriaId ?? "",
Math.abs(toNumber(row.totalAmount)),
])
],
),
);
}
@@ -112,7 +114,7 @@ export async function fetchBudgetsForUser(
.sort((a, b) =>
(a.category?.name ?? "").localeCompare(b.category?.name ?? "", "pt-BR", {
sensitivity: "base",
})
}),
);
const categoriesOptions = categoryRows.map((category) => ({

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiFundsLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Anotações | Opensheets",

View File

@@ -23,10 +23,7 @@ export default function OrcamentosLoading() {
{/* Grid de cards de orçamentos */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border p-6 space-y-4"
>
<div key={i} className="rounded-2xl border p-6 space-y-4">
{/* Categoria com ícone */}
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />

View File

@@ -12,11 +12,11 @@ type PageProps = {
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? value[0] ?? null : value;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
const capitalize = (value: string) =>
@@ -35,7 +35,10 @@ export default async function Page({ searchParams }: PageProps) {
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
const { budgets, categoriesOptions } = await fetchBudgetsForUser(userId, selectedPeriod);
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
userId,
selectedPeriod,
);
return (
<main className="flex flex-col gap-6">

View File

@@ -1,8 +1,12 @@
"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 { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
fetchPagadorBoletoStats,
fetchPagadorCardUsage,
@@ -10,10 +14,6 @@ import {
fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details";
import { displayPeriod } from "@/lib/utils/period";
import { and, desc, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { Resend } from "resend";
import { z } from "zod";
const inputSchema = z.object({
pagadorId: z.string().uuid("Pagador inválido."),
@@ -122,7 +122,7 @@ const buildSummaryHtml = ({
return `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
point.label
point.label,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">
<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>
<span style="font-weight:600;min-width:100px;text-align:right;">${formatCurrency(
point.despesas
point.despesas,
)}</span>
</div>
</td>
@@ -146,12 +146,12 @@ const buildSummaryHtml = ({
(item) => `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
item.name
item.name,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.amount
item.amount,
)}</td>
</tr>`
</tr>`,
)
.join("")
: `<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) => `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
item.name
item.name,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
item.dueDate ? formatDate(item.dueDate) : "—"
}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.amount
item.amount,
)}</td>
</tr>`
</tr>`,
)
.join("")
: `<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) => `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate(
item.purchaseDate
item.purchaseDate,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
escapeHtml(item.name) || "Sem descrição"
@@ -195,9 +195,9 @@ const buildSummaryHtml = ({
escapeHtml(item.paymentMethod) || "—"
}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.amount
item.amount,
)}</td>
</tr>`
</tr>`,
)
.join("")
: `<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) => `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate(
item.purchaseDate
item.purchaseDate,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
escapeHtml(item.name) || "Sem descrição"
@@ -218,12 +218,12 @@ const buildSummaryHtml = ({
item.currentInstallment
}/${item.installmentCount}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
item.installmentAmount
item.installmentAmount,
)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;color:#64748b;">${formatCurrency(
item.totalAmount
item.totalAmount,
)}</td>
</tr>`
</tr>`,
)
.join("")
: `<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;">
<h1 style="margin:0 0 6px 0;font-size:26px;font-weight:800;letter-spacing:-0.3px;color:#ffffff;">Resumo Financeiro</h1>
<p style="margin:0;font-size:15px;color:#ffece6;">${escapeHtml(
periodLabel
periodLabel,
)}</p>
</div>
@@ -246,7 +246,7 @@ const buildSummaryHtml = ({
<!-- Saudação -->
<p style="margin:0 0 24px 0;font-size:15px;color:#334155;">
Olá <strong>${escapeHtml(
pagadorName
pagadorName,
)}</strong>, segue o consolidado do mês:
</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;text-align:right;">
<strong style="font-size:22px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.totalExpenses
monthlyBreakdown.totalExpenses,
)}</strong>
</td>
</tr>
<tr>
<td style="padding:12px 18px;font-size:14px;color:#64748b;">💳 Cartões</td>
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.card
monthlyBreakdown.paymentSplits.card,
)}</strong></td>
</tr>
<tr style="background:#fcfcfd;">
<td style="padding:12px 18px;font-size:14px;color:#64748b;">📄 Boletos</td>
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.boleto
monthlyBreakdown.paymentSplits.boleto,
)}</strong></td>
</tr>
<tr>
<td style="padding:12px 18px;font-size:14px;color:#64748b;">⚡ Pix/Débito/Dinheiro</td>
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.instant
monthlyBreakdown.paymentSplits.instant,
)}</strong></td>
</tr>
</tbody>
@@ -305,7 +305,7 @@ const buildSummaryHtml = ({
<td style="color:#475569;font-weight:700;font-size:15px;">Total</td>
<td style="text-align:right;">
<strong style="font-size:18px;color:#0f172a;">${formatCurrency(
monthlyBreakdown.paymentSplits.card
monthlyBreakdown.paymentSplits.card,
)}</strong>
</td>
</tr>
@@ -333,7 +333,7 @@ const buildSummaryHtml = ({
<td style="color:#475569;font-weight:700;font-size:15px;">Total</td>
<td style="text-align:right;">
<strong style="font-size:18px;color:#0f172a;">${formatCurrency(
boletoStats.totalAmount
boletoStats.totalAmount,
)}</strong>
</td>
</tr>
@@ -396,7 +396,7 @@ const buildSummaryHtml = ({
};
export async function sendPagadorSummaryAction(
input: z.infer<typeof inputSchema>
input: z.infer<typeof inputSchema>,
): Promise<ActionResult> {
try {
const { pagadorId, period } = inputSchema.parse(input);
@@ -471,8 +471,8 @@ export async function sendPagadorSummaryAction(
eq(lancamentos.userId, user.id),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, "Boleto")
)
eq(lancamentos.paymentMethod, "Boleto"),
),
)
.orderBy(desc(lancamentos.dueDate)),
db
@@ -490,8 +490,8 @@ export async function sendPagadorSummaryAction(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period)
)
eq(lancamentos.period, period),
),
)
.orderBy(desc(lancamentos.purchaseDate)),
db
@@ -509,8 +509,8 @@ export async function sendPagadorSummaryAction(
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false)
)
eq(lancamentos.isAnticipated, false),
),
)
.orderBy(desc(lancamentos.purchaseDate)),
]);
@@ -530,7 +530,7 @@ export async function sendPagadorSummaryAction(
transactionType: row.transactionType,
purchaseDate: row.purchaseDate,
amount: Number(row.amount ?? 0),
})
}),
);
const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => {
@@ -573,7 +573,7 @@ export async function sendPagadorSummaryAction(
.update(pagadores)
.set({ lastMailAt: now })
.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}`);

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 {
cartoes,
categorias,
contas,
lancamentos,
pagadores,
pagadorShares,
user as usersTable,
} from "@/db/schema";
import { db } from "@/lib/db";
export type ShareData = {
id: string;
@@ -11,7 +19,7 @@ export type ShareData = {
};
export async function fetchPagadorShares(
pagadorId: string
pagadorId: string,
): Promise<ShareData[]> {
const shareRows = await db
.select({
@@ -22,10 +30,7 @@ export async function fetchPagadorShares(
userEmail: usersTable.email,
})
.from(pagadorShares)
.innerJoin(
usersTable,
eq(pagadorShares.sharedWithUserId, usersTable.id)
)
.innerJoin(usersTable, eq(pagadorShares.sharedWithUserId, usersTable.id))
.where(eq(pagadorShares.pagadorId, pagadorId));
return shareRows.map((share) => ({
@@ -39,7 +44,7 @@ export async function fetchPagadorShares(
export async function fetchCurrentUserShare(
pagadorId: string,
userId: string
userId: string,
): Promise<{ id: string; createdAt: string } | null> {
const shareRow = await db.query.pagadorShares.findFirst({
columns: {
@@ -48,7 +53,7 @@ export async function fetchCurrentUserShare(
},
where: and(
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 { 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 type {
ContaCartaoFilterOption,
@@ -14,8 +8,15 @@ import type {
SelectOption,
} from "@/components/lancamentos/types";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card";
import { PagadorBoletoCard } from "@/components/pagadores/details/pagador-payment-method-cards";
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { pagadores } from "@/db/schema";
import type { pagadores } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
@@ -25,22 +26,25 @@ import {
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
mapLancamentosData,
type LancamentoSearchFilters,
mapLancamentosData,
type ResolvedSearchParams,
type SlugMaps,
type SluggedFilters,
type SlugMaps,
} from "@/lib/lancamentos/page-helpers";
import { getPagadorAccess } from "@/lib/pagadores/access";
import { parsePeriodParam } from "@/lib/utils/period";
import {
fetchPagadorBoletoStats,
fetchPagadorCardUsage,
fetchPagadorHistory,
fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details";
import { notFound } from "next/navigation";
import { fetchPagadorLancamentos, fetchPagadorShares, fetchCurrentUserShare } from "./data";
import { parsePeriodParam } from "@/lib/utils/period";
import {
fetchCurrentUserShare,
fetchPagadorLancamentos,
fetchPagadorShares,
} from "./data";
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
if (loggedUserFilterSources) {
const loggedUserSluggedFilters = buildSluggedFilters(loggedUserFilterSources);
const loggedUserSluggedFilters = buildSluggedFilters(
loggedUserFilterSources,
);
loggedUserOptionSets = buildOptionSets({
...loggedUserSluggedFilters,
pagadorRows: loggedUserFilterSources.pagadorRows,
@@ -222,12 +228,12 @@ export default async function Page({ params, searchParams }: PageProps) {
const pagadorSlug =
effectiveSluggedFilters.pagadorFiltersRaw.find(
(item) => item.id === pagador.id
(item) => item.id === pagador.id,
)?.slug ?? null;
const pagadorFilterOptions = pagadorSlug
? optionSets.pagadorFilterOptions.filter(
(option) => option.slug === pagadorSlug
(option) => option.slug === pagadorSlug,
)
: optionSets.pagadorFilterOptions;
@@ -334,7 +340,9 @@ export default async function Page({ params, searchParams }: PageProps) {
estabelecimentos={estabelecimentos}
allowCreate={canEdit}
importPagadorOptions={loggedUserOptionSets?.pagadorOptions}
importSplitPagadorOptions={loggedUserOptionSets?.splitPagadorOptions}
importSplitPagadorOptions={
loggedUserOptionSets?.splitPagadorOptions
}
importDefaultPagadorId={loggedUserOptionSets?.defaultPagadorId}
importContaOptions={loggedUserOptionSets?.contaOptions}
importCartaoOptions={loggedUserOptionSets?.cartaoOptions}
@@ -349,12 +357,12 @@ export default async function Page({ params, searchParams }: PageProps) {
const normalizeOptionLabel = (
value: string | null | undefined,
fallback: string
fallback: string,
) => (value?.trim().length ? value.trim() : fallback);
function buildReadOnlyOptionSets(
items: LancamentoItem[],
pagador: typeof pagadores.$inferSelect
pagador: typeof pagadores.$inferSelect,
): OptionSet {
const pagadorLabel = normalizeOptionLabel(pagador.name, "Pagador");
const pagadorOptions: SelectOption[] = [
@@ -405,7 +413,7 @@ function buildReadOnlyOptionSets(
(option) => ({
slug: option.value,
label: option.label,
})
}),
);
const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [

View File

@@ -1,10 +1,14 @@
"use server";
import { randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { pagadores, pagadorShares, user } from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {
DEFAULT_PAGADOR_AVATAR,
PAGADOR_ROLE_ADMIN,
@@ -14,10 +18,6 @@ import {
import { normalizeAvatarPath } from "@/lib/pagadores/utils";
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
import { normalizeOptionalString } from "@/lib/utils/string";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { randomBytes } from "node:crypto";
import { z } from "zod";
const statusEnum = z.enum(PAGADOR_STATUS_OPTIONS as [string, ...string[]], {
errorMap: () => ({
@@ -83,7 +83,7 @@ const generateShareCode = () => {
};
export async function createPagadorAction(
input: CreateInput
input: CreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -110,14 +110,17 @@ export async function createPagadorAction(
}
export async function updatePagadorAction(
input: UpdateInput
input: UpdateInput,
): Promise<ActionResult> {
try {
const currentUser = await getUser();
const data = updateSchema.parse(input);
const existing = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)),
where: and(
eq(pagadores.id, data.id),
eq(pagadores.userId, currentUser.id),
),
});
if (!existing) {
@@ -139,7 +142,9 @@ export async function updatePagadorAction(
isAutoSend: data.isAutoSend ?? false,
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
if (existing.role === PAGADOR_ROLE_ADMIN) {
@@ -160,7 +165,7 @@ export async function updatePagadorAction(
}
export async function deletePagadorAction(
input: DeleteInput
input: DeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -197,7 +202,7 @@ export async function deletePagadorAction(
}
export async function joinPagadorByShareCodeAction(
input: ShareCodeJoinInput
input: ShareCodeJoinInput,
): Promise<ActionResult> {
try {
const user = await getUser();
@@ -221,7 +226,7 @@ export async function joinPagadorByShareCodeAction(
const existingShare = await db.query.pagadorShares.findFirst({
where: and(
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(
input: ShareDeleteInput
input: ShareDeleteInput,
): Promise<ActionResult> {
try {
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
if (!existing || (existing.pagador.userId !== user.id && existing.sharedWithUserId !== user.id)) {
if (
!existing ||
(existing.pagador.userId !== user.id &&
existing.sharedWithUserId !== user.id)
) {
return {
success: false,
error: "Compartilhamento não encontrado.",
};
}
await db
.delete(pagadorShares)
.where(eq(pagadorShares.id, data.shareId));
await db.delete(pagadorShares).where(eq(pagadorShares.id, data.shareId));
revalidate();
revalidatePath(`/pagadores/${existing.pagadorId}`);
@@ -292,7 +299,7 @@ export async function deletePagadorShareAction(
}
export async function regeneratePagadorShareCodeAction(
input: ShareCodeRegenerateInput
input: ShareCodeRegenerateInput,
): Promise<{ success: true; message: string; code: string } | ActionResult> {
try {
const user = await getUser();
@@ -300,7 +307,10 @@ export async function regeneratePagadorShareCodeAction(
const existing = await db.query.pagadores.findFirst({
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) {
@@ -314,7 +324,12 @@ export async function regeneratePagadorShareCodeAction(
await db
.update(pagadores)
.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();
revalidatePath(`/pagadores/${data.pagadorId}`);

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiGroupLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
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 path from "node:path";
import { PagadoresPage } from "@/components/pagadores/pagadores-page";
import { getUserId } from "@/lib/auth/server";
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
import type { PagadorStatus } from "@/lib/pagadores/constants";
import {
DEFAULT_PAGADOR_AVATAR,
PAGADOR_ROLE_ADMIN,
PAGADOR_STATUS_OPTIONS,
} from "@/lib/pagadores/constants";
const AVATAR_DIRECTORY = path.join(process.cwd(), "public", "avatares");
const AVATAR_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
@@ -36,7 +36,7 @@ async function loadAvatarOptions() {
const resolveStatus = (status: string | null): PagadorStatus => {
const normalized = status?.trim() ?? "";
const found = PAGADOR_STATUS_OPTIONS.find(
(option) => option.toLowerCase() === normalized.toLowerCase()
(option) => option.toLowerCase() === normalized.toLowerCase(),
);
return found ?? PAGADOR_STATUS_OPTIONS[0];
};
@@ -64,7 +64,7 @@ export default async function Page() {
sharedByName: pagador.sharedByName ?? null,
sharedByEmail: pagador.sharedByEmail ?? null,
shareId: pagador.shareId ?? null,
shareCode: pagador.canEdit ? pagador.shareCode ?? null : null,
shareCode: pagador.canEdit ? (pagador.shareCode ?? null) : null,
}))
.sort((a, b) => {
// Admin sempre primeiro

View File

@@ -1,13 +1,13 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { inboxItems } from "@/db/schema";
import { handleActionError } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const markProcessedSchema = z.object({
inboxItemId: z.string().uuid("ID do item inválido"),

View File

@@ -2,19 +2,28 @@
* Data fetching functions for Pré-Lançamentos
*/
import { db } from "@/lib/db";
import { inboxItems, categorias, contas, cartoes, lancamentos } from "@/db/schema";
import { eq, desc, and, gte } from "drizzle-orm";
import type { InboxItem, SelectOption } from "@/components/pre-lancamentos/types";
import { and, desc, eq, gte } from "drizzle-orm";
import type {
InboxItem,
SelectOption,
} from "@/components/pre-lancamentos/types";
import {
cartoes,
categorias,
contas,
inboxItems,
lancamentos,
} from "@/db/schema";
import { db } from "@/lib/db";
import {
fetchLancamentoFilterSources,
buildSluggedFilters,
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
} from "@/lib/lancamentos/page-helpers";
export async function fetchInboxItems(
userId: string,
status: "pending" | "processed" | "discarded" = "pending"
status: "pending" | "processed" | "discarded" = "pending",
): Promise<InboxItem[]> {
const items = await db
.select()
@@ -27,7 +36,7 @@ export async function fetchInboxItems(
export async function fetchInboxItemById(
userId: string,
itemId: string
itemId: string,
): Promise<InboxItem | null> {
const [item] = await db
.select()
@@ -40,7 +49,7 @@ export async function fetchInboxItemById(
export async function fetchCategoriasForSelect(
userId: string,
type?: string
type?: string,
): Promise<SelectOption[]> {
const query = db
.select({ id: categorias.id, name: categorias.name })
@@ -48,14 +57,16 @@ export async function fetchCategoriasForSelect(
.where(
type
? and(eq(categorias.userId, userId), eq(categorias.type, type))
: eq(categorias.userId, userId)
: eq(categorias.userId, userId),
)
.orderBy(categorias.name);
return query;
}
export async function fetchContasForSelect(userId: string): Promise<SelectOption[]> {
export async function fetchContasForSelect(
userId: string,
): Promise<SelectOption[]> {
const items = await db
.select({ id: contas.id, name: contas.name })
.from(contas)
@@ -66,7 +77,7 @@ export async function fetchContasForSelect(userId: string): Promise<SelectOption
}
export async function fetchCartoesForSelect(
userId: string
userId: string,
): Promise<(SelectOption & { lastDigits?: string })[]> {
const items = await db
.select({ id: cartoes.id, name: cartoes.name })
@@ -81,7 +92,9 @@ export async function fetchPendingInboxCount(userId: string): Promise<number> {
const items = await db
.select({ id: inboxItems.id })
.from(inboxItems)
.where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")));
.where(
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
);
return items.length;
}
@@ -123,8 +136,8 @@ export async function fetchInboxDialogData(userId: string): Promise<{
.where(
and(
eq(lancamentos.userId, userId),
gte(lancamentos.purchaseDate, threeMonthsAgo)
)
gte(lancamentos.purchaseDate, threeMonthsAgo),
),
)
.orderBy(desc(lancamentos.purchaseDate));
@@ -135,11 +148,11 @@ export async function fetchInboxDialogData(userId: string): Promise<{
(name: string | null): name is string =>
name != null &&
name.trim().length > 0 &&
!name.toLowerCase().startsWith("pagamento fatura")
!name.toLowerCase().startsWith("pagamento fatura"),
);
const estabelecimentos = Array.from<string>(new Set(filteredNames)).slice(
0,
100
100,
);
return {

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiInboxLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Pré-Lançamentos | Opensheets",

View File

@@ -1,5 +1,5 @@
import PageDescription from "@/components/page-description";
import { RiBankCard2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Relatório de Cartões | Opensheets",

View File

@@ -1,3 +1,4 @@
import { RiBankCard2Line } from "@remixicon/react";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { CardCategoryBreakdown } from "@/components/relatorios/cartoes/card-category-breakdown";
import { CardInvoiceStatus } from "@/components/relatorios/cartoes/card-invoice-status";
@@ -7,7 +8,6 @@ import { CardsOverview } from "@/components/relatorios/cartoes/cards-overview";
import { getUser } from "@/lib/auth/server";
import { fetchCartoesReportData } from "@/lib/relatorios/cartoes-report";
import { parsePeriodParam } from "@/lib/utils/period";
import { RiBankCard2Line } from "@remixicon/react";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;

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 PageDescription from "@/components/page-description";
export const metadata = {
title: "Relatórios | Opensheets",

View File

@@ -1,18 +1,17 @@
import { redirect } from "next/navigation";
import { CategoryReportPage } from "@/components/relatorios/category-report-page";
import { getUserId } from "@/lib/auth/server";
import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period";
import { validateDateRange } from "@/lib/relatorios/utils";
import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report";
import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
import type { CategoryReportFilters } from "@/lib/relatorios/types";
import type {
CategoryOption,
FilterState,
} from "@/components/relatorios/types";
import { db } from "@/lib/db";
import { categorias, type Categoria } from "@/db/schema";
import { eq, asc } from "drizzle-orm";
import { redirect } from "next/navigation";
import type { Categoria } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report";
import type { CategoryReportFilters } from "@/lib/relatorios/types";
import { validateDateRange } from "@/lib/relatorios/utils";
import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period";
import { fetchUserCategories } from "./data";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -22,11 +21,11 @@ type PageProps = {
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string
key: string,
): string | null => {
const value = params?.[key];
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) {
@@ -59,15 +58,12 @@ export default async function Page({ searchParams }: PageProps) {
if (!validation.isValid) {
// Redirect to default if validation fails
redirect(
`/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`
`/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`,
);
}
// Fetch all categories for the user
const categoriaRows = await db.query.categorias.findMany({
where: eq(categorias.userId, userId),
orderBy: [asc(categorias.name)],
});
const categoriaRows = await fetchUserCategories(userId);
// Map to CategoryOption format
const categoryOptions: CategoryOption[] = categoriaRows.map(
@@ -76,7 +72,7 @@ export default async function Page({ searchParams }: PageProps) {
name: cat.name,
icon: cat.icon,
type: cat.type as "despesa" | "receita",
})
}),
);
// Build filters for data fetching
@@ -95,7 +91,7 @@ export default async function Page({ searchParams }: PageProps) {
userId,
startPeriod,
endPeriod,
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
);
// 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 PageDescription from "@/components/page-description";
export const metadata = {
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 {
RiArrowRightSLine,
RiBankCard2Line,
@@ -12,23 +6,29 @@ import {
RiCodeSSlashLine,
RiDatabase2Line,
RiDeviceLine,
RiDownloadCloudLine,
RiEyeOffLine,
RiFileTextLine,
RiFlashlightLine,
RiGithubFill,
RiLineChartLine,
RiLockLine,
RiPercentLine,
RiPieChartLine,
RiRobot2Line,
RiShieldCheckLine,
RiTeamLine,
RiTimeLine,
RiWalletLine,
RiRobot2Line,
RiTeamLine,
RiFileTextLine,
RiDownloadCloudLine,
RiEyeOffLine,
RiFlashlightLine,
RiPercentLine,
} from "@remixicon/react";
import Image from "next/image";
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() {
const session = await getOptionalUserSession();

View File

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

View File

@@ -5,11 +5,16 @@
* Usado pelo app Android quando o access token expira.
*/
import { refreshAccessToken, extractBearerToken, verifyJwt, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import {
extractBearerToken,
hashToken,
refreshAccessToken,
verifyJwt,
} from "@/lib/auth/api-token";
import { db } from "@/lib/db";
export async function POST(request: Request) {
try {
@@ -20,7 +25,7 @@ export async function POST(request: Request) {
if (!token) {
return NextResponse.json(
{ 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") {
return NextResponse.json(
{ error: "Refresh token inválido ou expirado" },
{ status: 401 }
{ status: 401 },
);
}
@@ -39,14 +44,14 @@ export async function POST(request: Request) {
where: and(
eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt)
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token revogado ou não encontrado" },
{ status: 401 }
{ status: 401 },
);
}
@@ -56,7 +61,7 @@ export async function POST(request: Request) {
if (!result) {
return NextResponse.json(
{ error: "Não foi possível renovar o token" },
{ status: 401 }
{ status: 401 },
);
}
@@ -66,7 +71,9 @@ export async function POST(request: Request) {
.set({
tokenHash: hashToken(result.accessToken),
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,
})
.where(eq(apiTokens.id, payload.tokenId));
@@ -79,7 +86,7 @@ export async function POST(request: Request) {
console.error("[API] Error refreshing device token:", error);
return NextResponse.json(
{ error: "Erro ao renovar token" },
{ status: 500 }
{ status: 500 },
);
}
}

View File

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

View File

@@ -5,18 +5,18 @@
* Requer sessão web autenticada.
*/
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
interface RouteParams {
params: Promise<{ tokenId: string }>;
}
export async function DELETE(request: Request, { params }: RouteParams) {
export async function DELETE(_request: Request, { params }: RouteParams) {
try {
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() });
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401 }
);
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Verificar se token pertence ao usuário
const token = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.id, tokenId),
eq(apiTokens.userId, session.user.id)
eq(apiTokens.userId, session.user.id),
),
});
if (!token) {
return NextResponse.json(
{ 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);
return NextResponse.json(
{ error: "Erro ao revogar token" },
{ status: 500 }
{ status: 500 },
);
}
}

View File

@@ -5,12 +5,12 @@
* Requer sessão web autenticada.
*/
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/lib/auth/config";
import { db } from "@/lib/db";
export async function GET() {
try {
@@ -18,10 +18,7 @@ export async function GET() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401 }
);
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Buscar tokens ativos do usuário
@@ -40,14 +37,16 @@ export async function GET() {
.orderBy(desc(apiTokens.createdAt));
// 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 });
} catch (error) {
console.error("[API] Error listing device tokens:", error);
return NextResponse.json(
{ 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).
*/
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { apiTokens } from "@/db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
try {
@@ -22,7 +22,7 @@ export async function POST(request: Request) {
if (!token) {
return NextResponse.json(
{ 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_")) {
return NextResponse.json(
{ 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({
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt)
isNull(apiTokens.revokedAt),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ valid: false, error: "Token inválido ou revogado" },
{ status: 401 }
{ status: 401 },
);
}
// Atualizar último uso
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| request.headers.get("x-real-ip")
|| null;
const clientIp =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null;
await db
.update(apiTokens)
@@ -75,7 +76,7 @@ export async function POST(request: Request) {
console.error("[API] Error verifying device token:", error);
return NextResponse.json(
{ valid: false, error: "Erro ao validar token" },
{ status: 500 }
{ status: 500 },
);
}
}

View File

@@ -24,7 +24,7 @@ export async function GET() {
version: APP_VERSION,
timestamp: new Date().toISOString(),
},
{ status: 200 }
{ status: 200 },
);
} catch (error) {
// Se houver erro na conexão com banco, retorna status 503 (Service Unavailable)
@@ -36,9 +36,10 @@ export async function GET() {
name: "OpenSheets",
version: APP_VERSION,
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).
*/
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
import { inboxBatchSchema } from "@/lib/schemas/inbox";
import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
// Rate limiting simples em memória
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();

View File

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

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 { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata } from "next";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { main_font } from "@/public/fonts/font_index";
import "./globals.css";
export const metadata: Metadata = {

View File

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

View File

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

View File

@@ -1,21 +1,18 @@
"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 { useState, useTransition } from "react";
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 {
disableMagnetlines: boolean;
}
export function PreferencesForm({
disableMagnetlines,
}: PreferencesFormProps) {
export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [magnetlinesDisabled, setMagnetlinesDisabled] =
@@ -44,10 +41,7 @@ export function PreferencesForm({
};
return (
<form
onSubmit={handleSubmit}
className="flex flex-col space-y-6"
>
<form onSubmit={handleSubmit} className="flex flex-col space-y-6">
<div className="space-y-4 max-w-md">
<div className="flex items-center justify-between rounded-lg border border-dashed p-4">
<div className="space-y-0.5">

View File

@@ -1,19 +1,27 @@
"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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 = {
currentEmail: string;
authProvider?: string; // 'google' | 'credential' | undefined
};
export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormProps) {
export function UpdateEmailForm({
currentEmail,
authProvider,
}: UpdateEmailFormProps) {
const [isPending, startTransition] = useTransition();
const [password, setPassword] = useState("");
const [newEmail, setNewEmail] = useState("");
@@ -139,12 +147,19 @@ export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormP
aria-required="true"
aria-describedby="new-email-help"
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 && (
<p 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" />
O novo e-mail deve ser diferente do atual
<p
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" />O novo e-mail deve ser
diferente do atual
</p>
)}
{!newEmail && (
@@ -183,22 +198,35 @@ export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormP
{emailsMatch !== null && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{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>
{/* Mensagem de erro em tempo real */}
{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" />
Os e-mails não coincidem
</p>
)}
{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" />
Os e-mails coincidem
</p>

View File

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

View File

@@ -1,19 +1,19 @@
"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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 {
hasLowercase: boolean;
@@ -29,7 +29,7 @@ function validatePassword(password: string): PasswordValidation {
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password);
const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
const hasMinLength = password.length >= 7;
const hasMaxLength = password.length <= 23;
@@ -55,7 +55,9 @@ function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
<div
className={cn(
"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 ? (
@@ -93,7 +95,7 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
// Validação de requisitos da senha
const passwordValidation = useMemo(
() => validatePassword(newPassword),
[newPassword]
[newPassword],
);
const handleSubmit = (e: React.FormEvent) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
"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 { useCallback, useMemo, useState } from "react";
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 { NoteCard } from "./note-card";
import { NoteDetailsDialog } from "./note-details-dialog";
@@ -33,9 +36,9 @@ export function NotesPage({ notes, isArquivadas = false }: NotesPageProps) {
() =>
[...notes].sort(
(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) => {

View File

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

View File

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

View File

@@ -1,4 +1,8 @@
"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 { Card, CardContent } from "@/components/ui/card";
import {
@@ -11,10 +15,6 @@ import {
import { Input } from "@/components/ui/input";
import { authClient, googleSignInAvailable } from "@/lib/auth/client";
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 { AuthErrorAlert } from "./auth-error-alert";
import { AuthHeader } from "./auth-header";
@@ -55,14 +55,19 @@ export function LoginForm({ className, ...props }: DivProps) {
router.replace("/dashboard");
},
onError: (ctx) => {
if (ctx.error.status === 500 && ctx.error.statusText === "Internal Server Error") {
toast.error("Ocorreu uma falha na requisição. Tente novamente mais tarde.");
if (
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);
setLoadingEmail(false);
},
}
},
);
}
@@ -88,7 +93,7 @@ export function LoginForm({ className, ...props }: DivProps) {
setError(ctx.error.message);
setLoadingGoogle(false);
},
}
},
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
"use client";
import { RiAddLine } from "@remixicon/react";
import type { KeyboardEvent, MouseEvent } from "react";
import type { CalendarDay, CalendarEvent } from "@/components/calendario/types";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { cn } from "@/lib/utils/ui";
import { RiAddLine } from "@remixicon/react";
import type { KeyboardEvent, MouseEvent } from "react";
type DayCellProps = {
day: CalendarDay;
@@ -103,7 +103,7 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
<div
className={cn(
"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">
@@ -145,7 +145,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
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",
!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">
@@ -154,7 +154,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
"text-sm font-semibold leading-none",
day.isToday
? "text-orange-100 bg-primary size-5 rounded-full flex items-center justify-center"
: "text-foreground/90"
: "text-foreground/90",
)}
>
{day.label}

View File

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

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