${formatDate(
- item.purchaseDate
- )}
+ item.purchaseDate,
+ )}
${
- escapeHtml(item.name) || "Sem descrição"
- }
+ escapeHtml(item.name) || "Sem descrição"
+ }
${
- escapeHtml(item.condition) || "—"
- }
+ escapeHtml(item.condition) || "—"
+ }
${
- escapeHtml(item.paymentMethod) || "—"
- }
+ escapeHtml(item.paymentMethod) || "—"
+ }
${formatCurrency(
- item.amount
- )}
- `
- )
- .join("")
- : `
${formatDate(
- item.purchaseDate
- )}
+ item.purchaseDate,
+ )}
${
- escapeHtml(item.name) || "Sem descrição"
- }
+ escapeHtml(item.name) || "Sem descrição"
+ }
${
- item.currentInstallment
- }/${item.installmentCount}
+ item.currentInstallment
+ }/${item.installmentCount}
${formatCurrency(
- item.installmentAmount
- )}
+ item.installmentAmount,
+ )}
${formatCurrency(
- item.totalAmount
- )}
- `
- )
- .join("")
- : `
Resumo mensal e detalhes de gastos por cartão, boletos e lançamentos.
@@ -237,8 +237,8 @@ const buildSummaryHtml = ({
Resumo Financeiro
${escapeHtml(
- periodLabel
- )}
+ periodLabel,
+ )}
@@ -246,8 +246,8 @@ const buildSummaryHtml = ({
Olá ${escapeHtml(
- pagadorName
- )} , segue o consolidado do mês:
+ pagadorName,
+ )}, segue o consolidado do mês:
@@ -258,27 +258,27 @@ const buildSummaryHtml = ({
Total gasto
${formatCurrency(
- monthlyBreakdown.totalExpenses
- )}
+ monthlyBreakdown.totalExpenses,
+ )}
💳 Cartões
${formatCurrency(
- monthlyBreakdown.paymentSplits.card
- )}
+ monthlyBreakdown.paymentSplits.card,
+ )}
📄 Boletos
${formatCurrency(
- monthlyBreakdown.paymentSplits.boleto
- )}
+ monthlyBreakdown.paymentSplits.boleto,
+ )}
⚡ Pix/Débito/Dinheiro
${formatCurrency(
- monthlyBreakdown.paymentSplits.instant
- )}
+ monthlyBreakdown.paymentSplits.instant,
+ )}
@@ -305,8 +305,8 @@ const buildSummaryHtml = ({
Total
${formatCurrency(
- monthlyBreakdown.paymentSplits.card
- )}
+ monthlyBreakdown.paymentSplits.card,
+ )}
@@ -333,8 +333,8 @@ const buildSummaryHtml = ({
Total
${formatCurrency(
- boletoStats.totalAmount
- )}
+ boletoStats.totalAmount,
+ )}
@@ -396,207 +396,207 @@ const buildSummaryHtml = ({
};
export async function sendPagadorSummaryAction(
- input: z.infer
+ input: z.infer,
): Promise {
- try {
- const { pagadorId, period } = inputSchema.parse(input);
- const user = await getUser();
+ try {
+ const { pagadorId, period } = inputSchema.parse(input);
+ const user = await getUser();
- const pagadorRow = await db.query.pagadores.findFirst({
- where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, user.id)),
- });
+ const pagadorRow = await db.query.pagadores.findFirst({
+ where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, user.id)),
+ });
- if (!pagadorRow) {
- return { success: false, error: "Pagador não encontrado." };
- }
+ if (!pagadorRow) {
+ return { success: false, error: "Pagador não encontrado." };
+ }
- if (!pagadorRow.email) {
- return {
- success: false,
- error: "Cadastre um e-mail para conseguir enviar o resumo.",
- };
- }
+ if (!pagadorRow.email) {
+ return {
+ success: false,
+ error: "Cadastre um e-mail para conseguir enviar o resumo.",
+ };
+ }
- const resendApiKey = process.env.RESEND_API_KEY;
- const resendFrom =
- process.env.RESEND_FROM_EMAIL ?? "Opensheets ";
+ const resendApiKey = process.env.RESEND_API_KEY;
+ const resendFrom =
+ process.env.RESEND_FROM_EMAIL ?? "Opensheets ";
- if (!resendApiKey) {
- return {
- success: false,
- error: "Serviço de e-mail não configurado (RESEND_API_KEY ausente).",
- };
- }
+ if (!resendApiKey) {
+ return {
+ success: false,
+ error: "Serviço de e-mail não configurado (RESEND_API_KEY ausente).",
+ };
+ }
- const resend = new Resend(resendApiKey);
+ const resend = new Resend(resendApiKey);
- const [
- monthlyBreakdown,
- historyData,
- cardUsage,
- boletoStats,
- boletoRows,
- lancamentoRows,
- parceladoRows,
- ] = await Promise.all([
- fetchPagadorMonthlyBreakdown({
- userId: user.id,
- pagadorId,
- period,
- }),
- fetchPagadorHistory({
- userId: user.id,
- pagadorId,
- period,
- }),
- fetchPagadorCardUsage({
- userId: user.id,
- pagadorId,
- period,
- }),
- fetchPagadorBoletoStats({
- userId: user.id,
- pagadorId,
- period,
- }),
- db
- .select({
- name: lancamentos.name,
- amount: lancamentos.amount,
- dueDate: lancamentos.dueDate,
- })
- .from(lancamentos)
- .where(
- and(
- eq(lancamentos.userId, user.id),
- eq(lancamentos.pagadorId, pagadorId),
- eq(lancamentos.period, period),
- eq(lancamentos.paymentMethod, "Boleto")
- )
- )
- .orderBy(desc(lancamentos.dueDate)),
- db
- .select({
- id: lancamentos.id,
- name: lancamentos.name,
- paymentMethod: lancamentos.paymentMethod,
- condition: lancamentos.condition,
- amount: lancamentos.amount,
- transactionType: lancamentos.transactionType,
- purchaseDate: lancamentos.purchaseDate,
- })
- .from(lancamentos)
- .where(
- and(
- eq(lancamentos.userId, user.id),
- eq(lancamentos.pagadorId, pagadorId),
- eq(lancamentos.period, period)
- )
- )
- .orderBy(desc(lancamentos.purchaseDate)),
- db
- .select({
- name: lancamentos.name,
- amount: lancamentos.amount,
- installmentCount: lancamentos.installmentCount,
- currentInstallment: lancamentos.currentInstallment,
- purchaseDate: lancamentos.purchaseDate,
- })
- .from(lancamentos)
- .where(
- and(
- eq(lancamentos.userId, user.id),
- eq(lancamentos.pagadorId, pagadorId),
- eq(lancamentos.period, period),
- eq(lancamentos.condition, "Parcelado"),
- eq(lancamentos.isAnticipated, false)
- )
- )
- .orderBy(desc(lancamentos.purchaseDate)),
- ]);
+ const [
+ monthlyBreakdown,
+ historyData,
+ cardUsage,
+ boletoStats,
+ boletoRows,
+ lancamentoRows,
+ parceladoRows,
+ ] = await Promise.all([
+ fetchPagadorMonthlyBreakdown({
+ userId: user.id,
+ pagadorId,
+ period,
+ }),
+ fetchPagadorHistory({
+ userId: user.id,
+ pagadorId,
+ period,
+ }),
+ fetchPagadorCardUsage({
+ userId: user.id,
+ pagadorId,
+ period,
+ }),
+ fetchPagadorBoletoStats({
+ userId: user.id,
+ pagadorId,
+ period,
+ }),
+ db
+ .select({
+ name: lancamentos.name,
+ amount: lancamentos.amount,
+ dueDate: lancamentos.dueDate,
+ })
+ .from(lancamentos)
+ .where(
+ and(
+ eq(lancamentos.userId, user.id),
+ eq(lancamentos.pagadorId, pagadorId),
+ eq(lancamentos.period, period),
+ eq(lancamentos.paymentMethod, "Boleto"),
+ ),
+ )
+ .orderBy(desc(lancamentos.dueDate)),
+ db
+ .select({
+ id: lancamentos.id,
+ name: lancamentos.name,
+ paymentMethod: lancamentos.paymentMethod,
+ condition: lancamentos.condition,
+ amount: lancamentos.amount,
+ transactionType: lancamentos.transactionType,
+ purchaseDate: lancamentos.purchaseDate,
+ })
+ .from(lancamentos)
+ .where(
+ and(
+ eq(lancamentos.userId, user.id),
+ eq(lancamentos.pagadorId, pagadorId),
+ eq(lancamentos.period, period),
+ ),
+ )
+ .orderBy(desc(lancamentos.purchaseDate)),
+ db
+ .select({
+ name: lancamentos.name,
+ amount: lancamentos.amount,
+ installmentCount: lancamentos.installmentCount,
+ currentInstallment: lancamentos.currentInstallment,
+ purchaseDate: lancamentos.purchaseDate,
+ })
+ .from(lancamentos)
+ .where(
+ and(
+ eq(lancamentos.userId, user.id),
+ eq(lancamentos.pagadorId, pagadorId),
+ eq(lancamentos.period, period),
+ eq(lancamentos.condition, "Parcelado"),
+ eq(lancamentos.isAnticipated, false),
+ ),
+ )
+ .orderBy(desc(lancamentos.purchaseDate)),
+ ]);
- const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({
- name: row.name ?? "Sem descrição",
- amount: Math.abs(Number(row.amount ?? 0)),
- dueDate: row.dueDate,
- }));
+ const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({
+ name: row.name ?? "Sem descrição",
+ amount: Math.abs(Number(row.amount ?? 0)),
+ dueDate: row.dueDate,
+ }));
- const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map(
- (row) => ({
- id: row.id,
- name: row.name,
- paymentMethod: row.paymentMethod,
- condition: row.condition,
- transactionType: row.transactionType,
- purchaseDate: row.purchaseDate,
- amount: Number(row.amount ?? 0),
- })
- );
+ const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map(
+ (row) => ({
+ id: row.id,
+ name: row.name,
+ paymentMethod: row.paymentMethod,
+ condition: row.condition,
+ transactionType: row.transactionType,
+ purchaseDate: row.purchaseDate,
+ amount: Number(row.amount ?? 0),
+ }),
+ );
- const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => {
- const installmentAmount = Math.abs(Number(row.amount ?? 0));
- const installmentCount = row.installmentCount ?? 1;
- const totalAmount = installmentAmount * installmentCount;
+ const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => {
+ const installmentAmount = Math.abs(Number(row.amount ?? 0));
+ const installmentCount = row.installmentCount ?? 1;
+ const totalAmount = installmentAmount * installmentCount;
- return {
- name: row.name ?? "Sem descrição",
- installmentAmount,
- installmentCount,
- currentInstallment: row.currentInstallment ?? 1,
- totalAmount,
- purchaseDate: row.purchaseDate,
- };
- });
+ return {
+ name: row.name ?? "Sem descrição",
+ installmentAmount,
+ installmentCount,
+ currentInstallment: row.currentInstallment ?? 1,
+ totalAmount,
+ purchaseDate: row.purchaseDate,
+ };
+ });
- const html = buildSummaryHtml({
- pagadorName: pagadorRow.name,
- periodLabel: displayPeriod(period),
- monthlyBreakdown,
- historyData,
- cardUsage,
- boletoStats,
- boletos: normalizedBoletos,
- lancamentos: normalizedLancamentos,
- parcelados: normalizedParcelados,
- });
+ const html = buildSummaryHtml({
+ pagadorName: pagadorRow.name,
+ periodLabel: displayPeriod(period),
+ monthlyBreakdown,
+ historyData,
+ cardUsage,
+ boletoStats,
+ boletos: normalizedBoletos,
+ lancamentos: normalizedLancamentos,
+ parcelados: normalizedParcelados,
+ });
- await resend.emails.send({
- from: resendFrom,
- to: pagadorRow.email,
- subject: `Resumo Financeiro | ${displayPeriod(period)}`,
- html,
- });
+ await resend.emails.send({
+ from: resendFrom,
+ to: pagadorRow.email,
+ subject: `Resumo Financeiro | ${displayPeriod(period)}`,
+ html,
+ });
- const now = new Date();
+ const now = new Date();
- await db
- .update(pagadores)
- .set({ lastMailAt: now })
- .where(
- and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.id))
- );
+ await db
+ .update(pagadores)
+ .set({ lastMailAt: now })
+ .where(
+ and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.id)),
+ );
- revalidatePath(`/pagadores/${pagadorRow.id}`);
+ revalidatePath(`/pagadores/${pagadorRow.id}`);
- return { success: true, message: "Resumo enviado com sucesso." };
- } catch (error) {
- // Log estruturado em desenvolvimento
- if (process.env.NODE_ENV === "development") {
- console.error("[sendPagadorSummaryAction]", error);
- }
+ return { success: true, message: "Resumo enviado com sucesso." };
+ } catch (error) {
+ // Log estruturado em desenvolvimento
+ if (process.env.NODE_ENV === "development") {
+ console.error("[sendPagadorSummaryAction]", error);
+ }
- // Tratar erros de validação separadamente
- if (error instanceof z.ZodError) {
- return {
- success: false,
- error: error.issues[0]?.message ?? "Dados inválidos.",
- };
- }
+ // Tratar erros de validação separadamente
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: error.issues[0]?.message ?? "Dados inválidos.",
+ };
+ }
- // Não expor detalhes do erro para o usuário
- return {
- success: false,
- error: "Não foi possível enviar o resumo. Tente novamente mais tarde.",
- };
- }
+ // Não expor detalhes do erro para o usuário
+ return {
+ success: false,
+ error: "Não foi possível enviar o resumo. Tente novamente mais tarde.",
+ };
+ }
}
diff --git a/app/(dashboard)/pagadores/[pagadorId]/data.ts b/app/(dashboard)/pagadores/[pagadorId]/data.ts
index accbbbf..bed5a83 100644
--- a/app/(dashboard)/pagadores/[pagadorId]/data.ts
+++ b/app/(dashboard)/pagadores/[pagadorId]/data.ts
@@ -1,90 +1,95 @@
-import { lancamentos, pagadorShares, user as usersTable, contas, cartoes, categorias, pagadores } from "@/db/schema";
-import { db } from "@/lib/db";
import { and, desc, eq, type SQL } from "drizzle-orm";
+import {
+ cartoes,
+ categorias,
+ contas,
+ lancamentos,
+ pagadores,
+ pagadorShares,
+ user as usersTable,
+} from "@/db/schema";
+import { db } from "@/lib/db";
export type ShareData = {
- id: string;
- userId: string;
- name: string;
- email: string;
- createdAt: string;
+ id: string;
+ userId: string;
+ name: string;
+ email: string;
+ createdAt: string;
};
export async function fetchPagadorShares(
- pagadorId: string
+ pagadorId: string,
): Promise {
- const shareRows = await db
- .select({
- id: pagadorShares.id,
- sharedWithUserId: pagadorShares.sharedWithUserId,
- createdAt: pagadorShares.createdAt,
- userName: usersTable.name,
- userEmail: usersTable.email,
- })
- .from(pagadorShares)
- .innerJoin(
- usersTable,
- eq(pagadorShares.sharedWithUserId, usersTable.id)
- )
- .where(eq(pagadorShares.pagadorId, pagadorId));
+ const shareRows = await db
+ .select({
+ id: pagadorShares.id,
+ sharedWithUserId: pagadorShares.sharedWithUserId,
+ createdAt: pagadorShares.createdAt,
+ userName: usersTable.name,
+ userEmail: usersTable.email,
+ })
+ .from(pagadorShares)
+ .innerJoin(usersTable, eq(pagadorShares.sharedWithUserId, usersTable.id))
+ .where(eq(pagadorShares.pagadorId, pagadorId));
- return shareRows.map((share) => ({
- id: share.id,
- userId: share.sharedWithUserId,
- name: share.userName ?? "Usuário",
- email: share.userEmail ?? "email não informado",
- createdAt: share.createdAt?.toISOString() ?? new Date().toISOString(),
- }));
+ return shareRows.map((share) => ({
+ id: share.id,
+ userId: share.sharedWithUserId,
+ name: share.userName ?? "Usuário",
+ email: share.userEmail ?? "email não informado",
+ createdAt: share.createdAt?.toISOString() ?? new Date().toISOString(),
+ }));
}
export async function fetchCurrentUserShare(
- pagadorId: string,
- userId: string
+ pagadorId: string,
+ userId: string,
): Promise<{ id: string; createdAt: string } | null> {
- const shareRow = await db.query.pagadorShares.findFirst({
- columns: {
- id: true,
- createdAt: true,
- },
- where: and(
- eq(pagadorShares.pagadorId, pagadorId),
- eq(pagadorShares.sharedWithUserId, userId)
- ),
- });
+ const shareRow = await db.query.pagadorShares.findFirst({
+ columns: {
+ id: true,
+ createdAt: true,
+ },
+ where: and(
+ eq(pagadorShares.pagadorId, pagadorId),
+ eq(pagadorShares.sharedWithUserId, userId),
+ ),
+ });
- if (!shareRow) {
- return null;
- }
+ if (!shareRow) {
+ return null;
+ }
- return {
- id: shareRow.id,
- createdAt: shareRow.createdAt?.toISOString() ?? new Date().toISOString(),
- };
+ return {
+ id: shareRow.id,
+ createdAt: shareRow.createdAt?.toISOString() ?? new Date().toISOString(),
+ };
}
export async function fetchPagadorLancamentos(filters: SQL[]) {
- const lancamentoRows = await db
- .select({
- lancamento: lancamentos,
- pagador: pagadores,
- conta: contas,
- cartao: cartoes,
- categoria: categorias,
- })
- .from(lancamentos)
- .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
- .leftJoin(contas, eq(lancamentos.contaId, contas.id))
- .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
- .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
- .where(and(...filters))
- .orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
+ const lancamentoRows = await db
+ .select({
+ lancamento: lancamentos,
+ pagador: pagadores,
+ conta: contas,
+ cartao: cartoes,
+ categoria: categorias,
+ })
+ .from(lancamentos)
+ .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
+ .leftJoin(contas, eq(lancamentos.contaId, contas.id))
+ .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
+ .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
+ .where(and(...filters))
+ .orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
- // Transformar resultado para o formato esperado
- return lancamentoRows.map((row: any) => ({
- ...row.lancamento,
- pagador: row.pagador,
- conta: row.conta,
- cartao: row.cartao,
- categoria: row.categoria,
- }));
+ // Transformar resultado para o formato esperado
+ return lancamentoRows.map((row: any) => ({
+ ...row.lancamento,
+ pagador: row.pagador,
+ conta: row.conta,
+ cartao: row.cartao,
+ categoria: row.categoria,
+ }));
}
diff --git a/app/(dashboard)/pagadores/[pagadorId]/loading.tsx b/app/(dashboard)/pagadores/[pagadorId]/loading.tsx
index 555c0dc..3623965 100644
--- a/app/(dashboard)/pagadores/[pagadorId]/loading.tsx
+++ b/app/(dashboard)/pagadores/[pagadorId]/loading.tsx
@@ -5,80 +5,80 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos)
*/
export default function PagadorDetailsLoading() {
- return (
-
- {/* Month Picker placeholder */}
-
+ return (
+
+ {/* Month Picker placeholder */}
+
- {/* Info do Pagador (sempre visível) */}
-
-
- {/* Avatar */}
-
+ {/* Info do Pagador (sempre visível) */}
+
+
+ {/* Avatar */}
+
-
- {/* Nome + Badge */}
-
-
-
-
+
+ {/* Nome + Badge */}
+
+
+
+
- {/* Email */}
-
+ {/* Email */}
+
- {/* Status */}
-
-
-
-
-
+ {/* Status */}
+
+
+
+
+
- {/* Botões de ação */}
-
-
-
-
-
-
+ {/* Botões de ação */}
+
+
+
+
+
+
- {/* Tabs */}
-
-
-
-
-
+ {/* Tabs */}
+
+
+
+
+
- {/* Conteúdo da aba Visão Geral (grid de cards) */}
-
- {/* Card de resumo mensal */}
-
-
-
- {Array.from({ length: 3 }).map((_, i) => (
-
-
-
-
- ))}
-
-
+ {/* Conteúdo da aba Visão Geral (grid de cards) */}
+
+ {/* Card de resumo mensal */}
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
- {/* Outros cards */}
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
-
-
- );
+ {/* Outros cards */}
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
}
diff --git a/app/(dashboard)/pagadores/[pagadorId]/page.tsx b/app/(dashboard)/pagadores/[pagadorId]/page.tsx
index 2d587ce..462ba81 100644
--- a/app/(dashboard)/pagadores/[pagadorId]/page.tsx
+++ b/app/(dashboard)/pagadores/[pagadorId]/page.tsx
@@ -1,435 +1,443 @@
+import { notFound } from "next/navigation";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
+import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
+import type {
+ ContaCartaoFilterOption,
+ LancamentoFilterOption,
+ LancamentoItem,
+ SelectOption,
+} from "@/components/lancamentos/types";
+import MonthNavigation from "@/components/month-picker/month-navigation";
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
+import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card";
import { PagadorBoletoCard } from "@/components/pagadores/details/pagador-payment-method-cards";
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
-import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
-import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
-import type {
- ContaCartaoFilterOption,
- LancamentoFilterOption,
- LancamentoItem,
- SelectOption,
-} from "@/components/lancamentos/types";
-import MonthNavigation from "@/components/month-picker/month-navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { pagadores } from "@/db/schema";
+import type { pagadores } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import {
- buildLancamentoWhere,
- buildOptionSets,
- buildSluggedFilters,
- buildSlugMaps,
- extractLancamentoSearchFilters,
- fetchLancamentoFilterSources,
- getSingleParam,
- mapLancamentosData,
- type LancamentoSearchFilters,
- type ResolvedSearchParams,
- type SlugMaps,
- type SluggedFilters,
+ buildLancamentoWhere,
+ buildOptionSets,
+ buildSluggedFilters,
+ buildSlugMaps,
+ extractLancamentoSearchFilters,
+ fetchLancamentoFilterSources,
+ getSingleParam,
+ type LancamentoSearchFilters,
+ mapLancamentosData,
+ type ResolvedSearchParams,
+ type SluggedFilters,
+ type SlugMaps,
} from "@/lib/lancamentos/page-helpers";
import { getPagadorAccess } from "@/lib/pagadores/access";
+import {
+ fetchPagadorBoletoStats,
+ fetchPagadorCardUsage,
+ fetchPagadorHistory,
+ fetchPagadorMonthlyBreakdown,
+} from "@/lib/pagadores/details";
import { parsePeriodParam } from "@/lib/utils/period";
import {
- fetchPagadorBoletoStats,
- fetchPagadorCardUsage,
- fetchPagadorHistory,
- fetchPagadorMonthlyBreakdown,
-} from "@/lib/pagadores/details";
-import { notFound } from "next/navigation";
-import { fetchPagadorLancamentos, fetchPagadorShares, fetchCurrentUserShare } from "./data";
+ fetchCurrentUserShare,
+ fetchPagadorLancamentos,
+ fetchPagadorShares,
+} from "./data";
type PageSearchParams = Promise;
type PageProps = {
- params: Promise<{ pagadorId: string }>;
- searchParams?: PageSearchParams;
+ params: Promise<{ pagadorId: string }>;
+ searchParams?: PageSearchParams;
};
const capitalize = (value: string) =>
- value.length ? value.charAt(0).toUpperCase().concat(value.slice(1)) : value;
+ value.length ? value.charAt(0).toUpperCase().concat(value.slice(1)) : value;
const EMPTY_FILTERS: LancamentoSearchFilters = {
- transactionFilter: null,
- conditionFilter: null,
- paymentFilter: null,
- pagadorFilter: null,
- categoriaFilter: null,
- contaCartaoFilter: null,
- searchFilter: null,
+ transactionFilter: null,
+ conditionFilter: null,
+ paymentFilter: null,
+ pagadorFilter: null,
+ categoriaFilter: null,
+ contaCartaoFilter: null,
+ searchFilter: null,
};
const createEmptySlugMaps = (): SlugMaps => ({
- pagador: new Map(),
- categoria: new Map(),
- conta: new Map(),
- cartao: new Map(),
+ pagador: new Map(),
+ categoria: new Map(),
+ conta: new Map(),
+ cartao: new Map(),
});
type OptionSet = ReturnType;
export default async function Page({ params, searchParams }: PageProps) {
- const { pagadorId } = await params;
- const userId = await getUserId();
- const resolvedSearchParams = searchParams ? await searchParams : undefined;
+ const { pagadorId } = await params;
+ const userId = await getUserId();
+ const resolvedSearchParams = searchParams ? await searchParams : undefined;
- const access = await getPagadorAccess(userId, pagadorId);
+ const access = await getPagadorAccess(userId, pagadorId);
- if (!access) {
- notFound();
- }
+ if (!access) {
+ notFound();
+ }
- const { pagador, canEdit } = access;
- const dataOwnerId = pagador.userId;
+ const { pagador, canEdit } = access;
+ const dataOwnerId = pagador.userId;
- const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
- const {
- period: selectedPeriod,
- monthName,
- year,
- } = parsePeriodParam(periodoParamRaw);
- const periodLabel = `${capitalize(monthName)} de ${year}`;
+ const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
+ const {
+ period: selectedPeriod,
+ monthName,
+ year,
+ } = parsePeriodParam(periodoParamRaw);
+ const periodLabel = `${capitalize(monthName)} de ${year}`;
- const allSearchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
- const searchFilters = canEdit
- ? allSearchFilters
- : {
- ...EMPTY_FILTERS,
- searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only
- };
+ const allSearchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
+ const searchFilters = canEdit
+ ? allSearchFilters
+ : {
+ ...EMPTY_FILTERS,
+ searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only
+ };
- let filterSources: Awaited<
- ReturnType
- > | null = null;
- let loggedUserFilterSources: Awaited<
- ReturnType
- > | null = null;
- let sluggedFilters: SluggedFilters;
- let slugMaps: SlugMaps;
+ let filterSources: Awaited<
+ ReturnType
+ > | null = null;
+ let loggedUserFilterSources: Awaited<
+ ReturnType
+ > | null = null;
+ let sluggedFilters: SluggedFilters;
+ let slugMaps: SlugMaps;
- if (canEdit) {
- filterSources = await fetchLancamentoFilterSources(dataOwnerId);
- sluggedFilters = buildSluggedFilters(filterSources);
- slugMaps = buildSlugMaps(sluggedFilters);
- } else {
- // Buscar opções do usuário logado para usar ao importar
- loggedUserFilterSources = await fetchLancamentoFilterSources(userId);
- sluggedFilters = {
- pagadorFiltersRaw: [],
- categoriaFiltersRaw: [],
- contaFiltersRaw: [],
- cartaoFiltersRaw: [],
- };
- slugMaps = createEmptySlugMaps();
- }
+ if (canEdit) {
+ filterSources = await fetchLancamentoFilterSources(dataOwnerId);
+ sluggedFilters = buildSluggedFilters(filterSources);
+ slugMaps = buildSlugMaps(sluggedFilters);
+ } else {
+ // Buscar opções do usuário logado para usar ao importar
+ loggedUserFilterSources = await fetchLancamentoFilterSources(userId);
+ sluggedFilters = {
+ pagadorFiltersRaw: [],
+ categoriaFiltersRaw: [],
+ contaFiltersRaw: [],
+ cartaoFiltersRaw: [],
+ };
+ slugMaps = createEmptySlugMaps();
+ }
- const filters = buildLancamentoWhere({
- userId: dataOwnerId,
- period: selectedPeriod,
- filters: searchFilters,
- slugMaps,
- pagadorId: pagador.id,
- });
+ const filters = buildLancamentoWhere({
+ userId: dataOwnerId,
+ period: selectedPeriod,
+ filters: searchFilters,
+ slugMaps,
+ pagadorId: pagador.id,
+ });
- const sharesPromise = canEdit
- ? fetchPagadorShares(pagador.id)
- : Promise.resolve([]);
+ const sharesPromise = canEdit
+ ? fetchPagadorShares(pagador.id)
+ : Promise.resolve([]);
- const currentUserSharePromise = !canEdit
- ? fetchCurrentUserShare(pagador.id, userId)
- : Promise.resolve(null);
+ const currentUserSharePromise = !canEdit
+ ? fetchCurrentUserShare(pagador.id, userId)
+ : Promise.resolve(null);
- const [
- lancamentoRows,
- monthlyBreakdown,
- historyData,
- cardUsage,
- boletoStats,
- shareRows,
- currentUserShare,
- estabelecimentos,
- ] = await Promise.all([
- fetchPagadorLancamentos(filters),
- fetchPagadorMonthlyBreakdown({
- userId: dataOwnerId,
- pagadorId: pagador.id,
- period: selectedPeriod,
- }),
- fetchPagadorHistory({
- userId: dataOwnerId,
- pagadorId: pagador.id,
- period: selectedPeriod,
- }),
- fetchPagadorCardUsage({
- userId: dataOwnerId,
- pagadorId: pagador.id,
- period: selectedPeriod,
- }),
- fetchPagadorBoletoStats({
- userId: dataOwnerId,
- pagadorId: pagador.id,
- period: selectedPeriod,
- }),
- sharesPromise,
- currentUserSharePromise,
- getRecentEstablishmentsAction(),
- ]);
+ const [
+ lancamentoRows,
+ monthlyBreakdown,
+ historyData,
+ cardUsage,
+ boletoStats,
+ shareRows,
+ currentUserShare,
+ estabelecimentos,
+ ] = await Promise.all([
+ fetchPagadorLancamentos(filters),
+ fetchPagadorMonthlyBreakdown({
+ userId: dataOwnerId,
+ pagadorId: pagador.id,
+ period: selectedPeriod,
+ }),
+ fetchPagadorHistory({
+ userId: dataOwnerId,
+ pagadorId: pagador.id,
+ period: selectedPeriod,
+ }),
+ fetchPagadorCardUsage({
+ userId: dataOwnerId,
+ pagadorId: pagador.id,
+ period: selectedPeriod,
+ }),
+ fetchPagadorBoletoStats({
+ userId: dataOwnerId,
+ pagadorId: pagador.id,
+ period: selectedPeriod,
+ }),
+ sharesPromise,
+ currentUserSharePromise,
+ getRecentEstablishmentsAction(),
+ ]);
- const mappedLancamentos = mapLancamentosData(lancamentoRows);
- const lancamentosData = canEdit
- ? mappedLancamentos
- : mappedLancamentos.map((item) => ({ ...item, readonly: true }));
+ const mappedLancamentos = mapLancamentosData(lancamentoRows);
+ const lancamentosData = canEdit
+ ? mappedLancamentos
+ : mappedLancamentos.map((item) => ({ ...item, readonly: true }));
- const pagadorSharesData = shareRows;
+ const pagadorSharesData = shareRows;
- let optionSets: OptionSet;
- let loggedUserOptionSets: OptionSet | null = null;
- let effectiveSluggedFilters = sluggedFilters;
+ let optionSets: OptionSet;
+ let loggedUserOptionSets: OptionSet | null = null;
+ let effectiveSluggedFilters = sluggedFilters;
- if (canEdit && filterSources) {
- optionSets = buildOptionSets({
- ...sluggedFilters,
- pagadorRows: filterSources.pagadorRows,
- });
- } else {
- effectiveSluggedFilters = {
- pagadorFiltersRaw: [
- {
- id: pagador.id,
- label: pagador.name,
- slug: pagador.id,
- role: pagador.role,
- },
- ],
- categoriaFiltersRaw: [],
- contaFiltersRaw: [],
- cartaoFiltersRaw: [],
- };
- optionSets = buildReadOnlyOptionSets(lancamentosData, pagador);
+ if (canEdit && filterSources) {
+ optionSets = buildOptionSets({
+ ...sluggedFilters,
+ pagadorRows: filterSources.pagadorRows,
+ });
+ } else {
+ effectiveSluggedFilters = {
+ pagadorFiltersRaw: [
+ {
+ id: pagador.id,
+ label: pagador.name,
+ slug: pagador.id,
+ role: pagador.role,
+ },
+ ],
+ categoriaFiltersRaw: [],
+ contaFiltersRaw: [],
+ cartaoFiltersRaw: [],
+ };
+ optionSets = buildReadOnlyOptionSets(lancamentosData, pagador);
- // Construir opções do usuário logado para usar ao importar
- if (loggedUserFilterSources) {
- const loggedUserSluggedFilters = buildSluggedFilters(loggedUserFilterSources);
- loggedUserOptionSets = buildOptionSets({
- ...loggedUserSluggedFilters,
- pagadorRows: loggedUserFilterSources.pagadorRows,
- });
- }
- }
+ // Construir opções do usuário logado para usar ao importar
+ if (loggedUserFilterSources) {
+ const loggedUserSluggedFilters = buildSluggedFilters(
+ loggedUserFilterSources,
+ );
+ loggedUserOptionSets = buildOptionSets({
+ ...loggedUserSluggedFilters,
+ pagadorRows: loggedUserFilterSources.pagadorRows,
+ });
+ }
+ }
- const pagadorSlug =
- effectiveSluggedFilters.pagadorFiltersRaw.find(
- (item) => item.id === pagador.id
- )?.slug ?? null;
+ const pagadorSlug =
+ effectiveSluggedFilters.pagadorFiltersRaw.find(
+ (item) => item.id === pagador.id,
+ )?.slug ?? null;
- const pagadorFilterOptions = pagadorSlug
- ? optionSets.pagadorFilterOptions.filter(
- (option) => option.slug === pagadorSlug
- )
- : optionSets.pagadorFilterOptions;
+ const pagadorFilterOptions = pagadorSlug
+ ? optionSets.pagadorFilterOptions.filter(
+ (option) => option.slug === pagadorSlug,
+ )
+ : optionSets.pagadorFilterOptions;
- const pagadorData = {
- id: pagador.id,
- name: pagador.name,
- email: pagador.email ?? null,
- avatarUrl: pagador.avatarUrl ?? null,
- status: pagador.status,
- note: pagador.note ?? null,
- role: pagador.role ?? null,
- isAutoSend: pagador.isAutoSend ?? false,
- createdAt: pagador.createdAt
- ? pagador.createdAt.toISOString()
- : new Date().toISOString(),
- lastMailAt: pagador.lastMailAt ? pagador.lastMailAt.toISOString() : null,
- shareCode: canEdit ? pagador.shareCode : null,
- canEdit,
- };
+ const pagadorData = {
+ id: pagador.id,
+ name: pagador.name,
+ email: pagador.email ?? null,
+ avatarUrl: pagador.avatarUrl ?? null,
+ status: pagador.status,
+ note: pagador.note ?? null,
+ role: pagador.role ?? null,
+ isAutoSend: pagador.isAutoSend ?? false,
+ createdAt: pagador.createdAt
+ ? pagador.createdAt.toISOString()
+ : new Date().toISOString(),
+ lastMailAt: pagador.lastMailAt ? pagador.lastMailAt.toISOString() : null,
+ shareCode: canEdit ? pagador.shareCode : null,
+ canEdit,
+ };
- const summaryPreview = {
- periodLabel,
- totalExpenses: monthlyBreakdown.totalExpenses,
- paymentSplits: monthlyBreakdown.paymentSplits,
- cardUsage: cardUsage.slice(0, 3).map((item) => ({
- name: item.name,
- amount: item.amount,
- })),
- boletoStats: {
- totalAmount: boletoStats.totalAmount,
- paidAmount: boletoStats.paidAmount,
- pendingAmount: boletoStats.pendingAmount,
- paidCount: boletoStats.paidCount,
- pendingCount: boletoStats.pendingCount,
- },
- lancamentoCount: lancamentosData.length,
- };
+ const summaryPreview = {
+ periodLabel,
+ totalExpenses: monthlyBreakdown.totalExpenses,
+ paymentSplits: monthlyBreakdown.paymentSplits,
+ cardUsage: cardUsage.slice(0, 3).map((item) => ({
+ name: item.name,
+ amount: item.amount,
+ })),
+ boletoStats: {
+ totalAmount: boletoStats.totalAmount,
+ paidAmount: boletoStats.paidAmount,
+ pendingAmount: boletoStats.pendingAmount,
+ paidCount: boletoStats.paidCount,
+ pendingCount: boletoStats.pendingCount,
+ },
+ lancamentoCount: lancamentosData.length,
+ };
- return (
-
-
+ return (
+
+
-
-
- Perfil
- Painel
- Lançamentos
-
+
+
+ Perfil
+ Painel
+ Lançamentos
+
-
-
- {canEdit && pagadorData.shareCode ? (
-
- ) : null}
- {!canEdit && currentUserShare ? (
-
- ) : null}
-
+
+
+ {canEdit && pagadorData.shareCode ? (
+
+ ) : null}
+ {!canEdit && currentUserShare ? (
+
+ ) : null}
+
-
-
+
+
-
-
+
+
-
-
-
-
-
- );
+
+
+
+
+
+ );
}
const normalizeOptionLabel = (
- value: string | null | undefined,
- fallback: string
+ value: string | null | undefined,
+ fallback: string,
) => (value?.trim().length ? value.trim() : fallback);
function buildReadOnlyOptionSets(
- items: LancamentoItem[],
- pagador: typeof pagadores.$inferSelect
+ items: LancamentoItem[],
+ pagador: typeof pagadores.$inferSelect,
): OptionSet {
- const pagadorLabel = normalizeOptionLabel(pagador.name, "Pagador");
- const pagadorOptions: SelectOption[] = [
- {
- value: pagador.id,
- label: pagadorLabel,
- slug: pagador.id,
- },
- ];
+ const pagadorLabel = normalizeOptionLabel(pagador.name, "Pagador");
+ const pagadorOptions: SelectOption[] = [
+ {
+ value: pagador.id,
+ label: pagadorLabel,
+ slug: pagador.id,
+ },
+ ];
- const contaOptionsMap = new Map();
- const cartaoOptionsMap = new Map();
- const categoriaOptionsMap = new Map();
+ const contaOptionsMap = new Map();
+ const cartaoOptionsMap = new Map();
+ const categoriaOptionsMap = new Map();
- items.forEach((item) => {
- if (item.contaId && !contaOptionsMap.has(item.contaId)) {
- contaOptionsMap.set(item.contaId, {
- value: item.contaId,
- label: normalizeOptionLabel(item.contaName, "Conta sem nome"),
- slug: item.contaId,
- });
- }
- if (item.cartaoId && !cartaoOptionsMap.has(item.cartaoId)) {
- cartaoOptionsMap.set(item.cartaoId, {
- value: item.cartaoId,
- label: normalizeOptionLabel(item.cartaoName, "Cartão sem nome"),
- slug: item.cartaoId,
- });
- }
- if (item.categoriaId && !categoriaOptionsMap.has(item.categoriaId)) {
- categoriaOptionsMap.set(item.categoriaId, {
- value: item.categoriaId,
- label: normalizeOptionLabel(item.categoriaName, "Categoria"),
- slug: item.categoriaId,
- });
- }
- });
+ items.forEach((item) => {
+ if (item.contaId && !contaOptionsMap.has(item.contaId)) {
+ contaOptionsMap.set(item.contaId, {
+ value: item.contaId,
+ label: normalizeOptionLabel(item.contaName, "Conta sem nome"),
+ slug: item.contaId,
+ });
+ }
+ if (item.cartaoId && !cartaoOptionsMap.has(item.cartaoId)) {
+ cartaoOptionsMap.set(item.cartaoId, {
+ value: item.cartaoId,
+ label: normalizeOptionLabel(item.cartaoName, "Cartão sem nome"),
+ slug: item.cartaoId,
+ });
+ }
+ if (item.categoriaId && !categoriaOptionsMap.has(item.categoriaId)) {
+ categoriaOptionsMap.set(item.categoriaId, {
+ value: item.categoriaId,
+ label: normalizeOptionLabel(item.categoriaName, "Categoria"),
+ slug: item.categoriaId,
+ });
+ }
+ });
- const contaOptions = Array.from(contaOptionsMap.values());
- const cartaoOptions = Array.from(cartaoOptionsMap.values());
- const categoriaOptions = Array.from(categoriaOptionsMap.values());
+ const contaOptions = Array.from(contaOptionsMap.values());
+ const cartaoOptions = Array.from(cartaoOptionsMap.values());
+ const categoriaOptions = Array.from(categoriaOptionsMap.values());
- const pagadorFilterOptions: LancamentoFilterOption[] = [
- { slug: pagador.id, label: pagadorLabel },
- ];
+ const pagadorFilterOptions: LancamentoFilterOption[] = [
+ { slug: pagador.id, label: pagadorLabel },
+ ];
- const categoriaFilterOptions: LancamentoFilterOption[] = categoriaOptions.map(
- (option) => ({
- slug: option.value,
- label: option.label,
- })
- );
+ const categoriaFilterOptions: LancamentoFilterOption[] = categoriaOptions.map(
+ (option) => ({
+ slug: option.value,
+ label: option.label,
+ }),
+ );
- const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [
- ...contaOptions.map((option) => ({
- slug: option.value,
- label: option.label,
- kind: "conta" as const,
- })),
- ...cartaoOptions.map((option) => ({
- slug: option.value,
- label: option.label,
- kind: "cartao" as const,
- })),
- ];
+ const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [
+ ...contaOptions.map((option) => ({
+ slug: option.value,
+ label: option.label,
+ kind: "conta" as const,
+ })),
+ ...cartaoOptions.map((option) => ({
+ slug: option.value,
+ label: option.label,
+ kind: "cartao" as const,
+ })),
+ ];
- return {
- pagadorOptions,
- splitPagadorOptions: [],
- defaultPagadorId: pagador.id,
- contaOptions,
- cartaoOptions,
- categoriaOptions,
- pagadorFilterOptions,
- categoriaFilterOptions,
- contaCartaoFilterOptions,
- };
+ return {
+ pagadorOptions,
+ splitPagadorOptions: [],
+ defaultPagadorId: pagador.id,
+ contaOptions,
+ cartaoOptions,
+ categoriaOptions,
+ pagadorFilterOptions,
+ categoriaFilterOptions,
+ contaCartaoFilterOptions,
+ };
}
diff --git a/app/(dashboard)/pagadores/actions.ts b/app/(dashboard)/pagadores/actions.ts
index 25b77dc..f7a1b08 100644
--- a/app/(dashboard)/pagadores/actions.ts
+++ b/app/(dashboard)/pagadores/actions.ts
@@ -1,70 +1,70 @@
"use server";
+import { randomBytes } from "node:crypto";
+import { and, eq } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import { z } from "zod";
import { pagadores, pagadorShares, user } from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
-import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
+import { db } from "@/lib/db";
import {
- DEFAULT_PAGADOR_AVATAR,
- PAGADOR_ROLE_ADMIN,
- PAGADOR_ROLE_TERCEIRO,
- PAGADOR_STATUS_OPTIONS,
+ DEFAULT_PAGADOR_AVATAR,
+ PAGADOR_ROLE_ADMIN,
+ PAGADOR_ROLE_TERCEIRO,
+ PAGADOR_STATUS_OPTIONS,
} from "@/lib/pagadores/constants";
import { normalizeAvatarPath } from "@/lib/pagadores/utils";
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
import { normalizeOptionalString } from "@/lib/utils/string";
-import { and, eq } from "drizzle-orm";
-import { revalidatePath } from "next/cache";
-import { randomBytes } from "node:crypto";
-import { z } from "zod";
const statusEnum = z.enum(PAGADOR_STATUS_OPTIONS as [string, ...string[]], {
- errorMap: () => ({
- message: "Selecione um status válido.",
- }),
+ errorMap: () => ({
+ message: "Selecione um status válido.",
+ }),
});
const baseSchema = z.object({
- name: z
- .string({ message: "Informe o nome do pagador." })
- .trim()
- .min(1, "Informe o nome do pagador."),
- email: z
- .string()
- .trim()
- .email("Informe um e-mail válido.")
- .optional()
- .transform((value) => normalizeOptionalString(value)),
- status: statusEnum,
- note: noteSchema,
- avatarUrl: z.string().trim().optional(),
- isAutoSend: z.boolean().optional().default(false),
+ name: z
+ .string({ message: "Informe o nome do pagador." })
+ .trim()
+ .min(1, "Informe o nome do pagador."),
+ email: z
+ .string()
+ .trim()
+ .email("Informe um e-mail válido.")
+ .optional()
+ .transform((value) => normalizeOptionalString(value)),
+ status: statusEnum,
+ note: noteSchema,
+ avatarUrl: z.string().trim().optional(),
+ isAutoSend: z.boolean().optional().default(false),
});
const createSchema = baseSchema;
const updateSchema = baseSchema.extend({
- id: uuidSchema("Pagador"),
+ id: uuidSchema("Pagador"),
});
const deleteSchema = z.object({
- id: uuidSchema("Pagador"),
+ id: uuidSchema("Pagador"),
});
const shareDeleteSchema = z.object({
- shareId: uuidSchema("Compartilhamento"),
+ shareId: uuidSchema("Compartilhamento"),
});
const shareCodeJoinSchema = z.object({
- code: z
- .string({ message: "Informe o código." })
- .trim()
- .min(8, "Código inválido."),
+ code: z
+ .string({ message: "Informe o código." })
+ .trim()
+ .min(8, "Código inválido."),
});
const shareCodeRegenerateSchema = z.object({
- pagadorId: uuidSchema("Pagador"),
+ pagadorId: uuidSchema("Pagador"),
});
type CreateInput = z.infer;
@@ -77,271 +77,286 @@ type ShareCodeRegenerateInput = z.infer;
const revalidate = () => revalidateForEntity("pagadores");
const generateShareCode = () => {
- // base64url já retorna apenas [a-zA-Z0-9_-]
- // 18 bytes = 24 caracteres em base64
- return randomBytes(18).toString("base64url").slice(0, 24);
+ // base64url já retorna apenas [a-zA-Z0-9_-]
+ // 18 bytes = 24 caracteres em base64
+ return randomBytes(18).toString("base64url").slice(0, 24);
};
export async function createPagadorAction(
- input: CreateInput
+ input: CreateInput,
): Promise {
- try {
- const user = await getUser();
- const data = createSchema.parse(input);
+ try {
+ const user = await getUser();
+ const data = createSchema.parse(input);
- await db.insert(pagadores).values({
- name: data.name,
- email: data.email,
- status: data.status,
- note: data.note,
- avatarUrl: normalizeAvatarPath(data.avatarUrl) ?? DEFAULT_PAGADOR_AVATAR,
- isAutoSend: data.isAutoSend ?? false,
- role: PAGADOR_ROLE_TERCEIRO,
- shareCode: generateShareCode(),
- userId: user.id,
- });
+ await db.insert(pagadores).values({
+ name: data.name,
+ email: data.email,
+ status: data.status,
+ note: data.note,
+ avatarUrl: normalizeAvatarPath(data.avatarUrl) ?? DEFAULT_PAGADOR_AVATAR,
+ isAutoSend: data.isAutoSend ?? false,
+ role: PAGADOR_ROLE_TERCEIRO,
+ shareCode: generateShareCode(),
+ userId: user.id,
+ });
- revalidate();
+ revalidate();
- return { success: true, message: "Pagador criado com sucesso." };
- } catch (error) {
- return handleActionError(error);
- }
+ return { success: true, message: "Pagador criado com sucesso." };
+ } catch (error) {
+ return handleActionError(error);
+ }
}
export async function updatePagadorAction(
- input: UpdateInput
+ input: UpdateInput,
): Promise {
- try {
- const currentUser = await getUser();
- const data = updateSchema.parse(input);
+ try {
+ const currentUser = await getUser();
+ const data = updateSchema.parse(input);
- const existing = await db.query.pagadores.findFirst({
- where: and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)),
- });
+ const existing = await db.query.pagadores.findFirst({
+ where: and(
+ eq(pagadores.id, data.id),
+ eq(pagadores.userId, currentUser.id),
+ ),
+ });
- if (!existing) {
- return {
- success: false,
- error: "Pagador não encontrado.",
- };
- }
+ if (!existing) {
+ return {
+ success: false,
+ error: "Pagador não encontrado.",
+ };
+ }
- await db
- .update(pagadores)
- .set({
- name: data.name,
- email: data.email,
- status: data.status,
- note: data.note,
- avatarUrl:
- normalizeAvatarPath(data.avatarUrl) ?? existing.avatarUrl ?? null,
- isAutoSend: data.isAutoSend ?? false,
- role: existing.role ?? PAGADOR_ROLE_TERCEIRO,
- })
- .where(and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)));
+ await db
+ .update(pagadores)
+ .set({
+ name: data.name,
+ email: data.email,
+ status: data.status,
+ note: data.note,
+ avatarUrl:
+ normalizeAvatarPath(data.avatarUrl) ?? existing.avatarUrl ?? null,
+ isAutoSend: data.isAutoSend ?? false,
+ role: existing.role ?? PAGADOR_ROLE_TERCEIRO,
+ })
+ .where(
+ and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)),
+ );
- // Se o pagador é admin, sincronizar nome com o usuário
- if (existing.role === PAGADOR_ROLE_ADMIN) {
- await db
- .update(user)
- .set({ name: data.name })
- .where(eq(user.id, currentUser.id));
+ // Se o pagador é admin, sincronizar nome com o usuário
+ if (existing.role === PAGADOR_ROLE_ADMIN) {
+ await db
+ .update(user)
+ .set({ name: data.name })
+ .where(eq(user.id, currentUser.id));
- revalidatePath("/", "layout");
- }
+ revalidatePath("/", "layout");
+ }
- revalidate();
+ revalidate();
- return { success: true, message: "Pagador atualizado com sucesso." };
- } catch (error) {
- return handleActionError(error);
- }
+ return { success: true, message: "Pagador atualizado com sucesso." };
+ } catch (error) {
+ return handleActionError(error);
+ }
}
export async function deletePagadorAction(
- input: DeleteInput
+ input: DeleteInput,
): Promise {
- try {
- const user = await getUser();
- const data = deleteSchema.parse(input);
+ try {
+ const user = await getUser();
+ const data = deleteSchema.parse(input);
- const existing = await db.query.pagadores.findFirst({
- where: and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)),
- });
+ const existing = await db.query.pagadores.findFirst({
+ where: and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)),
+ });
- if (!existing) {
- return {
- success: false,
- error: "Pagador não encontrado.",
- };
- }
+ if (!existing) {
+ return {
+ success: false,
+ error: "Pagador não encontrado.",
+ };
+ }
- if (existing.role === PAGADOR_ROLE_ADMIN) {
- return {
- success: false,
- error: "Pagadores administradores não podem ser removidos.",
- };
- }
+ if (existing.role === PAGADOR_ROLE_ADMIN) {
+ return {
+ success: false,
+ error: "Pagadores administradores não podem ser removidos.",
+ };
+ }
- await db
- .delete(pagadores)
- .where(and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)));
+ await db
+ .delete(pagadores)
+ .where(and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)));
- revalidate();
+ revalidate();
- return { success: true, message: "Pagador removido com sucesso." };
- } catch (error) {
- return handleActionError(error);
- }
+ return { success: true, message: "Pagador removido com sucesso." };
+ } catch (error) {
+ return handleActionError(error);
+ }
}
export async function joinPagadorByShareCodeAction(
- input: ShareCodeJoinInput
+ input: ShareCodeJoinInput,
): Promise {
- try {
- const user = await getUser();
- const data = shareCodeJoinSchema.parse(input);
+ try {
+ const user = await getUser();
+ const data = shareCodeJoinSchema.parse(input);
- const pagadorRow = await db.query.pagadores.findFirst({
- where: eq(pagadores.shareCode, data.code),
- });
+ const pagadorRow = await db.query.pagadores.findFirst({
+ where: eq(pagadores.shareCode, data.code),
+ });
- if (!pagadorRow) {
- return { success: false, error: "Código inválido ou expirado." };
- }
+ if (!pagadorRow) {
+ return { success: false, error: "Código inválido ou expirado." };
+ }
- if (pagadorRow.userId === user.id) {
- return {
- success: false,
- error: "Você já é o proprietário deste pagador.",
- };
- }
+ if (pagadorRow.userId === user.id) {
+ return {
+ success: false,
+ error: "Você já é o proprietário deste pagador.",
+ };
+ }
- const existingShare = await db.query.pagadorShares.findFirst({
- where: and(
- eq(pagadorShares.pagadorId, pagadorRow.id),
- eq(pagadorShares.sharedWithUserId, user.id)
- ),
- });
+ const existingShare = await db.query.pagadorShares.findFirst({
+ where: and(
+ eq(pagadorShares.pagadorId, pagadorRow.id),
+ eq(pagadorShares.sharedWithUserId, user.id),
+ ),
+ });
- if (existingShare) {
- return {
- success: false,
- error: "Você já possui acesso a este pagador.",
- };
- }
+ if (existingShare) {
+ return {
+ success: false,
+ error: "Você já possui acesso a este pagador.",
+ };
+ }
- await db.insert(pagadorShares).values({
- pagadorId: pagadorRow.id,
- sharedWithUserId: user.id,
- permission: "read",
- createdByUserId: pagadorRow.userId,
- });
+ await db.insert(pagadorShares).values({
+ pagadorId: pagadorRow.id,
+ sharedWithUserId: user.id,
+ permission: "read",
+ createdByUserId: pagadorRow.userId,
+ });
- revalidate();
+ revalidate();
- return { success: true, message: "Pagador adicionado à sua lista." };
- } catch (error) {
- return handleActionError(error);
- }
+ return { success: true, message: "Pagador adicionado à sua lista." };
+ } catch (error) {
+ return handleActionError(error);
+ }
}
export async function deletePagadorShareAction(
- input: ShareDeleteInput
+ input: ShareDeleteInput,
): Promise {
- try {
- const user = await getUser();
- const data = shareDeleteSchema.parse(input);
+ try {
+ const user = await getUser();
+ const data = shareDeleteSchema.parse(input);
- const existing = await db.query.pagadorShares.findFirst({
- columns: {
- id: true,
- pagadorId: true,
- sharedWithUserId: true,
- },
- where: eq(pagadorShares.id, data.shareId),
- with: {
- pagador: {
- columns: {
- userId: true,
- },
- },
- },
- });
+ const existing = await db.query.pagadorShares.findFirst({
+ columns: {
+ id: true,
+ pagadorId: true,
+ sharedWithUserId: true,
+ },
+ where: eq(pagadorShares.id, data.shareId),
+ with: {
+ pagador: {
+ columns: {
+ userId: true,
+ },
+ },
+ },
+ });
- // Permitir que o owner OU o próprio usuário compartilhado remova o share
- if (!existing || (existing.pagador.userId !== user.id && existing.sharedWithUserId !== user.id)) {
- return {
- success: false,
- error: "Compartilhamento não encontrado.",
- };
- }
+ // Permitir que o owner OU o próprio usuário compartilhado remova o share
+ if (
+ !existing ||
+ (existing.pagador.userId !== user.id &&
+ existing.sharedWithUserId !== user.id)
+ ) {
+ return {
+ success: false,
+ error: "Compartilhamento não encontrado.",
+ };
+ }
- await db
- .delete(pagadorShares)
- .where(eq(pagadorShares.id, data.shareId));
+ await db.delete(pagadorShares).where(eq(pagadorShares.id, data.shareId));
- revalidate();
- revalidatePath(`/pagadores/${existing.pagadorId}`);
+ revalidate();
+ revalidatePath(`/pagadores/${existing.pagadorId}`);
- return { success: true, message: "Compartilhamento removido." };
- } catch (error) {
- return handleActionError(error);
- }
+ return { success: true, message: "Compartilhamento removido." };
+ } catch (error) {
+ return handleActionError(error);
+ }
}
export async function regeneratePagadorShareCodeAction(
- input: ShareCodeRegenerateInput
+ input: ShareCodeRegenerateInput,
): Promise<{ success: true; message: string; code: string } | ActionResult> {
- try {
- const user = await getUser();
- const data = shareCodeRegenerateSchema.parse(input);
+ try {
+ const user = await getUser();
+ const data = shareCodeRegenerateSchema.parse(input);
- const existing = await db.query.pagadores.findFirst({
- columns: { id: true, userId: true },
- where: and(eq(pagadores.id, data.pagadorId), eq(pagadores.userId, user.id)),
- });
+ const existing = await db.query.pagadores.findFirst({
+ columns: { id: true, userId: true },
+ where: and(
+ eq(pagadores.id, data.pagadorId),
+ eq(pagadores.userId, user.id),
+ ),
+ });
- if (!existing) {
- return { success: false, error: "Pagador não encontrado." };
- }
+ if (!existing) {
+ return { success: false, error: "Pagador não encontrado." };
+ }
- let attempts = 0;
- while (attempts < 5) {
- const newCode = generateShareCode();
- try {
- await db
- .update(pagadores)
- .set({ shareCode: newCode })
- .where(and(eq(pagadores.id, data.pagadorId), eq(pagadores.userId, user.id)));
+ let attempts = 0;
+ while (attempts < 5) {
+ const newCode = generateShareCode();
+ try {
+ await db
+ .update(pagadores)
+ .set({ shareCode: newCode })
+ .where(
+ and(
+ eq(pagadores.id, data.pagadorId),
+ eq(pagadores.userId, user.id),
+ ),
+ );
- revalidate();
- revalidatePath(`/pagadores/${data.pagadorId}`);
- return {
- success: true,
- message: "Código atualizado com sucesso.",
- code: newCode,
- };
- } catch (error) {
- if (
- error instanceof Error &&
- "constraint" in error &&
- // @ts-expect-error constraint is present in postgres errors
- error.constraint === "pagadores_share_code_key"
- ) {
- attempts += 1;
- continue;
- }
- throw error;
- }
- }
+ revalidate();
+ revalidatePath(`/pagadores/${data.pagadorId}`);
+ return {
+ success: true,
+ message: "Código atualizado com sucesso.",
+ code: newCode,
+ };
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ "constraint" in error &&
+ // @ts-expect-error constraint is present in postgres errors
+ error.constraint === "pagadores_share_code_key"
+ ) {
+ attempts += 1;
+ continue;
+ }
+ throw error;
+ }
+ }
- return {
- success: false,
- error: "Não foi possível gerar um código único. Tente novamente.",
- };
- } catch (error) {
- return handleActionError(error);
- }
+ return {
+ success: false,
+ error: "Não foi possível gerar um código único. Tente novamente.",
+ };
+ } catch (error) {
+ return handleActionError(error);
+ }
}
diff --git a/app/(dashboard)/pagadores/layout.tsx b/app/(dashboard)/pagadores/layout.tsx
index b7fb12d..6e12a23 100644
--- a/app/(dashboard)/pagadores/layout.tsx
+++ b/app/(dashboard)/pagadores/layout.tsx
@@ -1,23 +1,23 @@
-import PageDescription from "@/components/page-description";
import { RiGroupLine } from "@remixicon/react";
+import PageDescription from "@/components/page-description";
export const metadata = {
- title: "Pagadores | Opensheets",
+ title: "Pagadores | Opensheets",
};
export default function RootLayout({
- children,
+ children,
}: {
- children: React.ReactNode;
+ children: React.ReactNode;
}) {
- return (
-
- }
- title="Pagadores"
- subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos."
- />
- {children}
-
- );
+ return (
+
+ }
+ title="Pagadores"
+ subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos."
+ />
+ {children}
+
+ );
}
diff --git a/app/(dashboard)/pagadores/loading.tsx b/app/(dashboard)/pagadores/loading.tsx
index 85fa4b5..bbf3c35 100644
--- a/app/(dashboard)/pagadores/loading.tsx
+++ b/app/(dashboard)/pagadores/loading.tsx
@@ -5,53 +5,53 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: Header + Input de compartilhamento + Grid de cards
*/
export default function PagadoresLoading() {
- return (
-
-
- {/* Input de código de compartilhamento */}
-
+ return (
+
+
+ {/* Input de código de compartilhamento */}
+
- {/* Grid de cards de pagadores */}
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- {/* Avatar + Nome + Badge */}
-
-
-
-
-
-
- {i === 0 && (
-
- )}
-
+ {/* Grid de cards de pagadores */}
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ {/* Avatar + Nome + Badge */}
+
+
+
+
+
+
+ {i === 0 && (
+
+ )}
+
- {/* Email */}
-
+ {/* Email */}
+
- {/* Status */}
-
-
-
-
+ {/* Status */}
+
+
+
+
- {/* Botões de ação */}
-
-
-
-
-
-
- ))}
-
-
-
- );
+ {/* Botões de ação */}
+
+
+
+
+
+
+ ))}
+
+
+
+ );
}
diff --git a/app/(dashboard)/pagadores/page.tsx b/app/(dashboard)/pagadores/page.tsx
index 7ff25b9..1890cdd 100644
--- a/app/(dashboard)/pagadores/page.tsx
+++ b/app/(dashboard)/pagadores/page.tsx
@@ -1,86 +1,86 @@
-import { PagadoresPage } from "@/components/pagadores/pagadores-page";
-import type { PagadorStatus } from "@/lib/pagadores/constants";
-import {
- PAGADOR_STATUS_OPTIONS,
- DEFAULT_PAGADOR_AVATAR,
- PAGADOR_ROLE_ADMIN,
-} from "@/lib/pagadores/constants";
-import { getUserId } from "@/lib/auth/server";
-import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
import { readdir } from "node:fs/promises";
import path from "node:path";
+import { PagadoresPage } from "@/components/pagadores/pagadores-page";
+import { getUserId } from "@/lib/auth/server";
+import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
+import type { PagadorStatus } from "@/lib/pagadores/constants";
+import {
+ DEFAULT_PAGADOR_AVATAR,
+ PAGADOR_ROLE_ADMIN,
+ PAGADOR_STATUS_OPTIONS,
+} from "@/lib/pagadores/constants";
const AVATAR_DIRECTORY = path.join(process.cwd(), "public", "avatares");
const AVATAR_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
async function loadAvatarOptions() {
- try {
- const files = await readdir(AVATAR_DIRECTORY, { withFileTypes: true });
+ try {
+ const files = await readdir(AVATAR_DIRECTORY, { withFileTypes: true });
- const items = files
- .filter((file) => file.isFile())
- .map((file) => file.name)
- .filter((file) => AVATAR_EXTENSIONS.has(path.extname(file).toLowerCase()))
- .sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
+ const items = files
+ .filter((file) => file.isFile())
+ .map((file) => file.name)
+ .filter((file) => AVATAR_EXTENSIONS.has(path.extname(file).toLowerCase()))
+ .sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
- if (items.length === 0) {
- items.push(DEFAULT_PAGADOR_AVATAR);
- }
+ if (items.length === 0) {
+ items.push(DEFAULT_PAGADOR_AVATAR);
+ }
- return Array.from(new Set(items));
- } catch {
- return [DEFAULT_PAGADOR_AVATAR];
- }
+ return Array.from(new Set(items));
+ } catch {
+ return [DEFAULT_PAGADOR_AVATAR];
+ }
}
const resolveStatus = (status: string | null): PagadorStatus => {
- const normalized = status?.trim() ?? "";
- const found = PAGADOR_STATUS_OPTIONS.find(
- (option) => option.toLowerCase() === normalized.toLowerCase()
- );
- return found ?? PAGADOR_STATUS_OPTIONS[0];
+ const normalized = status?.trim() ?? "";
+ const found = PAGADOR_STATUS_OPTIONS.find(
+ (option) => option.toLowerCase() === normalized.toLowerCase(),
+ );
+ return found ?? PAGADOR_STATUS_OPTIONS[0];
};
export default async function Page() {
- const userId = await getUserId();
+ const userId = await getUserId();
- const [pagadorRows, avatarOptions] = await Promise.all([
- fetchPagadoresWithAccess(userId),
- loadAvatarOptions(),
- ]);
+ const [pagadorRows, avatarOptions] = await Promise.all([
+ fetchPagadoresWithAccess(userId),
+ loadAvatarOptions(),
+ ]);
- const pagadoresData = pagadorRows
- .map((pagador) => ({
- id: pagador.id,
- name: pagador.name,
- email: pagador.email,
- avatarUrl: pagador.avatarUrl,
- status: resolveStatus(pagador.status),
- note: pagador.note,
- role: pagador.role,
- isAutoSend: pagador.isAutoSend ?? false,
- createdAt: pagador.createdAt?.toISOString() ?? new Date().toISOString(),
- canEdit: pagador.canEdit,
- sharedByName: pagador.sharedByName ?? null,
- sharedByEmail: pagador.sharedByEmail ?? null,
- shareId: pagador.shareId ?? null,
- shareCode: pagador.canEdit ? pagador.shareCode ?? null : null,
- }))
- .sort((a, b) => {
- // Admin sempre primeiro
- if (a.role === PAGADOR_ROLE_ADMIN && b.role !== PAGADOR_ROLE_ADMIN) {
- return -1;
- }
- if (a.role !== PAGADOR_ROLE_ADMIN && b.role === PAGADOR_ROLE_ADMIN) {
- return 1;
- }
- // Se ambos são admin ou ambos não são, mantém ordem original
- return 0;
- });
+ const pagadoresData = pagadorRows
+ .map((pagador) => ({
+ id: pagador.id,
+ name: pagador.name,
+ email: pagador.email,
+ avatarUrl: pagador.avatarUrl,
+ status: resolveStatus(pagador.status),
+ note: pagador.note,
+ role: pagador.role,
+ isAutoSend: pagador.isAutoSend ?? false,
+ createdAt: pagador.createdAt?.toISOString() ?? new Date().toISOString(),
+ canEdit: pagador.canEdit,
+ sharedByName: pagador.sharedByName ?? null,
+ sharedByEmail: pagador.sharedByEmail ?? null,
+ shareId: pagador.shareId ?? null,
+ shareCode: pagador.canEdit ? (pagador.shareCode ?? null) : null,
+ }))
+ .sort((a, b) => {
+ // Admin sempre primeiro
+ if (a.role === PAGADOR_ROLE_ADMIN && b.role !== PAGADOR_ROLE_ADMIN) {
+ return -1;
+ }
+ if (a.role !== PAGADOR_ROLE_ADMIN && b.role === PAGADOR_ROLE_ADMIN) {
+ return 1;
+ }
+ // Se ambos são admin ou ambos não são, mantém ordem original
+ return 0;
+ });
- return (
-
-
-
- );
+ return (
+
+
+
+ );
}
diff --git a/app/(dashboard)/pre-lancamentos/actions.ts b/app/(dashboard)/pre-lancamentos/actions.ts
index 7f560d0..436597c 100644
--- a/app/(dashboard)/pre-lancamentos/actions.ts
+++ b/app/(dashboard)/pre-lancamentos/actions.ts
@@ -1,149 +1,149 @@
"use server";
+import { and, eq, inArray } from "drizzle-orm";
+import { revalidatePath } from "next/cache";
+import { z } from "zod";
import { inboxItems } from "@/db/schema";
import { handleActionError } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
-import { and, eq, inArray } from "drizzle-orm";
-import { revalidatePath } from "next/cache";
-import { z } from "zod";
const markProcessedSchema = z.object({
- inboxItemId: z.string().uuid("ID do item inválido"),
+ inboxItemId: z.string().uuid("ID do item inválido"),
});
const discardInboxSchema = z.object({
- inboxItemId: z.string().uuid("ID do item inválido"),
+ inboxItemId: z.string().uuid("ID do item inválido"),
});
const bulkDiscardSchema = z.object({
- inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"),
+ inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"),
});
function revalidateInbox() {
- revalidatePath("/pre-lancamentos");
- revalidatePath("/lancamentos");
- revalidatePath("/dashboard");
+ revalidatePath("/pre-lancamentos");
+ revalidatePath("/lancamentos");
+ revalidatePath("/dashboard");
}
/**
* Mark an inbox item as processed after a lancamento was created
*/
export async function markInboxAsProcessedAction(
- input: z.infer,
+ input: z.infer,
): Promise {
- try {
- const user = await getUser();
- const data = markProcessedSchema.parse(input);
+ try {
+ const user = await getUser();
+ const data = markProcessedSchema.parse(input);
- // Verificar se item existe e pertence ao usuário
- const [item] = await db
- .select()
- .from(inboxItems)
- .where(
- and(
- eq(inboxItems.id, data.inboxItemId),
- eq(inboxItems.userId, user.id),
- eq(inboxItems.status, "pending"),
- ),
- )
- .limit(1);
+ // Verificar se item existe e pertence ao usuário
+ const [item] = await db
+ .select()
+ .from(inboxItems)
+ .where(
+ and(
+ eq(inboxItems.id, data.inboxItemId),
+ eq(inboxItems.userId, user.id),
+ eq(inboxItems.status, "pending"),
+ ),
+ )
+ .limit(1);
- if (!item) {
- return { success: false, error: "Item não encontrado ou já processado." };
- }
+ if (!item) {
+ return { success: false, error: "Item não encontrado ou já processado." };
+ }
- // Marcar item como processado
- await db
- .update(inboxItems)
- .set({
- status: "processed",
- processedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(inboxItems.id, data.inboxItemId));
+ // Marcar item como processado
+ await db
+ .update(inboxItems)
+ .set({
+ status: "processed",
+ processedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(inboxItems.id, data.inboxItemId));
- revalidateInbox();
+ revalidateInbox();
- return { success: true, message: "Item processado com sucesso!" };
- } catch (error) {
- return handleActionError(error);
- }
+ return { success: true, message: "Item processado com sucesso!" };
+ } catch (error) {
+ return handleActionError(error);
+ }
}
export async function discardInboxItemAction(
- input: z.infer,
+ input: z.infer,
): Promise {
- try {
- const user = await getUser();
- const data = discardInboxSchema.parse(input);
+ try {
+ const user = await getUser();
+ const data = discardInboxSchema.parse(input);
- // Verificar se item existe e pertence ao usuário
- const [item] = await db
- .select()
- .from(inboxItems)
- .where(
- and(
- eq(inboxItems.id, data.inboxItemId),
- eq(inboxItems.userId, user.id),
- eq(inboxItems.status, "pending"),
- ),
- )
- .limit(1);
+ // Verificar se item existe e pertence ao usuário
+ const [item] = await db
+ .select()
+ .from(inboxItems)
+ .where(
+ and(
+ eq(inboxItems.id, data.inboxItemId),
+ eq(inboxItems.userId, user.id),
+ eq(inboxItems.status, "pending"),
+ ),
+ )
+ .limit(1);
- if (!item) {
- return { success: false, error: "Item não encontrado ou já processado." };
- }
+ if (!item) {
+ return { success: false, error: "Item não encontrado ou já processado." };
+ }
- // Marcar item como descartado
- await db
- .update(inboxItems)
- .set({
- status: "discarded",
- discardedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(inboxItems.id, data.inboxItemId));
+ // Marcar item como descartado
+ await db
+ .update(inboxItems)
+ .set({
+ status: "discarded",
+ discardedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(inboxItems.id, data.inboxItemId));
- revalidateInbox();
+ revalidateInbox();
- return { success: true, message: "Item descartado." };
- } catch (error) {
- return handleActionError(error);
- }
+ return { success: true, message: "Item descartado." };
+ } catch (error) {
+ return handleActionError(error);
+ }
}
export async function bulkDiscardInboxItemsAction(
- input: z.infer,
+ input: z.infer,
): Promise {
- try {
- const user = await getUser();
- const data = bulkDiscardSchema.parse(input);
+ try {
+ const user = await getUser();
+ const data = bulkDiscardSchema.parse(input);
- // Marcar todos os itens como descartados
- await db
- .update(inboxItems)
- .set({
- status: "discarded",
- discardedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(
- and(
- inArray(inboxItems.id, data.inboxItemIds),
- eq(inboxItems.userId, user.id),
- eq(inboxItems.status, "pending"),
- ),
- );
+ // Marcar todos os itens como descartados
+ await db
+ .update(inboxItems)
+ .set({
+ status: "discarded",
+ discardedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ inArray(inboxItems.id, data.inboxItemIds),
+ eq(inboxItems.userId, user.id),
+ eq(inboxItems.status, "pending"),
+ ),
+ );
- revalidateInbox();
+ revalidateInbox();
- return {
- success: true,
- message: `${data.inboxItemIds.length} item(s) descartado(s).`,
- };
- } catch (error) {
- return handleActionError(error);
- }
+ return {
+ success: true,
+ message: `${data.inboxItemIds.length} item(s) descartado(s).`,
+ };
+ } catch (error) {
+ return handleActionError(error);
+ }
}
diff --git a/app/(dashboard)/pre-lancamentos/data.ts b/app/(dashboard)/pre-lancamentos/data.ts
index 4533982..1286385 100644
--- a/app/(dashboard)/pre-lancamentos/data.ts
+++ b/app/(dashboard)/pre-lancamentos/data.ts
@@ -2,153 +2,166 @@
* Data fetching functions for Pré-Lançamentos
*/
-import { db } from "@/lib/db";
-import { inboxItems, categorias, contas, cartoes, lancamentos } from "@/db/schema";
-import { eq, desc, and, gte } from "drizzle-orm";
-import type { InboxItem, SelectOption } from "@/components/pre-lancamentos/types";
+import { and, desc, eq, gte } from "drizzle-orm";
+import type {
+ InboxItem,
+ SelectOption,
+} from "@/components/pre-lancamentos/types";
import {
- fetchLancamentoFilterSources,
- buildSluggedFilters,
- buildOptionSets,
+ cartoes,
+ categorias,
+ contas,
+ inboxItems,
+ lancamentos,
+} from "@/db/schema";
+import { db } from "@/lib/db";
+import {
+ buildOptionSets,
+ buildSluggedFilters,
+ fetchLancamentoFilterSources,
} from "@/lib/lancamentos/page-helpers";
export async function fetchInboxItems(
- userId: string,
- status: "pending" | "processed" | "discarded" = "pending"
+ userId: string,
+ status: "pending" | "processed" | "discarded" = "pending",
): Promise {
- const items = await db
- .select()
- .from(inboxItems)
- .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)))
- .orderBy(desc(inboxItems.createdAt));
+ const items = await db
+ .select()
+ .from(inboxItems)
+ .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status)))
+ .orderBy(desc(inboxItems.createdAt));
- return items;
+ return items;
}
export async function fetchInboxItemById(
- userId: string,
- itemId: string
+ userId: string,
+ itemId: string,
): Promise {
- const [item] = await db
- .select()
- .from(inboxItems)
- .where(and(eq(inboxItems.id, itemId), eq(inboxItems.userId, userId)))
- .limit(1);
+ const [item] = await db
+ .select()
+ .from(inboxItems)
+ .where(and(eq(inboxItems.id, itemId), eq(inboxItems.userId, userId)))
+ .limit(1);
- return item ?? null;
+ return item ?? null;
}
export async function fetchCategoriasForSelect(
- userId: string,
- type?: string
+ userId: string,
+ type?: string,
): Promise {
- const query = db
- .select({ id: categorias.id, name: categorias.name })
- .from(categorias)
- .where(
- type
- ? and(eq(categorias.userId, userId), eq(categorias.type, type))
- : eq(categorias.userId, userId)
- )
- .orderBy(categorias.name);
+ const query = db
+ .select({ id: categorias.id, name: categorias.name })
+ .from(categorias)
+ .where(
+ type
+ ? and(eq(categorias.userId, userId), eq(categorias.type, type))
+ : eq(categorias.userId, userId),
+ )
+ .orderBy(categorias.name);
- return query;
+ return query;
}
-export async function fetchContasForSelect(userId: string): Promise {
- const items = await db
- .select({ id: contas.id, name: contas.name })
- .from(contas)
- .where(and(eq(contas.userId, userId), eq(contas.status, "ativo")))
- .orderBy(contas.name);
+export async function fetchContasForSelect(
+ userId: string,
+): Promise {
+ const items = await db
+ .select({ id: contas.id, name: contas.name })
+ .from(contas)
+ .where(and(eq(contas.userId, userId), eq(contas.status, "ativo")))
+ .orderBy(contas.name);
- return items;
+ return items;
}
export async function fetchCartoesForSelect(
- userId: string
+ userId: string,
): Promise<(SelectOption & { lastDigits?: string })[]> {
- const items = await db
- .select({ id: cartoes.id, name: cartoes.name })
- .from(cartoes)
- .where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo")))
- .orderBy(cartoes.name);
+ const items = await db
+ .select({ id: cartoes.id, name: cartoes.name })
+ .from(cartoes)
+ .where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo")))
+ .orderBy(cartoes.name);
- return items;
+ return items;
}
export async function fetchPendingInboxCount(userId: string): Promise {
- const items = await db
- .select({ id: inboxItems.id })
- .from(inboxItems)
- .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")));
+ const items = await db
+ .select({ id: inboxItems.id })
+ .from(inboxItems)
+ .where(
+ and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
+ );
- return items.length;
+ return items.length;
}
/**
* Fetch all data needed for the LancamentoDialog in inbox context
*/
export async function fetchInboxDialogData(userId: string): Promise<{
- pagadorOptions: SelectOption[];
- splitPagadorOptions: SelectOption[];
- defaultPagadorId: string | null;
- contaOptions: SelectOption[];
- cartaoOptions: SelectOption[];
- categoriaOptions: SelectOption[];
- estabelecimentos: string[];
+ pagadorOptions: SelectOption[];
+ splitPagadorOptions: SelectOption[];
+ defaultPagadorId: string | null;
+ contaOptions: SelectOption[];
+ cartaoOptions: SelectOption[];
+ categoriaOptions: SelectOption[];
+ estabelecimentos: string[];
}> {
- const filterSources = await fetchLancamentoFilterSources(userId);
- const sluggedFilters = buildSluggedFilters(filterSources);
+ const filterSources = await fetchLancamentoFilterSources(userId);
+ const sluggedFilters = buildSluggedFilters(filterSources);
- const {
- pagadorOptions,
- splitPagadorOptions,
- defaultPagadorId,
- contaOptions,
- cartaoOptions,
- categoriaOptions,
- } = buildOptionSets({
- ...sluggedFilters,
- pagadorRows: filterSources.pagadorRows,
- });
+ const {
+ pagadorOptions,
+ splitPagadorOptions,
+ defaultPagadorId,
+ contaOptions,
+ cartaoOptions,
+ categoriaOptions,
+ } = buildOptionSets({
+ ...sluggedFilters,
+ pagadorRows: filterSources.pagadorRows,
+ });
- // Fetch recent establishments (same approach as getRecentEstablishmentsAction)
- const threeMonthsAgo = new Date();
- threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
+ // Fetch recent establishments (same approach as getRecentEstablishmentsAction)
+ const threeMonthsAgo = new Date();
+ threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
- const recentEstablishments = await db
- .select({ name: lancamentos.name })
- .from(lancamentos)
- .where(
- and(
- eq(lancamentos.userId, userId),
- gte(lancamentos.purchaseDate, threeMonthsAgo)
- )
- )
- .orderBy(desc(lancamentos.purchaseDate));
+ const recentEstablishments = await db
+ .select({ name: lancamentos.name })
+ .from(lancamentos)
+ .where(
+ and(
+ eq(lancamentos.userId, userId),
+ gte(lancamentos.purchaseDate, threeMonthsAgo),
+ ),
+ )
+ .orderBy(desc(lancamentos.purchaseDate));
- // Remove duplicates and filter empty names
- const filteredNames: string[] = recentEstablishments
- .map((r: { name: string }) => r.name)
- .filter(
- (name: string | null): name is string =>
- name != null &&
- name.trim().length > 0 &&
- !name.toLowerCase().startsWith("pagamento fatura")
- );
- const estabelecimentos = Array.from(new Set(filteredNames)).slice(
- 0,
- 100
- );
+ // Remove duplicates and filter empty names
+ const filteredNames: string[] = recentEstablishments
+ .map((r: { name: string }) => r.name)
+ .filter(
+ (name: string | null): name is string =>
+ name != null &&
+ name.trim().length > 0 &&
+ !name.toLowerCase().startsWith("pagamento fatura"),
+ );
+ const estabelecimentos = Array.from(new Set(filteredNames)).slice(
+ 0,
+ 100,
+ );
- return {
- pagadorOptions,
- splitPagadorOptions,
- defaultPagadorId,
- contaOptions,
- cartaoOptions,
- categoriaOptions,
- estabelecimentos,
- };
+ return {
+ pagadorOptions,
+ splitPagadorOptions,
+ defaultPagadorId,
+ contaOptions,
+ cartaoOptions,
+ categoriaOptions,
+ estabelecimentos,
+ };
}
diff --git a/app/(dashboard)/pre-lancamentos/layout.tsx b/app/(dashboard)/pre-lancamentos/layout.tsx
index 1579c32..d771a79 100644
--- a/app/(dashboard)/pre-lancamentos/layout.tsx
+++ b/app/(dashboard)/pre-lancamentos/layout.tsx
@@ -1,23 +1,23 @@
-import PageDescription from "@/components/page-description";
import { RiInboxLine } from "@remixicon/react";
+import PageDescription from "@/components/page-description";
export const metadata = {
- title: "Pré-Lançamentos | Opensheets",
+ title: "Pré-Lançamentos | Opensheets",
};
export default function RootLayout({
- children,
+ children,
}: {
- children: React.ReactNode;
+ children: React.ReactNode;
}) {
- return (
-
- }
- title="Pré-Lançamentos"
- subtitle="Notificações capturadas aguardando processamento"
- />
- {children}
-
- );
+ return (
+
+ }
+ title="Pré-Lançamentos"
+ subtitle="Notificações capturadas aguardando processamento"
+ />
+ {children}
+
+ );
}
diff --git a/app/(dashboard)/pre-lancamentos/loading.tsx b/app/(dashboard)/pre-lancamentos/loading.tsx
index badd381..f3fdd73 100644
--- a/app/(dashboard)/pre-lancamentos/loading.tsx
+++ b/app/(dashboard)/pre-lancamentos/loading.tsx
@@ -2,32 +2,32 @@ import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
- return (
-
-
-
-
-
-
-
- {Array.from({ length: 6 }).map((_, i) => (
-
-
-
- ))}
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+
+
+ ))}
+
+
+
+ );
}
diff --git a/app/(dashboard)/pre-lancamentos/page.tsx b/app/(dashboard)/pre-lancamentos/page.tsx
index b1d3279..6a11212 100644
--- a/app/(dashboard)/pre-lancamentos/page.tsx
+++ b/app/(dashboard)/pre-lancamentos/page.tsx
@@ -3,25 +3,25 @@ import { getUserId } from "@/lib/auth/server";
import { fetchInboxDialogData, fetchInboxItems } from "./data";
export default async function Page() {
- const userId = await getUserId();
+ const userId = await getUserId();
- const [items, dialogData] = await Promise.all([
- fetchInboxItems(userId, "pending"),
- fetchInboxDialogData(userId),
- ]);
+ const [items, dialogData] = await Promise.all([
+ fetchInboxItems(userId, "pending"),
+ fetchInboxDialogData(userId),
+ ]);
- return (
-
-
-
- );
+ return (
+
+
+
+ );
}
diff --git a/app/(dashboard)/relatorios/cartoes/layout.tsx b/app/(dashboard)/relatorios/cartoes/layout.tsx
index ec5dab2..fdea913 100644
--- a/app/(dashboard)/relatorios/cartoes/layout.tsx
+++ b/app/(dashboard)/relatorios/cartoes/layout.tsx
@@ -1,23 +1,23 @@
-import PageDescription from "@/components/page-description";
import { RiBankCard2Line } from "@remixicon/react";
+import PageDescription from "@/components/page-description";
export const metadata = {
- title: "Relatório de Cartões | Opensheets",
+ title: "Relatório de Cartões | Opensheets",
};
export default function RootLayout({
- children,
+ children,
}: {
- children: React.ReactNode;
+ children: React.ReactNode;
}) {
- return (
-
- }
- title="Relatório de Cartões"
- subtitle="Análise detalhada do uso dos seus cartões de crédito."
- />
- {children}
-
- );
+ return (
+
+ }
+ title="Relatório de Cartões"
+ subtitle="Análise detalhada do uso dos seus cartões de crédito."
+ />
+ {children}
+
+ );
}
diff --git a/app/(dashboard)/relatorios/cartoes/loading.tsx b/app/(dashboard)/relatorios/cartoes/loading.tsx
index 557d6e0..c337eb5 100644
--- a/app/(dashboard)/relatorios/cartoes/loading.tsx
+++ b/app/(dashboard)/relatorios/cartoes/loading.tsx
@@ -2,84 +2,84 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
- return (
-
-
-
-
-
+ return (
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- {[1, 2, 3, 4, 5].map((i) => (
-
- ))}
-
-
+
+
+
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
-
-
-
-
-
- {[1, 2, 3, 4, 5].map((i) => (
-
- ))}
-
-
-
+
+
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
+
-
-
-
-
-
- {[1, 2, 3, 4, 5, 6].map((i) => (
-
- ))}
-
-
-
-
-
- );
+
+
+
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => (
+
+ ))}
+
+
+
+
+
+ );
}
diff --git a/app/(dashboard)/relatorios/cartoes/page.tsx b/app/(dashboard)/relatorios/cartoes/page.tsx
index 27b4a16..2027637 100644
--- a/app/(dashboard)/relatorios/cartoes/page.tsx
+++ b/app/(dashboard)/relatorios/cartoes/page.tsx
@@ -1,3 +1,4 @@
+import { RiBankCard2Line } from "@remixicon/react";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { CardCategoryBreakdown } from "@/components/relatorios/cartoes/card-category-breakdown";
import { CardInvoiceStatus } from "@/components/relatorios/cartoes/card-invoice-status";
@@ -7,79 +8,78 @@ import { CardsOverview } from "@/components/relatorios/cartoes/cards-overview";
import { getUser } from "@/lib/auth/server";
import { fetchCartoesReportData } from "@/lib/relatorios/cartoes-report";
import { parsePeriodParam } from "@/lib/utils/period";
-import { RiBankCard2Line } from "@remixicon/react";
type PageSearchParams = Promise>;
type PageProps = {
- searchParams?: PageSearchParams;
+ searchParams?: PageSearchParams;
};
const getSingleParam = (
- params: Record | undefined,
- key: string,
+ params: Record | undefined,
+ key: string,
) => {
- const value = params?.[key];
- if (!value) return null;
- return Array.isArray(value) ? (value[0] ?? null) : value;
+ const value = params?.[key];
+ if (!value) return null;
+ return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function RelatorioCartoesPage({
- searchParams,
+ searchParams,
}: PageProps) {
- const user = await getUser();
- const resolvedSearchParams = searchParams ? await searchParams : undefined;
- const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
- const cartaoParam = getSingleParam(resolvedSearchParams, "cartao");
- const { period: selectedPeriod } = parsePeriodParam(periodoParam);
+ const user = await getUser();
+ const resolvedSearchParams = searchParams ? await searchParams : undefined;
+ const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
+ const cartaoParam = getSingleParam(resolvedSearchParams, "cartao");
+ const { period: selectedPeriod } = parsePeriodParam(periodoParam);
- const data = await fetchCartoesReportData(
- user.id,
- selectedPeriod,
- cartaoParam,
- );
+ const data = await fetchCartoesReportData(
+ user.id,
+ selectedPeriod,
+ cartaoParam,
+ );
- return (
-
-
+ return (
+
+
-
-
-
-
+
+
+
+
-
- {data.selectedCard ? (
- <>
-
+
+ {data.selectedCard ? (
+ <>
+
-
-
-
-
+
+
+
+
-
- >
- ) : (
-
-
-
Nenhum cartão selecionado
-
- Selecione um cartão na lista ao lado para ver detalhes.
-
-
- )}
-
-
-
- );
+
+ >
+ ) : (
+
+
+
Nenhum cartão selecionado
+
+ Selecione um cartão na lista ao lado para ver detalhes.
+
+
+ )}
+
+
+
+ );
}
diff --git a/app/(dashboard)/relatorios/categorias/data.ts b/app/(dashboard)/relatorios/categorias/data.ts
new file mode 100644
index 0000000..bae4ce2
--- /dev/null
+++ b/app/(dashboard)/relatorios/categorias/data.ts
@@ -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 {
+ return db.query.categorias.findMany({
+ where: eq(categorias.userId, userId),
+ orderBy: [asc(categorias.name)],
+ });
+}
diff --git a/app/(dashboard)/relatorios/categorias/layout.tsx b/app/(dashboard)/relatorios/categorias/layout.tsx
index 959f5e3..e076497 100644
--- a/app/(dashboard)/relatorios/categorias/layout.tsx
+++ b/app/(dashboard)/relatorios/categorias/layout.tsx
@@ -1,23 +1,23 @@
-import PageDescription from "@/components/page-description";
import { RiFileChartLine } from "@remixicon/react";
+import PageDescription from "@/components/page-description";
export const metadata = {
- title: "Relatórios | Opensheets",
+ title: "Relatórios | Opensheets",
};
export default function RootLayout({
- children,
+ children,
}: {
- children: React.ReactNode;
+ children: React.ReactNode;
}) {
- return (
-
- }
- title="Relatórios de Categorias"
- subtitle="Acompanhe a evolução dos seus gastos e receitas por categoria ao longo do tempo."
- />
- {children}
-
- );
+ return (
+
+ }
+ title="Relatórios de Categorias"
+ subtitle="Acompanhe a evolução dos seus gastos e receitas por categoria ao longo do tempo."
+ />
+ {children}
+
+ );
}
diff --git a/app/(dashboard)/relatorios/categorias/loading.tsx b/app/(dashboard)/relatorios/categorias/loading.tsx
index e832b3f..c39eac1 100644
--- a/app/(dashboard)/relatorios/categorias/loading.tsx
+++ b/app/(dashboard)/relatorios/categorias/loading.tsx
@@ -1,9 +1,9 @@
import { CategoryReportSkeleton } from "@/components/skeletons/category-report-skeleton";
export default function Loading() {
- return (
-
-
-
- );
+ return (
+
+
+
+ );
}
diff --git a/app/(dashboard)/relatorios/categorias/page.tsx b/app/(dashboard)/relatorios/categorias/page.tsx
index 4cbe29d..8ffb4b7 100644
--- a/app/(dashboard)/relatorios/categorias/page.tsx
+++ b/app/(dashboard)/relatorios/categorias/page.tsx
@@ -1,118 +1,114 @@
-import { CategoryReportPage } from "@/components/relatorios/category-report-page";
-import { getUserId } from "@/lib/auth/server";
-import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period";
-import { validateDateRange } from "@/lib/relatorios/utils";
-import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report";
-import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
-import type { CategoryReportFilters } from "@/lib/relatorios/types";
-import type {
- CategoryOption,
- FilterState,
-} from "@/components/relatorios/types";
-import { db } from "@/lib/db";
-import { categorias, type Categoria } from "@/db/schema";
-import { eq, asc } from "drizzle-orm";
import { redirect } from "next/navigation";
+import { CategoryReportPage } from "@/components/relatorios/category-report-page";
+import type {
+ CategoryOption,
+ FilterState,
+} from "@/components/relatorios/types";
+import type { Categoria } from "@/db/schema";
+import { getUserId } from "@/lib/auth/server";
+import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
+import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report";
+import type { CategoryReportFilters } from "@/lib/relatorios/types";
+import { validateDateRange } from "@/lib/relatorios/utils";
+import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period";
+import { fetchUserCategories } from "./data";
type PageSearchParams = Promise>;
type PageProps = {
- searchParams?: PageSearchParams;
+ searchParams?: PageSearchParams;
};
const getSingleParam = (
- params: Record | undefined,
- key: string
+ params: Record | undefined,
+ key: string,
): string | null => {
- const value = params?.[key];
- if (!value) return null;
- return Array.isArray(value) ? value[0] ?? null : value;
+ const value = params?.[key];
+ if (!value) return null;
+ return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
- // Get authenticated user
- const userId = await getUserId();
+ // Get authenticated user
+ const userId = await getUserId();
- // Resolve search params
- const resolvedSearchParams = searchParams ? await searchParams : undefined;
+ // Resolve search params
+ const resolvedSearchParams = searchParams ? await searchParams : undefined;
- // Extract query params
- const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
- const fimParam = getSingleParam(resolvedSearchParams, "fim");
- const categoriasParam = getSingleParam(resolvedSearchParams, "categorias");
+ // Extract query params
+ const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
+ const fimParam = getSingleParam(resolvedSearchParams, "fim");
+ const categoriasParam = getSingleParam(resolvedSearchParams, "categorias");
- // Calculate default period (last 6 months)
- const currentPeriod = getCurrentPeriod();
- const defaultStartPeriod = addMonthsToPeriod(currentPeriod, -5); // 6 months including current
+ // Calculate default period (last 6 months)
+ const currentPeriod = getCurrentPeriod();
+ const defaultStartPeriod = addMonthsToPeriod(currentPeriod, -5); // 6 months including current
- // Use params or defaults
- const startPeriod = inicioParam ?? defaultStartPeriod;
- const endPeriod = fimParam ?? currentPeriod;
+ // Use params or defaults
+ const startPeriod = inicioParam ?? defaultStartPeriod;
+ const endPeriod = fimParam ?? currentPeriod;
- // Parse selected categories
- const selectedCategoryIds = categoriasParam
- ? categoriasParam.split(",").filter(Boolean)
- : [];
+ // Parse selected categories
+ const selectedCategoryIds = categoriasParam
+ ? categoriasParam.split(",").filter(Boolean)
+ : [];
- // Validate date range
- const validation = validateDateRange(startPeriod, endPeriod);
- if (!validation.isValid) {
- // Redirect to default if validation fails
- redirect(
- `/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`
- );
- }
+ // Validate date range
+ const validation = validateDateRange(startPeriod, endPeriod);
+ if (!validation.isValid) {
+ // Redirect to default if validation fails
+ redirect(
+ `/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`,
+ );
+ }
- // Fetch all categories for the user
- const categoriaRows = await db.query.categorias.findMany({
- where: eq(categorias.userId, userId),
- orderBy: [asc(categorias.name)],
- });
+ // Fetch all categories for the user
+ const categoriaRows = await fetchUserCategories(userId);
- // Map to CategoryOption format
- const categoryOptions: CategoryOption[] = categoriaRows.map(
- (cat: Categoria): CategoryOption => ({
- id: cat.id,
- name: cat.name,
- icon: cat.icon,
- type: cat.type as "despesa" | "receita",
- })
- );
+ // Map to CategoryOption format
+ const categoryOptions: CategoryOption[] = categoriaRows.map(
+ (cat: Categoria): CategoryOption => ({
+ id: cat.id,
+ name: cat.name,
+ icon: cat.icon,
+ type: cat.type as "despesa" | "receita",
+ }),
+ );
- // Build filters for data fetching
- const filters: CategoryReportFilters = {
- startPeriod,
- endPeriod,
- categoryIds:
- selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
- };
+ // Build filters for data fetching
+ const filters: CategoryReportFilters = {
+ startPeriod,
+ endPeriod,
+ categoryIds:
+ selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
+ };
- // Fetch report data
- const reportData = await fetchCategoryReport(userId, filters);
+ // Fetch report data
+ const reportData = await fetchCategoryReport(userId, filters);
- // Fetch chart data with same filters
- const chartData = await fetchCategoryChartData(
- userId,
- startPeriod,
- endPeriod,
- selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined
- );
+ // Fetch chart data with same filters
+ const chartData = await fetchCategoryChartData(
+ userId,
+ startPeriod,
+ endPeriod,
+ selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
+ );
- // Build initial filter state for client component
- const initialFilters: FilterState = {
- selectedCategories: selectedCategoryIds,
- startPeriod,
- endPeriod,
- };
+ // Build initial filter state for client component
+ const initialFilters: FilterState = {
+ selectedCategories: selectedCategoryIds,
+ startPeriod,
+ endPeriod,
+ };
- return (
-
-
-
- );
+ return (
+
+
+
+ );
}
diff --git a/app/(dashboard)/top-estabelecimentos/layout.tsx b/app/(dashboard)/top-estabelecimentos/layout.tsx
index c6d619e..04e3bc1 100644
--- a/app/(dashboard)/top-estabelecimentos/layout.tsx
+++ b/app/(dashboard)/top-estabelecimentos/layout.tsx
@@ -1,23 +1,23 @@
-import PageDescription from "@/components/page-description";
import { RiStore2Line } from "@remixicon/react";
+import PageDescription from "@/components/page-description";
export const metadata = {
- title: "Top Estabelecimentos | Opensheets",
+ title: "Top Estabelecimentos | Opensheets",
};
export default function RootLayout({
- children,
+ children,
}: {
- children: React.ReactNode;
+ children: React.ReactNode;
}) {
- return (
-
- }
- title="Top Estabelecimentos"
- subtitle="Análise dos locais onde você mais compra e gasta"
- />
- {children}
-
- );
+ return (
+
+ }
+ title="Top Estabelecimentos"
+ subtitle="Análise dos locais onde você mais compra e gasta"
+ />
+ {children}
+
+ );
}
diff --git a/app/(dashboard)/top-estabelecimentos/loading.tsx b/app/(dashboard)/top-estabelecimentos/loading.tsx
index 498e8f9..b0fb8e5 100644
--- a/app/(dashboard)/top-estabelecimentos/loading.tsx
+++ b/app/(dashboard)/top-estabelecimentos/loading.tsx
@@ -2,57 +2,57 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
- return (
-
-
+ return (
+
+
-
- {[1, 2, 3, 4].map((i) => (
-
-
-
-
-
- ))}
-
+
+ {[1, 2, 3, 4].map((i) => (
+
+
+
+
+
+ ))}
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
- {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
-
- ))}
-
-
-
-
-
-
-
-
-
- {[1, 2, 3, 4, 5].map((i) => (
-
- ))}
-
-
-
-
-
- );
+
+
+
+
+
+
+
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
+
+
+
+ );
}
diff --git a/app/(dashboard)/top-estabelecimentos/page.tsx b/app/(dashboard)/top-estabelecimentos/page.tsx
index ecb8628..65ff9d5 100644
--- a/app/(dashboard)/top-estabelecimentos/page.tsx
+++ b/app/(dashboard)/top-estabelecimentos/page.tsx
@@ -6,71 +6,71 @@ import { TopCategories } from "@/components/top-estabelecimentos/top-categories"
import { Card } from "@/components/ui/card";
import { getUser } from "@/lib/auth/server";
import {
- fetchTopEstabelecimentosData,
- type PeriodFilter,
+ fetchTopEstabelecimentosData,
+ type PeriodFilter,
} from "@/lib/top-estabelecimentos/fetch-data";
import { parsePeriodParam } from "@/lib/utils/period";
type PageSearchParams = Promise>;
type PageProps = {
- searchParams?: PageSearchParams;
+ searchParams?: PageSearchParams;
};
const getSingleParam = (
- params: Record | undefined,
- key: string,
+ params: Record | undefined,
+ key: string,
) => {
- const value = params?.[key];
- if (!value) return null;
- return Array.isArray(value) ? (value[0] ?? null) : value;
+ const value = params?.[key];
+ if (!value) return null;
+ return Array.isArray(value) ? (value[0] ?? null) : value;
};
const validatePeriodFilter = (value: string | null): PeriodFilter => {
- if (value === "3" || value === "6" || value === "12") {
- return value;
- }
- return "6";
+ if (value === "3" || value === "6" || value === "12") {
+ return value;
+ }
+ return "6";
};
export default async function TopEstabelecimentosPage({
- searchParams,
+ searchParams,
}: PageProps) {
- const user = await getUser();
- const resolvedSearchParams = searchParams ? await searchParams : undefined;
- const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
- const mesesParam = getSingleParam(resolvedSearchParams, "meses");
+ const user = await getUser();
+ const resolvedSearchParams = searchParams ? await searchParams : undefined;
+ const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
+ const mesesParam = getSingleParam(resolvedSearchParams, "meses");
- const { period: currentPeriod } = parsePeriodParam(periodoParam);
- const periodFilter = validatePeriodFilter(mesesParam);
+ const { period: currentPeriod } = parsePeriodParam(periodoParam);
+ const periodFilter = validatePeriodFilter(mesesParam);
- const data = await fetchTopEstabelecimentosData(
- user.id,
- currentPeriod,
- periodFilter,
- );
+ const data = await fetchTopEstabelecimentosData(
+ user.id,
+ currentPeriod,
+ periodFilter,
+ );
- return (
-
-
-
- Selecione o período
-
-
-
+ return (
+
+
+
+ Selecione o período
+
+
+
-
+
-
+
-
-
- );
+
+
+ );
}
diff --git a/app/(landing-page)/page.tsx b/app/(landing-page)/page.tsx
index 9e1e7d3..96472c9 100644
--- a/app/(landing-page)/page.tsx
+++ b/app/(landing-page)/page.tsx
@@ -1,866 +1,866 @@
+import {
+ RiArrowRightSLine,
+ RiBankCard2Line,
+ RiBarChartBoxLine,
+ RiCalendarLine,
+ RiCodeSSlashLine,
+ RiDatabase2Line,
+ RiDeviceLine,
+ RiDownloadCloudLine,
+ RiEyeOffLine,
+ RiFileTextLine,
+ RiFlashlightLine,
+ RiGithubFill,
+ RiLineChartLine,
+ RiLockLine,
+ RiPercentLine,
+ RiPieChartLine,
+ RiRobot2Line,
+ RiShieldCheckLine,
+ RiTeamLine,
+ RiTimeLine,
+ RiWalletLine,
+} 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";
-import {
- RiArrowRightSLine,
- RiBankCard2Line,
- RiBarChartBoxLine,
- RiCalendarLine,
- RiCodeSSlashLine,
- RiDatabase2Line,
- RiDeviceLine,
- RiGithubFill,
- RiLineChartLine,
- RiLockLine,
- RiPieChartLine,
- RiShieldCheckLine,
- RiTimeLine,
- RiWalletLine,
- RiRobot2Line,
- RiTeamLine,
- RiFileTextLine,
- RiDownloadCloudLine,
- RiEyeOffLine,
- RiFlashlightLine,
- RiPercentLine,
-} from "@remixicon/react";
-import Image from "next/image";
-import Link from "next/link";
export default async function Page() {
- const session = await getOptionalUserSession();
+ const session = await getOptionalUserSession();
- return (
-
- {/* Navigation */}
-
-
-
-
-
+ return (
+
+ {/* Navigation */}
+
+
+
+ {session?.user ? (
+
+
+ Dashboard
+
+
+ ) : (
+ <>
+
+
+ Entrar
+
+
+
+
+ Começar
+
+
+
+ >
+ )}
+
+
+
- {/* Hero Section */}
-
-
-
-
-
- Projeto Open Source
-
+ {/* Hero Section */}
+
+
+
+
+
+ Projeto Open Source
+
-
- Suas finanças,
- do seu jeito
-
+
+ Suas finanças,
+ do seu jeito
+
-
- Um projeto pessoal de gestão financeira. Self-hosted, sem Open
- Finance, sem sincronização automática. Rode no seu computador ou
- servidor e tenha controle total sobre suas finanças.
-
+
+ Um projeto pessoal de gestão financeira. Self-hosted, sem Open
+ Finance, sem sincronização automática. Rode no seu computador ou
+ servidor e tenha controle total sobre suas finanças.
+
-
-
-
- ⚠️ Aviso importante:
- {" "}
- Este sistema requer disciplina. Você precisa registrar
- manualmente cada transação. Se prefere algo automático, este
- projeto não é pra você.
-
-
+
+
+
+ ⚠️ Aviso importante:
+ {" "}
+ Este sistema requer disciplina. Você precisa registrar
+ manualmente cada transação. Se prefere algo automático, este
+ projeto não é pra você.
+
+
-
-
-
-
- Baixar no GitHub
-
-
-
-
- Ver Documentação
-
-
-
+
+
+
+
+ Baixar no GitHub
+
+
+
+
+ Ver Documentação
+
+
+
-
-
-
- Seus dados, seu servidor
-
-
-
- 100% Open Source
-
-
-
-
-
+
+
+
+ Seus dados, seu servidor
+
+
+
+ 100% Open Source
+
+
+
+
+
- {/* Dashboard Preview Section */}
-
+ {/* Dashboard Preview Section */}
+
- {/* What's Here Section */}
-
-
-
-
-
- O que tem aqui
-
-
- Funcionalidades que importam
-
-
- Ferramentas simples para organizar suas contas, cartões, gastos
- e receitas
-
-
+ {/* What's Here Section */}
+
+
+
+
+
+ O que tem aqui
+
+
+ Funcionalidades que importam
+
+
+ Ferramentas simples para organizar suas contas, cartões, gastos
+ e receitas
+
+
-
-
-
-
-
-
-
-
-
- Contas e transações
-
-
- Registre suas contas bancárias, cartões e dinheiro.
- Adicione receitas, despesas e transferências. Organize
- por categorias. Extratos detalhados por conta.
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ Contas e transações
+
+
+ Registre suas contas bancárias, cartões e dinheiro.
+ Adicione receitas, despesas e transferências. Organize
+ por categorias. Extratos detalhados por conta.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Parcelamentos avançados
-
-
- Controle completo de compras parceladas. Antecipe
- parcelas com cálculo automático de desconto. Veja
- análise consolidada de todas as parcelas em aberto.
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Parcelamentos avançados
+
+
+ Controle completo de compras parceladas. Antecipe
+ parcelas com cálculo automático de desconto. Veja
+ análise consolidada de todas as parcelas em aberto.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Insights com IA
-
-
- Análises financeiras geradas por IA (Claude, GPT,
- Gemini). Insights personalizados sobre seus padrões de
- gastos e recomendações inteligentes.
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Insights com IA
+
+
+ Análises financeiras geradas por IA (Claude, GPT,
+ Gemini). Insights personalizados sobre seus padrões de
+ gastos e recomendações inteligentes.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Relatórios e gráficos
-
-
- Dashboard com 20+ widgets interativos. Relatórios
- detalhados por categoria. Gráficos de evolução e
- comparativos. Exportação em PDF e Excel.
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Relatórios e gráficos
+
+
+ Dashboard com 20+ widgets interativos. Relatórios
+ detalhados por categoria. Gráficos de evolução e
+ comparativos. Exportação em PDF e Excel.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Faturas de cartão
-
-
- Cadastre seus cartões e acompanhe as faturas por
- período. Veja o que ainda não foi fechado. Controle
- limites, vencimentos e fechamentos.
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Faturas de cartão
+
+
+ Cadastre seus cartões e acompanhe as faturas por
+ período. Veja o que ainda não foi fechado. Controle
+ limites, vencimentos e fechamentos.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Gestão colaborativa
-
-
- Compartilhe pagadores com permissões granulares (admin/
- viewer). Notificações automáticas por e-mail. Colabore
- em lançamentos compartilhados.
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Gestão colaborativa
+
+
+ Compartilhe pagadores com permissões granulares (admin/
+ viewer). Notificações automáticas por e-mail. Colabore
+ em lançamentos compartilhados.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Categorias e orçamentos
-
-
- Crie categorias personalizadas. Defina orçamentos
- mensais e acompanhe o quanto gastou vs. planejado com
- indicadores visuais.
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Categorias e orçamentos
+
+
+ Crie categorias personalizadas. Defina orçamentos
+ mensais e acompanhe o quanto gastou vs. planejado com
+ indicadores visuais.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Anotações e tarefas
-
-
- Crie notas de texto e listas de tarefas com checkboxes.
- Sistema de arquivamento para manter histórico. Organize
- seus planejamentos financeiros.
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Anotações e tarefas
+
+
+ Crie notas de texto e listas de tarefas com checkboxes.
+ Sistema de arquivamento para manter histórico. Organize
+ seus planejamentos financeiros.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Calendário financeiro
-
-
- Visualize todas as transações em calendário mensal.
- Navegação intuitiva por data. Nunca perca prazos de
- pagamentos importantes.
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Calendário financeiro
+
+
+ Visualize todas as transações em calendário mensal.
+ Navegação intuitiva por data. Nunca perca prazos de
+ pagamentos importantes.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Importação em massa
-
-
- Cole múltiplos lançamentos de uma vez. Economize tempo
- ao registrar várias transações. Formatação inteligente
- para facilitar a entrada de dados.
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Importação em massa
+
+
+ Cole múltiplos lançamentos de uma vez. Economize tempo
+ ao registrar várias transações. Formatação inteligente
+ para facilitar a entrada de dados.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Modo privacidade
-
-
- Oculte valores sensíveis com um clique. Tema dark/light
- adaptável. Preferências personalizáveis. Calculadora
- integrada para planejamento.
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Modo privacidade
+
+
+ Oculte valores sensíveis com um clique. Tema dark/light
+ adaptável. Preferências personalizáveis. Calculadora
+ integrada para planejamento.
+
+
+
+
+
-
-
-
-
-
-
-
-
- Performance otimizada
-
-
- Dashboard carrega em ~200-500ms com 18+ queries
- paralelas. Índices otimizados. Type-safe em toda
- codebase. Isolamento completo de dados por usuário.
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Performance otimizada
+
+
+ Dashboard carrega em ~200-500ms com 18+ queries
+ paralelas. Índices otimizados. Type-safe em toda
+ codebase. Isolamento completo de dados por usuário.
+
+
+
+
+
+
+
+
+
- {/* Tech Stack Section */}
-
-
-
-
-
- Stack técnica
-
-
- Construído com tecnologias modernas
-
-
- Open source, self-hosted e fácil de customizar
-
-
+ {/* Tech Stack Section */}
+
+
+
+
+
+ Stack técnica
+
+
+ Construído com tecnologias modernas
+
+
+ Open source, self-hosted e fácil de customizar
+
+
-
-
-
-
-
-
-
Frontend
-
- Next.js 16, TypeScript, Tailwind CSS, shadcn/ui
-
-
- Interface moderna e responsiva com React 19 e App Router
-
-
-
-
-
+
+
+
+
+
+
+
Frontend
+
+ Next.js 16, TypeScript, Tailwind CSS, shadcn/ui
+
+
+ Interface moderna e responsiva com React 19 e App Router
+
+
+
+
+
-
-
-
-
-
-
Backend
-
- PostgreSQL 18, Drizzle ORM, Better Auth
-
-
- Banco relacional robusto com type-safe ORM
-
-
-
-
-
+
+
+
+
+
+
Backend
+
+ PostgreSQL 18, Drizzle ORM, Better Auth
+
+
+ Banco relacional robusto com type-safe ORM
+
+
+
+
+
-
-
-
-
-
-
Segurança
-
- Better Auth com OAuth (Google) e autenticação por email
-
-
- Sessões seguras e proteção de rotas por middleware
-
-
-
-
-
+
+
+
+
+
+
Segurança
+
+ Better Auth com OAuth (Google) e autenticação por email
+
+
+ Sessões seguras e proteção de rotas por middleware
+
+
+
+
+
-
-
-
-
-
-
Deploy
-
- Docker com multi-stage build, health checks e volumes
- persistentes
-
-
- Fácil de rodar localmente ou em qualquer servidor
-
-
-
-
-
-
+
+
+
+
+
+
Deploy
+
+ Docker com multi-stage build, health checks e volumes
+ persistentes
+
+
+ Fácil de rodar localmente ou em qualquer servidor
+
+
+
+
+
+
-
-
- Seus dados ficam no seu controle. Pode rodar localmente ou no
- seu próprio servidor.
-
-
-
-
-
+
+
+ Seus dados ficam no seu controle. Pode rodar localmente ou no
+ seu próprio servidor.
+
+
+
+
+
- {/* How to run Section */}
-
-
-
-
-
- Como usar
-
-
- Rode no seu computador
-
-
- Não há versão hospedada online. Você precisa rodar localmente.
-
-
+ {/* How to run Section */}
+
+
+
+
+
+ Como usar
+
+
+ Rode no seu computador
+
+
+ Não há versão hospedada online. Você precisa rodar localmente.
+
+
-
-
-
-
-
- 1
-
-
-
- Clone o repositório
-
-
- git clone
- https://github.com/felipegcoutinho/opensheets-app.git
-
-
-
-
-
+
+
+
+
+
+ 1
+
+
+
+ Clone o repositório
+
+
+ git clone
+ https://github.com/felipegcoutinho/opensheets-app.git
+
+
+
+
+
-
-
-
-
- 2
-
-
-
- Configure as variáveis de ambiente
-
-
- Copie o{" "}
-
- .env.example
- {" "}
- para .env{" "}
- e configure o banco de dados
-
-
-
-
-
+
+
+
+
+ 2
+
+
+
+ Configure as variáveis de ambiente
+
+
+ Copie o{" "}
+
+ .env.example
+ {" "}
+ para .env{" "}
+ e configure o banco de dados
+
+
+
+
+
-
-
-
-
- 3
-
-
-
- Suba o banco via Docker
-
-
- docker compose up db -d
-
-
-
-
-
+
+
+
+
+ 3
+
+
+
+ Suba o banco via Docker
+
+
+ docker compose up db -d
+
+
+
+
+
-
-
-
-
- 4
-
-
-
- Rode a aplicação localmente
-
-
-
- pnpm install
-
-
- pnpm db:push
-
-
- pnpm dev
-
-
-
-
-
-
-
+
+
+
+
+ 4
+
+
+
+ Rode a aplicação localmente
+
+
+
+ pnpm install
+
+
+ pnpm db:push
+
+
+ pnpm dev
+
+
+
+
+
+
+
-
-
- Ver documentação completa →
-
-
-
-
-
+
+
+ Ver documentação completa →
+
+
+
+
+
- {/* Who is this for Section */}
-
-
-
-
-
- Para quem funciona?
-
-
- O opensheets funciona melhor se você:
-
-
+ {/* Who is this for Section */}
+
+
+
+
+
+ Para quem funciona?
+
+
+ O opensheets funciona melhor se você:
+
+
-
-
-
-
-
-
-
-
-
- Tem disciplina de registrar gastos
-
-
- Não se importa em dedicar alguns minutos por dia ou
- semana para manter tudo atualizado
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ Tem disciplina de registrar gastos
+
+
+ Não se importa em dedicar alguns minutos por dia ou
+ semana para manter tudo atualizado
+
+
+
+
+
-
-
-
-
-
-
-
-
- Quer controle total sobre seus dados
-
-
- Prefere hospedar seus próprios dados ao invés de
- depender de serviços terceiros
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Quer controle total sobre seus dados
+
+
+ Prefere hospedar seus próprios dados ao invés de
+ depender de serviços terceiros
+
+
+
+
+
-
-
-
-
-
-
-
-
- Gosta de entender exatamente onde o dinheiro vai
-
-
- Quer visualizar padrões de gastos e tomar decisões
- informadas
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Gosta de entender exatamente onde o dinheiro vai
+
+
+ Quer visualizar padrões de gastos e tomar decisões
+ informadas
+
+
+
+
+
+
-
-
- Se você não se encaixa nisso, provavelmente vai abandonar depois
- de uma semana. E tudo bem! Existem outras ferramentas com
- sincronização automática que podem funcionar melhor pra você.
-
-
-
-
-
+
+
+ Se você não se encaixa nisso, provavelmente vai abandonar depois
+ de uma semana. E tudo bem! Existem outras ferramentas com
+ sincronização automática que podem funcionar melhor pra você.
+
+
+
+
+
- {/* CTA Section */}
-
-
-
-
- Pronto para testar?
-
-
- Clone o repositório, rode localmente e veja se faz sentido pra
- você. É open source e gratuito.
-
-
-
-
-
- Baixar Projeto
-
-
-
-
- Como Instalar
-
-
-
-
-
-
+ {/* CTA Section */}
+
+
+
+
+ Pronto para testar?
+
+
+ Clone o repositório, rode localmente e veja se faz sentido pra
+ você. É open source e gratuito.
+
+
+
+
+
+ Baixar Projeto
+
+
+
+
+ Como Instalar
+
+
+
+
+
+
- {/* Footer */}
-
-
-
-
-
-
-
- Projeto pessoal de gestão financeira. Open source e
- self-hosted.
-
-
+ {/* Footer */}
+
+
+
+
+
+
+
+ Projeto pessoal de gestão financeira. Open source e
+ self-hosted.
+
+
-
-
Projeto
-
-
-
-
- GitHub
-
-
-
-
- Documentação
-
-
-
-
- Reportar Bug
-
-
-
-
+
+
Projeto
+
+
+
+
+ GitHub
+
+
+
+
+ Documentação
+
+
+
+
+ Reportar Bug
+
+
+
+
-
-
Stack
-
- Next.js 16 + TypeScript
- PostgreSQL 18 + Drizzle ORM
- Better Auth + shadcn/ui
- Docker + Docker Compose
-
-
-
+
+
Stack
+
+ Next.js 16 + TypeScript
+ PostgreSQL 18 + Drizzle ORM
+ Better Auth + shadcn/ui
+ Docker + Docker Compose
+
+
+
-
-
- © {new Date().getFullYear()} opensheets. Projeto open source sob
- licença MIT.
-
-
-
- Seus dados, seu servidor
-
-
-
-
-
-
- );
+
+
+ © {new Date().getFullYear()} opensheets. Projeto open source sob
+ licença MIT.
+
+
+
+ Seus dados, seu servidor
+
+
+
+
+
+
+ );
}
diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts
index b2c1b51..5f87d57 100644
--- a/app/api/auth/[...all]/route.ts
+++ b/app/api/auth/[...all]/route.ts
@@ -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);
diff --git a/app/api/auth/device/refresh/route.ts b/app/api/auth/device/refresh/route.ts
index 44a148c..7ab9f09 100644
--- a/app/api/auth/device/refresh/route.ts
+++ b/app/api/auth/device/refresh/route.ts
@@ -5,81 +5,88 @@
* Usado pelo app Android quando o access token expira.
*/
-import { refreshAccessToken, extractBearerToken, verifyJwt, hashToken } from "@/lib/auth/api-token";
-import { db } from "@/lib/db";
-import { apiTokens } from "@/db/schema";
-import { eq, and, isNull } from "drizzle-orm";
+import { and, eq, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
+import { apiTokens } from "@/db/schema";
+import {
+ extractBearerToken,
+ hashToken,
+ refreshAccessToken,
+ verifyJwt,
+} from "@/lib/auth/api-token";
+import { db } from "@/lib/db";
export async function POST(request: Request) {
- try {
- // Extrair refresh token do header
- const authHeader = request.headers.get("Authorization");
- const token = extractBearerToken(authHeader);
+ try {
+ // Extrair refresh token do header
+ const authHeader = request.headers.get("Authorization");
+ const token = extractBearerToken(authHeader);
- if (!token) {
- return NextResponse.json(
- { error: "Refresh token não fornecido" },
- { status: 401 }
- );
- }
+ if (!token) {
+ return NextResponse.json(
+ { error: "Refresh token não fornecido" },
+ { status: 401 },
+ );
+ }
- // Validar refresh token
- const payload = verifyJwt(token);
+ // Validar refresh token
+ const payload = verifyJwt(token);
- if (!payload || payload.type !== "api_refresh") {
- return NextResponse.json(
- { error: "Refresh token inválido ou expirado" },
- { status: 401 }
- );
- }
+ if (!payload || payload.type !== "api_refresh") {
+ return NextResponse.json(
+ { error: "Refresh token inválido ou expirado" },
+ { status: 401 },
+ );
+ }
- // Verificar se token não foi revogado
- const tokenRecord = await db.query.apiTokens.findFirst({
- where: and(
- eq(apiTokens.id, payload.tokenId),
- eq(apiTokens.userId, payload.sub),
- isNull(apiTokens.revokedAt)
- ),
- });
+ // Verificar se token não foi revogado
+ const tokenRecord = await db.query.apiTokens.findFirst({
+ where: and(
+ eq(apiTokens.id, payload.tokenId),
+ eq(apiTokens.userId, payload.sub),
+ isNull(apiTokens.revokedAt),
+ ),
+ });
- if (!tokenRecord) {
- return NextResponse.json(
- { error: "Token revogado ou não encontrado" },
- { status: 401 }
- );
- }
+ if (!tokenRecord) {
+ return NextResponse.json(
+ { error: "Token revogado ou não encontrado" },
+ { status: 401 },
+ );
+ }
- // Gerar novo access token
- const result = refreshAccessToken(token);
+ // Gerar novo access token
+ const result = refreshAccessToken(token);
- if (!result) {
- return NextResponse.json(
- { error: "Não foi possível renovar o token" },
- { status: 401 }
- );
- }
+ if (!result) {
+ return NextResponse.json(
+ { error: "Não foi possível renovar o token" },
+ { status: 401 },
+ );
+ }
- // Atualizar hash do token e último uso
- await db
- .update(apiTokens)
- .set({
- tokenHash: hashToken(result.accessToken),
- lastUsedAt: new Date(),
- lastUsedIp: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"),
- expiresAt: result.expiresAt,
- })
- .where(eq(apiTokens.id, payload.tokenId));
+ // Atualizar hash do token e último uso
+ await db
+ .update(apiTokens)
+ .set({
+ tokenHash: hashToken(result.accessToken),
+ lastUsedAt: new Date(),
+ lastUsedIp:
+ request.headers.get("x-forwarded-for") ||
+ request.headers.get("x-real-ip"),
+ expiresAt: result.expiresAt,
+ })
+ .where(eq(apiTokens.id, payload.tokenId));
- return NextResponse.json({
- accessToken: result.accessToken,
- expiresAt: result.expiresAt.toISOString(),
- });
- } catch (error) {
- console.error("[API] Error refreshing device token:", error);
- return NextResponse.json(
- { error: "Erro ao renovar token" },
- { status: 500 }
- );
- }
+ return NextResponse.json({
+ accessToken: result.accessToken,
+ expiresAt: result.expiresAt.toISOString(),
+ });
+ } catch (error) {
+ console.error("[API] Error refreshing device token:", error);
+ return NextResponse.json(
+ { error: "Erro ao renovar token" },
+ { status: 500 },
+ );
+ }
}
diff --git a/app/api/auth/device/token/route.ts b/app/api/auth/device/token/route.ts
index 6e7dab8..b494203 100644
--- a/app/api/auth/device/token/route.ts
+++ b/app/api/auth/device/token/route.ts
@@ -5,75 +5,74 @@
* Requer sessão web autenticada.
*/
-import { auth } from "@/lib/auth/config";
-import { generateTokenPair, hashToken, getTokenPrefix } from "@/lib/auth/api-token";
-import { db } from "@/lib/db";
-import { apiTokens } from "@/db/schema";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { z } from "zod";
+import { apiTokens } from "@/db/schema";
+import {
+ generateTokenPair,
+ getTokenPrefix,
+ hashToken,
+} from "@/lib/auth/api-token";
+import { auth } from "@/lib/auth/config";
+import { db } from "@/lib/db";
const createTokenSchema = z.object({
- name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
- deviceId: z.string().optional(),
+ name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
+ deviceId: z.string().optional(),
});
export async function POST(request: Request) {
- try {
- // Verificar autenticação via sessão web
- const session = await auth.api.getSession({ headers: await headers() });
+ try {
+ // Verificar autenticação via sessão web
+ const session = await auth.api.getSession({ headers: await headers() });
- if (!session?.user) {
- return NextResponse.json(
- { error: "Não autenticado" },
- { status: 401 }
- );
- }
+ if (!session?.user) {
+ return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
+ }
- // Validar body
- const body = await request.json();
- const { name, deviceId } = createTokenSchema.parse(body);
+ // Validar body
+ const body = await request.json();
+ const { name, deviceId } = createTokenSchema.parse(body);
- // Gerar par de tokens
- const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
- session.user.id,
- deviceId
- );
+ // Gerar par de tokens
+ const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
+ session.user.id,
+ deviceId,
+ );
- // Salvar hash do token no banco
- await db.insert(apiTokens).values({
- id: tokenId,
- userId: session.user.id,
- name,
- tokenHash: hashToken(accessToken),
- tokenPrefix: getTokenPrefix(accessToken),
- expiresAt,
- });
+ // Salvar hash do token no banco
+ await db.insert(apiTokens).values({
+ id: tokenId,
+ userId: session.user.id,
+ name,
+ tokenHash: hashToken(accessToken),
+ tokenPrefix: getTokenPrefix(accessToken),
+ expiresAt,
+ });
- // Retornar tokens (mostrados apenas uma vez)
- return NextResponse.json(
- {
- accessToken,
- refreshToken,
- tokenId,
- name,
- expiresAt: expiresAt.toISOString(),
- message: "Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
- },
- { status: 201 }
- );
- } catch (error) {
- if (error instanceof z.ZodError) {
- return NextResponse.json(
- { error: error.issues[0]?.message ?? "Dados inválidos" },
- { status: 400 }
- );
- }
+ // Retornar tokens (mostrados apenas uma vez)
+ return NextResponse.json(
+ {
+ accessToken,
+ refreshToken,
+ tokenId,
+ name,
+ expiresAt: expiresAt.toISOString(),
+ message:
+ "Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
+ },
+ { status: 201 },
+ );
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: error.issues[0]?.message ?? "Dados inválidos" },
+ { status: 400 },
+ );
+ }
- console.error("[API] Error creating device token:", error);
- return NextResponse.json(
- { error: "Erro ao criar token" },
- { status: 500 }
- );
- }
+ console.error("[API] Error creating device token:", error);
+ return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
+ }
}
diff --git a/app/api/auth/device/tokens/[tokenId]/route.ts b/app/api/auth/device/tokens/[tokenId]/route.ts
index c6c64e9..5758bf4 100644
--- a/app/api/auth/device/tokens/[tokenId]/route.ts
+++ b/app/api/auth/device/tokens/[tokenId]/route.ts
@@ -5,61 +5,58 @@
* Requer sessão web autenticada.
*/
-import { auth } from "@/lib/auth/config";
-import { db } from "@/lib/db";
-import { apiTokens } from "@/db/schema";
-import { eq, and } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
+import { apiTokens } from "@/db/schema";
+import { auth } from "@/lib/auth/config";
+import { db } from "@/lib/db";
interface RouteParams {
- params: Promise<{ tokenId: string }>;
+ params: Promise<{ tokenId: string }>;
}
-export async function DELETE(request: Request, { params }: RouteParams) {
- try {
- const { tokenId } = await params;
+export async function DELETE(_request: Request, { params }: RouteParams) {
+ try {
+ const { tokenId } = await params;
- // Verificar autenticação via sessão web
- const session = await auth.api.getSession({ headers: await headers() });
+ // Verificar autenticação via sessão web
+ const session = await auth.api.getSession({ headers: await headers() });
- if (!session?.user) {
- return NextResponse.json(
- { error: "Não autenticado" },
- { status: 401 }
- );
- }
+ if (!session?.user) {
+ return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
+ }
- // Verificar se token pertence ao usuário
- const token = await db.query.apiTokens.findFirst({
- where: and(
- eq(apiTokens.id, tokenId),
- eq(apiTokens.userId, session.user.id)
- ),
- });
+ // Verificar se token pertence ao usuário
+ const token = await db.query.apiTokens.findFirst({
+ where: and(
+ eq(apiTokens.id, tokenId),
+ eq(apiTokens.userId, session.user.id),
+ ),
+ });
- if (!token) {
- return NextResponse.json(
- { error: "Token não encontrado" },
- { status: 404 }
- );
- }
+ if (!token) {
+ return NextResponse.json(
+ { error: "Token não encontrado" },
+ { status: 404 },
+ );
+ }
- // Revogar token (soft delete)
- await db
- .update(apiTokens)
- .set({ revokedAt: new Date() })
- .where(eq(apiTokens.id, tokenId));
+ // Revogar token (soft delete)
+ await db
+ .update(apiTokens)
+ .set({ revokedAt: new Date() })
+ .where(eq(apiTokens.id, tokenId));
- return NextResponse.json({
- message: "Token revogado com sucesso",
- tokenId,
- });
- } catch (error) {
- console.error("[API] Error revoking device token:", error);
- return NextResponse.json(
- { error: "Erro ao revogar token" },
- { status: 500 }
- );
- }
+ return NextResponse.json({
+ message: "Token revogado com sucesso",
+ tokenId,
+ });
+ } catch (error) {
+ console.error("[API] Error revoking device token:", error);
+ return NextResponse.json(
+ { error: "Erro ao revogar token" },
+ { status: 500 },
+ );
+ }
}
diff --git a/app/api/auth/device/tokens/route.ts b/app/api/auth/device/tokens/route.ts
index 340ed9b..e57770f 100644
--- a/app/api/auth/device/tokens/route.ts
+++ b/app/api/auth/device/tokens/route.ts
@@ -5,49 +5,48 @@
* Requer sessão web autenticada.
*/
-import { auth } from "@/lib/auth/config";
-import { db } from "@/lib/db";
-import { apiTokens } from "@/db/schema";
-import { eq, desc } from "drizzle-orm";
+import { desc, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
+import { apiTokens } from "@/db/schema";
+import { auth } from "@/lib/auth/config";
+import { db } from "@/lib/db";
export async function GET() {
- try {
- // Verificar autenticação via sessão web
- const session = await auth.api.getSession({ headers: await headers() });
+ try {
+ // Verificar autenticação via sessão web
+ const session = await auth.api.getSession({ headers: await headers() });
- if (!session?.user) {
- return NextResponse.json(
- { error: "Não autenticado" },
- { status: 401 }
- );
- }
+ if (!session?.user) {
+ return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
+ }
- // Buscar tokens ativos do usuário
- const tokens = await db
- .select({
- id: apiTokens.id,
- name: apiTokens.name,
- tokenPrefix: apiTokens.tokenPrefix,
- lastUsedAt: apiTokens.lastUsedAt,
- lastUsedIp: apiTokens.lastUsedIp,
- expiresAt: apiTokens.expiresAt,
- createdAt: apiTokens.createdAt,
- })
- .from(apiTokens)
- .where(eq(apiTokens.userId, session.user.id))
- .orderBy(desc(apiTokens.createdAt));
+ // Buscar tokens ativos do usuário
+ const tokens = await db
+ .select({
+ id: apiTokens.id,
+ name: apiTokens.name,
+ tokenPrefix: apiTokens.tokenPrefix,
+ lastUsedAt: apiTokens.lastUsedAt,
+ lastUsedIp: apiTokens.lastUsedIp,
+ expiresAt: apiTokens.expiresAt,
+ createdAt: apiTokens.createdAt,
+ })
+ .from(apiTokens)
+ .where(eq(apiTokens.userId, session.user.id))
+ .orderBy(desc(apiTokens.createdAt));
- // Separar tokens ativos e revogados
- const activeTokens = tokens.filter((t) => !t.expiresAt || new Date(t.expiresAt) > new Date());
+ // Separar tokens ativos e revogados
+ const activeTokens = tokens.filter(
+ (t) => !t.expiresAt || new Date(t.expiresAt) > new Date(),
+ );
- return NextResponse.json({ tokens: activeTokens });
- } catch (error) {
- console.error("[API] Error listing device tokens:", error);
- return NextResponse.json(
- { error: "Erro ao listar tokens" },
- { status: 500 }
- );
- }
+ return NextResponse.json({ tokens: activeTokens });
+ } catch (error) {
+ console.error("[API] Error listing device tokens:", error);
+ return NextResponse.json(
+ { error: "Erro ao listar tokens" },
+ { status: 500 },
+ );
+ }
}
diff --git a/app/api/auth/device/verify/route.ts b/app/api/auth/device/verify/route.ts
index 8af7767..7539e9b 100644
--- a/app/api/auth/device/verify/route.ts
+++ b/app/api/auth/device/verify/route.ts
@@ -7,75 +7,76 @@
* Aceita tokens no formato os_xxx (hash-based, sem expiração).
*/
+import { and, eq, isNull } from "drizzle-orm";
+import { NextResponse } from "next/server";
+import { apiTokens } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/lib/auth/api-token";
import { db } from "@/lib/db";
-import { apiTokens } from "@/db/schema";
-import { eq, and, isNull } from "drizzle-orm";
-import { NextResponse } from "next/server";
export async function POST(request: Request) {
- try {
- // Extrair token do header
- const authHeader = request.headers.get("Authorization");
- const token = extractBearerToken(authHeader);
+ try {
+ // Extrair token do header
+ const authHeader = request.headers.get("Authorization");
+ const token = extractBearerToken(authHeader);
- if (!token) {
- return NextResponse.json(
- { valid: false, error: "Token não fornecido" },
- { status: 401 }
- );
- }
+ if (!token) {
+ return NextResponse.json(
+ { valid: false, error: "Token não fornecido" },
+ { status: 401 },
+ );
+ }
- // Validar token os_xxx via hash lookup
- if (!token.startsWith("os_")) {
- return NextResponse.json(
- { valid: false, error: "Formato de token inválido" },
- { status: 401 }
- );
- }
+ // Validar token os_xxx via hash lookup
+ if (!token.startsWith("os_")) {
+ return NextResponse.json(
+ { valid: false, error: "Formato de token inválido" },
+ { status: 401 },
+ );
+ }
- // Hash do token para buscar no DB
- const tokenHash = hashToken(token);
+ // Hash do token para buscar no DB
+ const tokenHash = hashToken(token);
- // Buscar token no banco
- const tokenRecord = await db.query.apiTokens.findFirst({
- where: and(
- eq(apiTokens.tokenHash, tokenHash),
- isNull(apiTokens.revokedAt)
- ),
- });
+ // Buscar token no banco
+ const tokenRecord = await db.query.apiTokens.findFirst({
+ where: and(
+ eq(apiTokens.tokenHash, tokenHash),
+ isNull(apiTokens.revokedAt),
+ ),
+ });
- if (!tokenRecord) {
- return NextResponse.json(
- { valid: false, error: "Token inválido ou revogado" },
- { status: 401 }
- );
- }
+ if (!tokenRecord) {
+ return NextResponse.json(
+ { valid: false, error: "Token inválido ou revogado" },
+ { status: 401 },
+ );
+ }
- // Atualizar último uso
- const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
- || request.headers.get("x-real-ip")
- || null;
+ // Atualizar último uso
+ const clientIp =
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ request.headers.get("x-real-ip") ||
+ null;
- await db
- .update(apiTokens)
- .set({
- lastUsedAt: new Date(),
- lastUsedIp: clientIp,
- })
- .where(eq(apiTokens.id, tokenRecord.id));
+ await db
+ .update(apiTokens)
+ .set({
+ lastUsedAt: new Date(),
+ lastUsedIp: clientIp,
+ })
+ .where(eq(apiTokens.id, tokenRecord.id));
- return NextResponse.json({
- valid: true,
- userId: tokenRecord.userId,
- tokenId: tokenRecord.id,
- tokenName: tokenRecord.name,
- });
- } catch (error) {
- console.error("[API] Error verifying device token:", error);
- return NextResponse.json(
- { valid: false, error: "Erro ao validar token" },
- { status: 500 }
- );
- }
+ return NextResponse.json({
+ valid: true,
+ userId: tokenRecord.userId,
+ tokenId: tokenRecord.id,
+ tokenName: tokenRecord.name,
+ });
+ } catch (error) {
+ console.error("[API] Error verifying device token:", error);
+ return NextResponse.json(
+ { valid: false, error: "Erro ao validar token" },
+ { status: 500 },
+ );
+ }
}
diff --git a/app/api/health/route.ts b/app/api/health/route.ts
index 7d129ff..32e8a8c 100644
--- a/app/api/health/route.ts
+++ b/app/api/health/route.ts
@@ -12,33 +12,34 @@ const APP_VERSION = "1.0.0";
* Usado pelo app Android para validar URL do servidor
*/
export async function GET() {
- try {
- // Tenta fazer uma query simples no banco para verificar conexão
- // Isso garante que o app está conectado ao banco antes de considerar "healthy"
- await db.execute("SELECT 1");
+ try {
+ // Tenta fazer uma query simples no banco para verificar conexão
+ // Isso garante que o app está conectado ao banco antes de considerar "healthy"
+ await db.execute("SELECT 1");
- return NextResponse.json(
- {
- status: "ok",
- name: "OpenSheets",
- version: APP_VERSION,
- timestamp: new Date().toISOString(),
- },
- { status: 200 }
- );
- } catch (error) {
- // Se houver erro na conexão com banco, retorna status 503 (Service Unavailable)
- console.error("Health check failed:", error);
+ return NextResponse.json(
+ {
+ status: "ok",
+ name: "OpenSheets",
+ version: APP_VERSION,
+ timestamp: new Date().toISOString(),
+ },
+ { status: 200 },
+ );
+ } catch (error) {
+ // Se houver erro na conexão com banco, retorna status 503 (Service Unavailable)
+ console.error("Health check failed:", error);
- return NextResponse.json(
- {
- status: "error",
- name: "OpenSheets",
- version: APP_VERSION,
- timestamp: new Date().toISOString(),
- message: error instanceof Error ? error.message : "Database connection failed",
- },
- { status: 503 }
- );
- }
+ return NextResponse.json(
+ {
+ status: "error",
+ name: "OpenSheets",
+ version: APP_VERSION,
+ timestamp: new Date().toISOString(),
+ message:
+ error instanceof Error ? error.message : "Database connection failed",
+ },
+ { status: 503 },
+ );
+ }
}
diff --git a/app/api/inbox/batch/route.ts b/app/api/inbox/batch/route.ts
index 58e57be..65421d6 100644
--- a/app/api/inbox/batch/route.ts
+++ b/app/api/inbox/batch/route.ts
@@ -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();
@@ -19,153 +19,153 @@ const RATE_LIMIT = 20; // 20 batch requests
const RATE_WINDOW = 60 * 1000; // por minuto
function checkRateLimit(userId: string): boolean {
- const now = Date.now();
- const userLimit = rateLimitMap.get(userId);
+ const now = Date.now();
+ const userLimit = rateLimitMap.get(userId);
- if (!userLimit || userLimit.resetAt < now) {
- rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
- return true;
- }
+ if (!userLimit || userLimit.resetAt < now) {
+ rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
+ return true;
+ }
- if (userLimit.count >= RATE_LIMIT) {
- return false;
- }
+ if (userLimit.count >= RATE_LIMIT) {
+ return false;
+ }
- userLimit.count++;
- return true;
+ userLimit.count++;
+ return true;
}
interface BatchResult {
- clientId?: string;
- serverId?: string;
- success: boolean;
- error?: string;
+ clientId?: string;
+ serverId?: string;
+ success: boolean;
+ error?: string;
}
export async function POST(request: Request) {
- try {
- // Extrair token do header
- const authHeader = request.headers.get("Authorization");
- const token = extractBearerToken(authHeader);
+ try {
+ // Extrair token do header
+ const authHeader = request.headers.get("Authorization");
+ const token = extractBearerToken(authHeader);
- if (!token) {
- return NextResponse.json(
- { error: "Token não fornecido" },
- { status: 401 },
- );
- }
+ if (!token) {
+ return NextResponse.json(
+ { error: "Token não fornecido" },
+ { status: 401 },
+ );
+ }
- // Validar token os_xxx via hash
- if (!token.startsWith("os_")) {
- return NextResponse.json(
- { error: "Formato de token inválido" },
- { status: 401 },
- );
- }
+ // Validar token os_xxx via hash
+ if (!token.startsWith("os_")) {
+ return NextResponse.json(
+ { error: "Formato de token inválido" },
+ { status: 401 },
+ );
+ }
- const tokenHash = hashToken(token);
+ const tokenHash = hashToken(token);
- // Buscar token no banco
- const tokenRecord = await db.query.apiTokens.findFirst({
- where: and(
- eq(apiTokens.tokenHash, tokenHash),
- isNull(apiTokens.revokedAt),
- ),
- });
+ // Buscar token no banco
+ const tokenRecord = await db.query.apiTokens.findFirst({
+ where: and(
+ eq(apiTokens.tokenHash, tokenHash),
+ isNull(apiTokens.revokedAt),
+ ),
+ });
- if (!tokenRecord) {
- return NextResponse.json(
- { error: "Token inválido ou revogado" },
- { status: 401 },
- );
- }
+ if (!tokenRecord) {
+ return NextResponse.json(
+ { error: "Token inválido ou revogado" },
+ { status: 401 },
+ );
+ }
- // Rate limiting
- if (!checkRateLimit(tokenRecord.userId)) {
- return NextResponse.json(
- { error: "Limite de requisições excedido", retryAfter: 60 },
- { status: 429 },
- );
- }
+ // Rate limiting
+ if (!checkRateLimit(tokenRecord.userId)) {
+ return NextResponse.json(
+ { error: "Limite de requisições excedido", retryAfter: 60 },
+ { status: 429 },
+ );
+ }
- // Validar body
- const body = await request.json();
- const { items } = inboxBatchSchema.parse(body);
+ // Validar body
+ const body = await request.json();
+ const { items } = inboxBatchSchema.parse(body);
- // Processar cada item
- const results: BatchResult[] = [];
+ // Processar cada item
+ const results: BatchResult[] = [];
- for (const item of items) {
- try {
- const [inserted] = await db
- .insert(inboxItems)
- .values({
- userId: tokenRecord.userId,
- sourceApp: item.sourceApp,
- sourceAppName: item.sourceAppName,
- originalTitle: item.originalTitle,
- originalText: item.originalText,
- notificationTimestamp: item.notificationTimestamp,
- parsedName: item.parsedName,
- parsedAmount: item.parsedAmount?.toString(),
- parsedTransactionType: item.parsedTransactionType,
- status: "pending",
- })
- .returning({ id: inboxItems.id });
+ for (const item of items) {
+ try {
+ const [inserted] = await db
+ .insert(inboxItems)
+ .values({
+ userId: tokenRecord.userId,
+ sourceApp: item.sourceApp,
+ sourceAppName: item.sourceAppName,
+ originalTitle: item.originalTitle,
+ originalText: item.originalText,
+ notificationTimestamp: item.notificationTimestamp,
+ parsedName: item.parsedName,
+ parsedAmount: item.parsedAmount?.toString(),
+ parsedTransactionType: item.parsedTransactionType,
+ status: "pending",
+ })
+ .returning({ id: inboxItems.id });
- results.push({
- clientId: item.clientId,
- serverId: inserted.id,
- success: true,
- });
- } catch (error) {
- results.push({
- clientId: item.clientId,
- success: false,
- error: error instanceof Error ? error.message : "Erro desconhecido",
- });
- }
- }
+ results.push({
+ clientId: item.clientId,
+ serverId: inserted.id,
+ success: true,
+ });
+ } catch (error) {
+ results.push({
+ clientId: item.clientId,
+ success: false,
+ error: error instanceof Error ? error.message : "Erro desconhecido",
+ });
+ }
+ }
- // Atualizar último uso do token
- const clientIp =
- request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
- request.headers.get("x-real-ip") ||
- null;
+ // Atualizar último uso do token
+ const clientIp =
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ request.headers.get("x-real-ip") ||
+ null;
- await db
- .update(apiTokens)
- .set({
- lastUsedAt: new Date(),
- lastUsedIp: clientIp,
- })
- .where(eq(apiTokens.id, tokenRecord.id));
+ await db
+ .update(apiTokens)
+ .set({
+ lastUsedAt: new Date(),
+ lastUsedIp: clientIp,
+ })
+ .where(eq(apiTokens.id, tokenRecord.id));
- const successCount = results.filter((r) => r.success).length;
- const failCount = results.filter((r) => !r.success).length;
+ const successCount = results.filter((r) => r.success).length;
+ const failCount = results.filter((r) => !r.success).length;
- return NextResponse.json(
- {
- message: `${successCount} notificações processadas${failCount > 0 ? `, ${failCount} falharam` : ""}`,
- total: items.length,
- success: successCount,
- failed: failCount,
- results,
- },
- { status: 201 },
- );
- } catch (error) {
- if (error instanceof z.ZodError) {
- return NextResponse.json(
- { error: error.issues[0]?.message ?? "Dados inválidos" },
- { status: 400 },
- );
- }
+ return NextResponse.json(
+ {
+ message: `${successCount} notificações processadas${failCount > 0 ? `, ${failCount} falharam` : ""}`,
+ total: items.length,
+ success: successCount,
+ failed: failCount,
+ results,
+ },
+ { status: 201 },
+ );
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: error.issues[0]?.message ?? "Dados inválidos" },
+ { status: 400 },
+ );
+ }
- console.error("[API] Error creating batch inbox items:", error);
- return NextResponse.json(
- { error: "Erro ao processar notificações" },
- { status: 500 },
- );
- }
+ console.error("[API] Error creating batch inbox items:", error);
+ return NextResponse.json(
+ { error: "Erro ao processar notificações" },
+ { status: 500 },
+ );
+ }
}
diff --git a/app/api/inbox/route.ts b/app/api/inbox/route.ts
index a13f8c9..03af77f 100644
--- a/app/api/inbox/route.ts
+++ b/app/api/inbox/route.ts
@@ -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();
@@ -19,123 +19,123 @@ const RATE_LIMIT = 100; // 100 requests
const RATE_WINDOW = 60 * 1000; // por minuto
function checkRateLimit(userId: string): boolean {
- const now = Date.now();
- const userLimit = rateLimitMap.get(userId);
+ const now = Date.now();
+ const userLimit = rateLimitMap.get(userId);
- if (!userLimit || userLimit.resetAt < now) {
- rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
- return true;
- }
+ if (!userLimit || userLimit.resetAt < now) {
+ rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW });
+ return true;
+ }
- if (userLimit.count >= RATE_LIMIT) {
- return false;
- }
+ if (userLimit.count >= RATE_LIMIT) {
+ return false;
+ }
- userLimit.count++;
- return true;
+ userLimit.count++;
+ return true;
}
export async function POST(request: Request) {
- try {
- // Extrair token do header
- const authHeader = request.headers.get("Authorization");
- const token = extractBearerToken(authHeader);
+ try {
+ // Extrair token do header
+ const authHeader = request.headers.get("Authorization");
+ const token = extractBearerToken(authHeader);
- if (!token) {
- return NextResponse.json(
- { error: "Token não fornecido" },
- { status: 401 },
- );
- }
+ if (!token) {
+ return NextResponse.json(
+ { error: "Token não fornecido" },
+ { status: 401 },
+ );
+ }
- // Validar token os_xxx via hash
- if (!token.startsWith("os_")) {
- return NextResponse.json(
- { error: "Formato de token inválido" },
- { status: 401 },
- );
- }
+ // Validar token os_xxx via hash
+ if (!token.startsWith("os_")) {
+ return NextResponse.json(
+ { error: "Formato de token inválido" },
+ { status: 401 },
+ );
+ }
- const tokenHash = hashToken(token);
+ const tokenHash = hashToken(token);
- // Buscar token no banco
- const tokenRecord = await db.query.apiTokens.findFirst({
- where: and(
- eq(apiTokens.tokenHash, tokenHash),
- isNull(apiTokens.revokedAt),
- ),
- });
+ // Buscar token no banco
+ const tokenRecord = await db.query.apiTokens.findFirst({
+ where: and(
+ eq(apiTokens.tokenHash, tokenHash),
+ isNull(apiTokens.revokedAt),
+ ),
+ });
- if (!tokenRecord) {
- return NextResponse.json(
- { error: "Token inválido ou revogado" },
- { status: 401 },
- );
- }
+ if (!tokenRecord) {
+ return NextResponse.json(
+ { error: "Token inválido ou revogado" },
+ { status: 401 },
+ );
+ }
- // Rate limiting
- if (!checkRateLimit(tokenRecord.userId)) {
- return NextResponse.json(
- { error: "Limite de requisições excedido", retryAfter: 60 },
- { status: 429 },
- );
- }
+ // Rate limiting
+ if (!checkRateLimit(tokenRecord.userId)) {
+ return NextResponse.json(
+ { error: "Limite de requisições excedido", retryAfter: 60 },
+ { status: 429 },
+ );
+ }
- // Validar body
- const body = await request.json();
- const data = inboxItemSchema.parse(body);
+ // Validar body
+ const body = await request.json();
+ const data = inboxItemSchema.parse(body);
- // Inserir item na inbox
- const [inserted] = await db
- .insert(inboxItems)
- .values({
- userId: tokenRecord.userId,
- sourceApp: data.sourceApp,
- sourceAppName: data.sourceAppName,
- originalTitle: data.originalTitle,
- originalText: data.originalText,
- notificationTimestamp: data.notificationTimestamp,
- parsedName: data.parsedName,
- parsedAmount: data.parsedAmount?.toString(),
- parsedTransactionType: data.parsedTransactionType,
- status: "pending",
- })
- .returning({ id: inboxItems.id });
+ // Inserir item na inbox
+ const [inserted] = await db
+ .insert(inboxItems)
+ .values({
+ userId: tokenRecord.userId,
+ sourceApp: data.sourceApp,
+ sourceAppName: data.sourceAppName,
+ originalTitle: data.originalTitle,
+ originalText: data.originalText,
+ notificationTimestamp: data.notificationTimestamp,
+ parsedName: data.parsedName,
+ parsedAmount: data.parsedAmount?.toString(),
+ parsedTransactionType: data.parsedTransactionType,
+ status: "pending",
+ })
+ .returning({ id: inboxItems.id });
- // Atualizar último uso do token
- const clientIp =
- request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
- request.headers.get("x-real-ip") ||
- null;
+ // Atualizar último uso do token
+ const clientIp =
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ request.headers.get("x-real-ip") ||
+ null;
- await db
- .update(apiTokens)
- .set({
- lastUsedAt: new Date(),
- lastUsedIp: clientIp,
- })
- .where(eq(apiTokens.id, tokenRecord.id));
+ await db
+ .update(apiTokens)
+ .set({
+ lastUsedAt: new Date(),
+ lastUsedIp: clientIp,
+ })
+ .where(eq(apiTokens.id, tokenRecord.id));
- return NextResponse.json(
- {
- id: inserted.id,
- clientId: data.clientId,
- message: "Notificação recebida",
- },
- { status: 201 },
- );
- } catch (error) {
- if (error instanceof z.ZodError) {
- return NextResponse.json(
- { error: error.issues[0]?.message ?? "Dados inválidos" },
- { status: 400 },
- );
- }
+ return NextResponse.json(
+ {
+ id: inserted.id,
+ clientId: data.clientId,
+ message: "Notificação recebida",
+ },
+ { status: 201 },
+ );
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: error.issues[0]?.message ?? "Dados inválidos" },
+ { status: 400 },
+ );
+ }
- console.error("[API] Error creating inbox item:", error);
- return NextResponse.json(
- { error: "Erro ao processar notificação" },
- { status: 500 },
- );
- }
+ console.error("[API] Error creating inbox item:", error);
+ return NextResponse.json(
+ { error: "Erro ao processar notificação" },
+ { status: 500 },
+ );
+ }
}
diff --git a/app/error.tsx b/app/error.tsx
index acbf006..5046826 100644
--- a/app/error.tsx
+++ b/app/error.tsx
@@ -6,48 +6,48 @@ import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
- Empty,
- EmptyContent,
- EmptyDescription,
- EmptyHeader,
- EmptyMedia,
- EmptyTitle,
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
} from "@/components/ui/empty";
export default function Error({
- error,
- reset,
+ error,
+ reset,
}: {
- error: Error & { digest?: string };
- reset: () => void;
+ error: Error & { digest?: string };
+ reset: () => void;
}) {
- useEffect(() => {
- // Log the error to an error reporting service
- console.error(error);
- }, [error]);
+ useEffect(() => {
+ // Log the error to an error reporting service
+ console.error(error);
+ }, [error]);
- return (
-
-
-
-
-
-
- Algo deu errado
-
- Ocorreu um problema inesperado. Por favor, tente novamente ou volte
- para o dashboard.
-
-
-
-
- reset()}>Tentar Novamente
-
- Voltar para o Dashboard
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+ Algo deu errado
+
+ Ocorreu um problema inesperado. Por favor, tente novamente ou volte
+ para o dashboard.
+
+
+
+
+ reset()}>Tentar Novamente
+
+ Voltar para o Dashboard
+
+
+
+
+
+ );
}
diff --git a/app/layout.tsx b/app/layout.tsx
index b3fddd0..434e6bf 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,37 +1,37 @@
-import { ThemeProvider } from "@/components/theme-provider";
-import { Toaster } from "@/components/ui/sonner";
-import { main_font } from "@/public/fonts/font_index";
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata } from "next";
+import { ThemeProvider } from "@/components/theme-provider";
+import { Toaster } from "@/components/ui/sonner";
+import { main_font } from "@/public/fonts/font_index";
import "./globals.css";
export const metadata: Metadata = {
- title: "Opensheets",
- description: "Finanças pessoais descomplicadas.",
+ title: "Opensheets",
+ description: "Finanças pessoais descomplicadas.",
};
export default function RootLayout({
- children,
+ children,
}: Readonly<{
- children: React.ReactNode;
+ children: React.ReactNode;
}>) {
- return (
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+ );
}
diff --git a/app/manifest.json b/app/manifest.json
index cefeef7..7f5006f 100644
--- a/app/manifest.json
+++ b/app/manifest.json
@@ -1,21 +1,21 @@
{
- "name": "Opensheets",
- "short_name": "Opensheets",
- "icons": [
- {
- "src": "/web-app-manifest-192x192.png",
- "sizes": "192x192",
- "type": "image/png",
- "purpose": "maskable"
- },
- {
- "src": "/web-app-manifest-512x512.png",
- "sizes": "512x512",
- "type": "image/png",
- "purpose": "maskable"
- }
- ],
- "theme_color": "#F2ECE7",
- "background_color": "#F2ECE7",
- "display": "standalone"
-}
\ No newline at end of file
+ "name": "Opensheets",
+ "short_name": "Opensheets",
+ "icons": [
+ {
+ "src": "/web-app-manifest-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/web-app-manifest-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "theme_color": "#F2ECE7",
+ "background_color": "#F2ECE7",
+ "display": "standalone"
+}
diff --git a/app/not-found.tsx b/app/not-found.tsx
index 9bbd750..0959f78 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -1,35 +1,35 @@
-import Link from "next/link";
import { RiFileSearchLine } from "@remixicon/react";
+import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
- Empty,
- EmptyContent,
- EmptyDescription,
- EmptyHeader,
- EmptyMedia,
- EmptyTitle,
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
} from "@/components/ui/empty";
export default function NotFound() {
- return (
-
-
-
-
-
-
- Página não encontrada
-
- A página que você está procurando não existe ou foi movida.
-
-
-
-
- Voltar para o Dashboard
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+ Página não encontrada
+
+ A página que você está procurando não existe ou foi movida.
+
+
+
+
+ Voltar para o Dashboard
+
+
+
+
+ );
}
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..603cf9c
--- /dev/null
+++ b/biome.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/components.json b/components.json
index 13a92f1..186b843 100644
--- a/components.json
+++ b/components.json
@@ -1,26 +1,26 @@
{
- "$schema": "https://ui.shadcn.com/schema.json",
- "style": "new-york",
- "rsc": true,
- "tsx": true,
- "tailwind": {
- "config": "",
- "css": "app/globals.css",
- "baseColor": "neutral",
- "cssVariables": true,
- "prefix": ""
- },
- "iconLibrary": "@remixicon/react",
- "aliases": {
- "components": "@/components",
- "utils": "@/lib/utils",
- "ui": "@/components/ui",
- "lib": "@/lib",
- "hooks": "@/hooks"
- },
- "registries": {
- "@coss": "https://coss.com/ui/r/{name}.json",
- "@magicui": "https://magicui.design/r/{name}.json",
- "@react-bits": "https://reactbits.dev/r/{name}.json"
- }
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "@remixicon/react",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {
+ "@coss": "https://coss.com/ui/r/{name}.json",
+ "@magicui": "https://magicui.design/r/{name}.json",
+ "@react-bits": "https://reactbits.dev/r/{name}.json"
+ }
}
diff --git a/components/ajustes/api-tokens-form.tsx b/components/ajustes/api-tokens-form.tsx
index c540d4d..f46d191 100644
--- a/components/ajustes/api-tokens-form.tsx
+++ b/components/ajustes/api-tokens-form.tsx
@@ -1,335 +1,352 @@
"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";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import { Badge } from "@/components/ui/badge";
-import { Card, CardContent } from "@/components/ui/card";
-import {
- RiSmartphoneLine,
- RiDeleteBinLine,
- RiAddLine,
- RiFileCopyLine,
- RiCheckLine,
- RiAlertLine,
+ RiAddLine,
+ RiAlertLine,
+ RiCheckLine,
+ RiDeleteBinLine,
+ RiFileCopyLine,
+ RiSmartphoneLine,
} from "@remixicon/react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
-import { createApiTokenAction, revokeApiTokenAction } from "@/app/(dashboard)/ajustes/actions";
+import { useState } from "react";
+import {
+ createApiTokenAction,
+ revokeApiTokenAction,
+} from "@/app/(dashboard)/ajustes/actions";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ 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 {
+ 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;
- name: string;
- tokenPrefix: string;
- lastUsedAt: Date | null;
- lastUsedIp: string | null;
- createdAt: Date;
- expiresAt: Date | null;
- revokedAt: Date | null;
+ id: string;
+ name: string;
+ tokenPrefix: string;
+ lastUsedAt: Date | null;
+ lastUsedIp: string | null;
+ createdAt: Date;
+ expiresAt: Date | null;
+ revokedAt: Date | null;
}
interface ApiTokensFormProps {
- tokens: ApiToken[];
+ tokens: ApiToken[];
}
export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
- const [isCreateOpen, setIsCreateOpen] = useState(false);
- const [tokenName, setTokenName] = useState("");
- const [isCreating, setIsCreating] = useState(false);
- const [newToken, setNewToken] = useState(null);
- const [copied, setCopied] = useState(false);
- const [revokeId, setRevokeId] = useState(null);
- const [isRevoking, setIsRevoking] = useState(false);
- const [error, setError] = useState(null);
+ const [isCreateOpen, setIsCreateOpen] = useState(false);
+ const [tokenName, setTokenName] = useState("");
+ const [isCreating, setIsCreating] = useState(false);
+ const [newToken, setNewToken] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const [revokeId, setRevokeId] = useState(null);
+ const [isRevoking, setIsRevoking] = useState(false);
+ const [error, setError] = useState(null);
- const activeTokens = tokens.filter((t) => !t.revokedAt);
+ const activeTokens = tokens.filter((t) => !t.revokedAt);
- const handleCreate = async () => {
- if (!tokenName.trim()) return;
+ const handleCreate = async () => {
+ if (!tokenName.trim()) return;
- setIsCreating(true);
- setError(null);
+ setIsCreating(true);
+ setError(null);
- try {
- const result = await createApiTokenAction({ name: tokenName.trim() });
+ try {
+ const result = await createApiTokenAction({ name: tokenName.trim() });
- if (result.success && result.data?.token) {
- setNewToken(result.data.token);
- setTokenName("");
- } else {
- setError(result.error || "Erro ao criar token");
- }
- } catch {
- setError("Erro ao criar token");
- } finally {
- setIsCreating(false);
- }
- };
+ if (result.success && result.data?.token) {
+ setNewToken(result.data.token);
+ setTokenName("");
+ } else {
+ setError(result.error || "Erro ao criar token");
+ }
+ } catch {
+ setError("Erro ao criar token");
+ } finally {
+ setIsCreating(false);
+ }
+ };
- const handleCopy = async () => {
- if (!newToken) return;
+ const handleCopy = async () => {
+ if (!newToken) return;
- try {
- await navigator.clipboard.writeText(newToken);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } catch {
- // Fallback for browsers that don't support clipboard API
- const textArea = document.createElement("textarea");
- textArea.value = newToken;
- document.body.appendChild(textArea);
- textArea.select();
- document.execCommand("copy");
- document.body.removeChild(textArea);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }
- };
+ try {
+ await navigator.clipboard.writeText(newToken);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Fallback for browsers that don't support clipboard API
+ const textArea = document.createElement("textarea");
+ textArea.value = newToken;
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand("copy");
+ document.body.removeChild(textArea);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ };
- const handleRevoke = async () => {
- if (!revokeId) return;
+ const handleRevoke = async () => {
+ if (!revokeId) return;
- setIsRevoking(true);
+ setIsRevoking(true);
- try {
- const result = await revokeApiTokenAction({ tokenId: revokeId });
+ try {
+ const result = await revokeApiTokenAction({ tokenId: revokeId });
- if (!result.success) {
- setError(result.error || "Erro ao revogar token");
- }
- } catch {
- setError("Erro ao revogar token");
- } finally {
- setIsRevoking(false);
- setRevokeId(null);
- }
- };
+ if (!result.success) {
+ setError(result.error || "Erro ao revogar token");
+ }
+ } catch {
+ setError("Erro ao revogar token");
+ } finally {
+ setIsRevoking(false);
+ setRevokeId(null);
+ }
+ };
- const handleCloseCreate = () => {
- setIsCreateOpen(false);
- setNewToken(null);
- setTokenName("");
- setError(null);
- };
+ const handleCloseCreate = () => {
+ setIsCreateOpen(false);
+ setNewToken(null);
+ setTokenName("");
+ setError(null);
+ };
- return (
-
-
-
-
Dispositivos conectados
-
- Gerencie os dispositivos que podem enviar notificações para o OpenSheets.
-
-
-
{
- if (!open) handleCloseCreate();
- else setIsCreateOpen(true);
- }}>
-
-
-
- Novo Token
-
-
-
- {!newToken ? (
- <>
-
- Criar Token de API
-
- Crie um token para conectar o OpenSheets Companion no seu dispositivo Android.
-
-
-
-
- Nome do dispositivo
- setTokenName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") handleCreate();
- }}
- />
-
- {error && (
-
-
- {error}
-
- )}
-
-
-
- Cancelar
-
-
- {isCreating ? "Criando..." : "Criar Token"}
-
-
- >
- ) : (
- <>
-
- Token Criado
-
- Copie o token abaixo e cole no app OpenSheets Companion. Este token
- não será exibido novamente .
-
-
-
-
-
Seu token de API
-
-
-
- {copied ? (
-
- ) : (
-
- )}
-
-
-
-
-
Importante:
-
- Guarde este token em local seguro
- Ele não será exibido novamente
- Use-o para configurar o app Android
-
-
-
-
- Fechar
-
- >
- )}
-
-
-
+ return (
+
+
+
+
Dispositivos conectados
+
+ Gerencie os dispositivos que podem enviar notificações para o
+ OpenSheets.
+
+
+
{
+ if (!open) handleCloseCreate();
+ else setIsCreateOpen(true);
+ }}
+ >
+
+
+
+ Novo Token
+
+
+
+ {!newToken ? (
+ <>
+
+ Criar Token de API
+
+ Crie um token para conectar o OpenSheets Companion no seu
+ dispositivo Android.
+
+
+
+
+ Nome do dispositivo
+ setTokenName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleCreate();
+ }}
+ />
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+ Cancelar
+
+
+ {isCreating ? "Criando..." : "Criar Token"}
+
+
+ >
+ ) : (
+ <>
+
+ Token Criado
+
+ Copie o token abaixo e cole no app OpenSheets Companion.
+ Este token
+ não será exibido novamente .
+
+
+
+
+
Seu token de API
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
Importante:
+
+ Guarde este token em local seguro
+ Ele não será exibido novamente
+ Use-o para configurar o app Android
+
+
+
+
+ Fechar
+
+ >
+ )}
+
+
+
- {activeTokens.length === 0 ? (
-
-
-
-
- Nenhum dispositivo conectado.
-
-
- Crie um token para conectar o app OpenSheets Companion.
-
-
-
- ) : (
-
- {activeTokens.map((token) => (
-
-
-
-
-
-
-
-
-
- {token.name}
-
- {token.tokenPrefix}...
-
-
-
- {token.lastUsedAt ? (
-
- Usado{" "}
- {formatDistanceToNow(token.lastUsedAt, {
- addSuffix: true,
- locale: ptBR,
- })}
- {token.lastUsedIp && (
-
- ({token.lastUsedIp})
-
- )}
-
- ) : (
- Nunca usado
- )}
-
-
- Criado em{" "}
- {new Date(token.createdAt).toLocaleDateString("pt-BR")}
-
-
-
-
setRevokeId(token.id)}
- >
-
-
-
-
-
- ))}
-
- )}
+ {activeTokens.length === 0 ? (
+
+
+
+
+ Nenhum dispositivo conectado.
+
+
+ Crie um token para conectar o app OpenSheets Companion.
+
+
+
+ ) : (
+
+ {activeTokens.map((token) => (
+
+
+
+
+
+
+
+
+
+ {token.name}
+
+ {token.tokenPrefix}...
+
+
+
+ {token.lastUsedAt ? (
+
+ Usado{" "}
+ {formatDistanceToNow(token.lastUsedAt, {
+ addSuffix: true,
+ locale: ptBR,
+ })}
+ {token.lastUsedIp && (
+
+ ({token.lastUsedIp})
+
+ )}
+
+ ) : (
+ Nunca usado
+ )}
+
+
+ Criado em{" "}
+ {new Date(token.createdAt).toLocaleDateString("pt-BR")}
+
+
+
+
setRevokeId(token.id)}
+ >
+
+
+
+
+
+ ))}
+
+ )}
- {/* Revoke Confirmation Dialog */}
-
!open && setRevokeId(null)}>
-
-
- Revogar token?
-
- O dispositivo associado a este token será desconectado e não poderá mais
- enviar notificações. Esta ação não pode ser desfeita.
-
-
-
- Cancelar
-
- {isRevoking ? "Revogando..." : "Revogar"}
-
-
-
-
-
- );
+ {/* Revoke Confirmation Dialog */}
+
!open && setRevokeId(null)}
+ >
+
+
+ Revogar token?
+
+ O dispositivo associado a este token será desconectado e não
+ poderá mais enviar notificações. Esta ação não pode ser desfeita.
+
+
+
+
+ Cancelar
+
+
+ {isRevoking ? "Revogando..." : "Revogar"}
+
+
+
+
+
+ );
}
diff --git a/components/ajustes/delete-account-form.tsx b/components/ajustes/delete-account-form.tsx
index 3406e29..eb7c91b 100644
--- a/components/ajustes/delete-account-form.tsx
+++ b/components/ajustes/delete-account-form.tsx
@@ -1,136 +1,136 @@
"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 {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
} from "@/components/ui/dialog";
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();
- const [isPending, startTransition] = useTransition();
- const [isModalOpen, setIsModalOpen] = useState(false);
- const [confirmation, setConfirmation] = useState("");
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [confirmation, setConfirmation] = useState("");
- const handleDelete = () => {
- startTransition(async () => {
- const result = await deleteAccountAction({
- confirmation,
- });
+ const handleDelete = () => {
+ startTransition(async () => {
+ const result = await deleteAccountAction({
+ confirmation,
+ });
- if (result.success) {
- toast.success(result.message);
- // Fazer logout e redirecionar para página de login
- await authClient.signOut();
- router.push("/");
- } else {
- toast.error(result.error);
- }
- });
- };
+ if (result.success) {
+ toast.success(result.message);
+ // Fazer logout e redirecionar para página de login
+ await authClient.signOut();
+ router.push("/");
+ } else {
+ toast.error(result.error);
+ }
+ });
+ };
- const handleOpenModal = () => {
- setConfirmation("");
- setIsModalOpen(true);
- };
+ const handleOpenModal = () => {
+ setConfirmation("");
+ setIsModalOpen(true);
+ };
- const handleCloseModal = () => {
- if (isPending) return;
- setConfirmation("");
- setIsModalOpen(false);
- };
+ const handleCloseModal = () => {
+ if (isPending) return;
+ setConfirmation("");
+ setIsModalOpen(false);
+ };
- return (
- <>
-
-
-
- Lançamentos, orçamentos e anotações
- Contas, cartões e categorias
- Pagadores (incluindo o pagador padrão)
- Preferências e configurações
-
- Resumindo tudo, sua conta será permanentemente removida
-
-
-
+ return (
+ <>
+
+
+
+ Lançamentos, orçamentos e anotações
+ Contas, cartões e categorias
+ Pagadores (incluindo o pagador padrão)
+ Preferências e configurações
+
+ Resumindo tudo, sua conta será permanentemente removida
+
+
+
-
-
- Deletar conta
-
-
-
+
+
+ Deletar conta
+
+
+
-
- {
- if (isPending) e.preventDefault();
- }}
- onPointerDownOutside={(e) => {
- if (isPending) e.preventDefault();
- }}
- >
-
- Você tem certeza?
-
- Essa ação não pode ser desfeita. Isso irá deletar permanentemente
- sua conta e remover seus dados de nossos servidores.
-
-
+
+ {
+ if (isPending) e.preventDefault();
+ }}
+ onPointerDownOutside={(e) => {
+ if (isPending) e.preventDefault();
+ }}
+ >
+
+ Você tem certeza?
+
+ Essa ação não pode ser desfeita. Isso irá deletar permanentemente
+ sua conta e remover seus dados de nossos servidores.
+
+
-
-
-
- Para confirmar, digite DELETAR no campo abaixo.
-
- setConfirmation(e.target.value)}
- disabled={isPending}
- placeholder="DELETAR"
- autoComplete="off"
- />
-
-
+
+
+
+ Para confirmar, digite DELETAR no campo abaixo.
+
+ setConfirmation(e.target.value)}
+ disabled={isPending}
+ placeholder="DELETAR"
+ autoComplete="off"
+ />
+
+
-
-
- Cancelar
-
-
- {isPending ? "Deletando..." : "Deletar"}
-
-
-
-
- >
- );
+
+
+ Cancelar
+
+
+ {isPending ? "Deletando..." : "Deletar"}
+
+
+
+
+ >
+ );
}
diff --git a/components/ajustes/preferences-form.tsx b/components/ajustes/preferences-form.tsx
index 9cec038..839def6 100644
--- a/components/ajustes/preferences-form.tsx
+++ b/components/ajustes/preferences-form.tsx
@@ -1,78 +1,72 @@
"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;
+ disableMagnetlines: boolean;
}
-export function PreferencesForm({
- disableMagnetlines,
-}: PreferencesFormProps) {
- const router = useRouter();
- const [isPending, startTransition] = useTransition();
- const [magnetlinesDisabled, setMagnetlinesDisabled] =
- useState(disableMagnetlines);
+export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+ const [magnetlinesDisabled, setMagnetlinesDisabled] =
+ useState(disableMagnetlines);
- const handleSubmit = async (event: React.FormEvent) => {
- event.preventDefault();
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
- startTransition(async () => {
- const result = await updatePreferencesAction({
- disableMagnetlines: magnetlinesDisabled,
- });
+ startTransition(async () => {
+ const result = await updatePreferencesAction({
+ disableMagnetlines: magnetlinesDisabled,
+ });
- if (result.success) {
- toast.success(result.message);
- // Recarregar a página para aplicar as mudanças nos componentes
- router.refresh();
- // Forçar reload completo para garantir que os hooks re-executem
- setTimeout(() => {
- window.location.reload();
- }, 500);
- } else {
- toast.error(result.error);
- }
- });
- };
+ if (result.success) {
+ toast.success(result.message);
+ // Recarregar a página para aplicar as mudanças nos componentes
+ router.refresh();
+ // Forçar reload completo para garantir que os hooks re-executem
+ setTimeout(() => {
+ window.location.reload();
+ }, 500);
+ } else {
+ toast.error(result.error);
+ }
+ });
+ };
- return (
-
- );
+
+
+ {isPending ? "Salvando..." : "Salvar preferências"}
+
+
+
+ );
}
diff --git a/components/ajustes/update-email-form.tsx b/components/ajustes/update-email-form.tsx
index 2d14b22..c5d6654 100644
--- a/components/ajustes/update-email-form.tsx
+++ b/components/ajustes/update-email-form.tsx
@@ -1,220 +1,248 @@
"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
+ currentEmail: string;
+ authProvider?: string; // 'google' | 'credential' | undefined
};
-export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormProps) {
- const [isPending, startTransition] = useTransition();
- const [password, setPassword] = useState("");
- const [newEmail, setNewEmail] = useState("");
- const [confirmEmail, setConfirmEmail] = useState("");
- const [showPassword, setShowPassword] = useState(false);
+export function UpdateEmailForm({
+ currentEmail,
+ authProvider,
+}: UpdateEmailFormProps) {
+ const [isPending, startTransition] = useTransition();
+ const [password, setPassword] = useState("");
+ const [newEmail, setNewEmail] = useState("");
+ const [confirmEmail, setConfirmEmail] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
- // Verificar se o usuário usa login via Google (não precisa de senha)
- const isGoogleAuth = authProvider === "google";
+ // Verificar se o usuário usa login via Google (não precisa de senha)
+ const isGoogleAuth = authProvider === "google";
- // Validação em tempo real: e-mails coincidem
- const emailsMatch = useMemo(() => {
- if (!confirmEmail) return null; // Não mostrar erro se campo vazio
- return newEmail.toLowerCase() === confirmEmail.toLowerCase();
- }, [newEmail, confirmEmail]);
+ // Validação em tempo real: e-mails coincidem
+ const emailsMatch = useMemo(() => {
+ if (!confirmEmail) return null; // Não mostrar erro se campo vazio
+ return newEmail.toLowerCase() === confirmEmail.toLowerCase();
+ }, [newEmail, confirmEmail]);
- // Validação: novo e-mail é diferente do atual
- const isEmailDifferent = useMemo(() => {
- if (!newEmail) return true;
- return newEmail.toLowerCase() !== currentEmail.toLowerCase();
- }, [newEmail, currentEmail]);
+ // Validação: novo e-mail é diferente do atual
+ const isEmailDifferent = useMemo(() => {
+ if (!newEmail) return true;
+ return newEmail.toLowerCase() !== currentEmail.toLowerCase();
+ }, [newEmail, currentEmail]);
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
- // Validação frontend antes de enviar
- if (newEmail.toLowerCase() !== confirmEmail.toLowerCase()) {
- toast.error("Os e-mails não coincidem");
- return;
- }
+ // Validação frontend antes de enviar
+ if (newEmail.toLowerCase() !== confirmEmail.toLowerCase()) {
+ toast.error("Os e-mails não coincidem");
+ return;
+ }
- if (newEmail.toLowerCase() === currentEmail.toLowerCase()) {
- toast.error("O novo e-mail deve ser diferente do atual");
- return;
- }
+ if (newEmail.toLowerCase() === currentEmail.toLowerCase()) {
+ toast.error("O novo e-mail deve ser diferente do atual");
+ return;
+ }
- startTransition(async () => {
- const result = await updateEmailAction({
- password: isGoogleAuth ? undefined : password,
- newEmail,
- confirmEmail,
- });
+ startTransition(async () => {
+ const result = await updateEmailAction({
+ password: isGoogleAuth ? undefined : password,
+ newEmail,
+ confirmEmail,
+ });
- if (result.success) {
- toast.success(result.message);
- setPassword("");
- setNewEmail("");
- setConfirmEmail("");
- } else {
- toast.error(result.error);
- }
- });
- };
+ if (result.success) {
+ toast.success(result.message);
+ setPassword("");
+ setNewEmail("");
+ setConfirmEmail("");
+ } else {
+ toast.error(result.error);
+ }
+ });
+ };
- return (
-
- );
+
+
+ {isPending ? "Atualizando..." : "Atualizar e-mail"}
+
+
+
+ );
}
diff --git a/components/ajustes/update-name-form.tsx b/components/ajustes/update-name-form.tsx
index cf9dabe..0606d39 100644
--- a/components/ajustes/update-name-form.tsx
+++ b/components/ajustes/update-name-form.tsx
@@ -1,75 +1,75 @@
"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;
+ currentName: string;
};
export function UpdateNameForm({ currentName }: UpdateNameFormProps) {
- const [isPending, startTransition] = useTransition();
+ const [isPending, startTransition] = useTransition();
- // Dividir o nome atual em primeiro nome e sobrenome
- const nameParts = currentName.split(" ");
- const initialFirstName = nameParts[0] || "";
- const initialLastName = nameParts.slice(1).join(" ") || "";
+ // Dividir o nome atual em primeiro nome e sobrenome
+ const nameParts = currentName.split(" ");
+ const initialFirstName = nameParts[0] || "";
+ const initialLastName = nameParts.slice(1).join(" ") || "";
- const [firstName, setFirstName] = useState(initialFirstName);
- const [lastName, setLastName] = useState(initialLastName);
+ const [firstName, setFirstName] = useState(initialFirstName);
+ const [lastName, setLastName] = useState(initialLastName);
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
- startTransition(async () => {
- const result = await updateNameAction({
- firstName,
- lastName,
- });
+ startTransition(async () => {
+ const result = await updateNameAction({
+ firstName,
+ lastName,
+ });
- if (result.success) {
- toast.success(result.message);
- } else {
- toast.error(result.error);
- }
- });
- };
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.error);
+ }
+ });
+ };
- return (
-
-
-
-
- {isPending ? "Atualizando..." : "Atualizar nome"}
-
-
-
- );
+
+
+ {isPending ? "Atualizando..." : "Atualizar nome"}
+
+
+
+ );
}
diff --git a/components/ajustes/update-password-form.tsx b/components/ajustes/update-password-form.tsx
index d4d3298..145c063 100644
--- a/components/ajustes/update-password-form.tsx
+++ b/components/ajustes/update-password-form.tsx
@@ -1,363 +1,365 @@
"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;
- hasUppercase: boolean;
- hasNumber: boolean;
- hasSpecial: boolean;
- hasMinLength: boolean;
- hasMaxLength: boolean;
- isValid: boolean;
+ hasLowercase: boolean;
+ hasUppercase: boolean;
+ hasNumber: boolean;
+ hasSpecial: boolean;
+ hasMinLength: boolean;
+ hasMaxLength: boolean;
+ isValid: boolean;
}
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 hasMinLength = password.length >= 7;
- const hasMaxLength = password.length <= 23;
+ const hasLowercase = /[a-z]/.test(password);
+ const hasUppercase = /[A-Z]/.test(password);
+ const hasNumber = /\d/.test(password);
+ const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password);
+ const hasMinLength = password.length >= 7;
+ const hasMaxLength = password.length <= 23;
- return {
- hasLowercase,
- hasUppercase,
- hasNumber,
- hasSpecial,
- hasMinLength,
- hasMaxLength,
- isValid:
- hasLowercase &&
- hasUppercase &&
- hasNumber &&
- hasSpecial &&
- hasMinLength &&
- hasMaxLength,
- };
+ return {
+ hasLowercase,
+ hasUppercase,
+ hasNumber,
+ hasSpecial,
+ hasMinLength,
+ hasMaxLength,
+ isValid:
+ hasLowercase &&
+ hasUppercase &&
+ hasNumber &&
+ hasSpecial &&
+ hasMinLength &&
+ hasMaxLength,
+ };
}
function PasswordRequirement({ met, label }: { met: boolean; label: string }) {
- return (
-
- {met ? (
-
- ) : (
-
- )}
- {label}
-
- );
+ return (
+
+ {met ? (
+
+ ) : (
+
+ )}
+ {label}
+
+ );
}
type UpdatePasswordFormProps = {
- authProvider?: string; // 'google' | 'credential' | undefined
+ authProvider?: string; // 'google' | 'credential' | undefined
};
export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
- const [isPending, startTransition] = useTransition();
- const [currentPassword, setCurrentPassword] = useState("");
- const [newPassword, setNewPassword] = useState("");
- const [confirmPassword, setConfirmPassword] = useState("");
- const [showCurrentPassword, setShowCurrentPassword] = useState(false);
- const [showNewPassword, setShowNewPassword] = useState(false);
- const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+ const [isPending, startTransition] = useTransition();
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [showCurrentPassword, setShowCurrentPassword] = useState(false);
+ const [showNewPassword, setShowNewPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
- // Verificar se o usuário usa login via Google
- const isGoogleAuth = authProvider === "google";
+ // Verificar se o usuário usa login via Google
+ const isGoogleAuth = authProvider === "google";
- // Validação em tempo real: senhas coincidem
- const passwordsMatch = useMemo(() => {
- if (!confirmPassword) return null; // Não mostrar erro se campo vazio
- return newPassword === confirmPassword;
- }, [newPassword, confirmPassword]);
+ // Validação em tempo real: senhas coincidem
+ const passwordsMatch = useMemo(() => {
+ if (!confirmPassword) return null; // Não mostrar erro se campo vazio
+ return newPassword === confirmPassword;
+ }, [newPassword, confirmPassword]);
- // Validação de requisitos da senha
- const passwordValidation = useMemo(
- () => validatePassword(newPassword),
- [newPassword]
- );
+ // Validação de requisitos da senha
+ const passwordValidation = useMemo(
+ () => validatePassword(newPassword),
+ [newPassword],
+ );
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
- // Validação frontend antes de enviar
- if (!passwordValidation.isValid) {
- toast.error("A senha não atende aos requisitos de segurança");
- return;
- }
+ // Validação frontend antes de enviar
+ if (!passwordValidation.isValid) {
+ toast.error("A senha não atende aos requisitos de segurança");
+ return;
+ }
- if (newPassword !== confirmPassword) {
- toast.error("As senhas não coincidem");
- return;
- }
+ if (newPassword !== confirmPassword) {
+ toast.error("As senhas não coincidem");
+ return;
+ }
- startTransition(async () => {
- const result = await updatePasswordAction({
- currentPassword,
- newPassword,
- confirmPassword,
- });
+ startTransition(async () => {
+ const result = await updatePasswordAction({
+ currentPassword,
+ newPassword,
+ confirmPassword,
+ });
- if (result.success) {
- toast.success(result.message);
- setCurrentPassword("");
- setNewPassword("");
- setConfirmPassword("");
- } else {
- toast.error(result.error);
- }
- });
- };
+ if (result.success) {
+ toast.success(result.message);
+ setCurrentPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ } else {
+ toast.error(result.error);
+ }
+ });
+ };
- // Se o usuário usa Google OAuth, mostrar aviso
- if (isGoogleAuth) {
- return (
-
-
-
-
-
- Alteração de senha não disponível
-
-
- Você fez login usando sua conta do Google. A senha é gerenciada
- diretamente pelo Google e não pode ser alterada aqui. Para
- modificar sua senha, acesse as configurações de segurança da sua
- conta Google.
-
-
-
-
- );
- }
+ // Se o usuário usa Google OAuth, mostrar aviso
+ if (isGoogleAuth) {
+ return (
+
+
+
+
+
+ Alteração de senha não disponível
+
+
+ Você fez login usando sua conta do Google. A senha é gerenciada
+ diretamente pelo Google e não pode ser alterada aqui. Para
+ modificar sua senha, acesse as configurações de segurança da sua
+ conta Google.
+
+
+
+
+ );
+ }
- return (
-
-
- {/* Senha atual */}
-
-
- Senha atual *
-
-
- setCurrentPassword(e.target.value)}
- disabled={isPending}
- placeholder="Digite sua senha atual"
- required
- aria-required="true"
- aria-describedby="current-password-help"
- />
- setShowCurrentPassword(!showCurrentPassword)}
- className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
- aria-label={
- showCurrentPassword
- ? "Ocultar senha atual"
- : "Mostrar senha atual"
- }
- >
- {showCurrentPassword ? (
-
- ) : (
-
- )}
-
-
-
- Por segurança, confirme sua senha atual antes de alterá-la
-
-
+ return (
+
+
+ {/* Senha atual */}
+
+
+ Senha atual *
+
+
+ setCurrentPassword(e.target.value)}
+ disabled={isPending}
+ placeholder="Digite sua senha atual"
+ required
+ aria-required="true"
+ aria-describedby="current-password-help"
+ />
+ setShowCurrentPassword(!showCurrentPassword)}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+ aria-label={
+ showCurrentPassword
+ ? "Ocultar senha atual"
+ : "Mostrar senha atual"
+ }
+ >
+ {showCurrentPassword ? (
+
+ ) : (
+
+ )}
+
+
+
+ Por segurança, confirme sua senha atual antes de alterá-la
+
+
- {/* Nova senha */}
-
-
- Nova senha *
-
-
- setNewPassword(e.target.value)}
- disabled={isPending}
- placeholder="Crie uma senha forte"
- required
- minLength={7}
- maxLength={23}
- aria-required="true"
- aria-describedby="new-password-help"
- aria-invalid={
- newPassword.length > 0 && !passwordValidation.isValid
- }
- />
- setShowNewPassword(!showNewPassword)}
- className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
- aria-label={
- showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"
- }
- >
- {showNewPassword ? (
-
- ) : (
-
- )}
-
-
- {/* Indicadores de requisitos da senha */}
- {newPassword.length > 0 && (
-
- )}
-
+ {/* Nova senha */}
+
+
+ Nova senha *
+
+
+ setNewPassword(e.target.value)}
+ disabled={isPending}
+ placeholder="Crie uma senha forte"
+ required
+ minLength={7}
+ maxLength={23}
+ aria-required="true"
+ aria-describedby="new-password-help"
+ aria-invalid={
+ newPassword.length > 0 && !passwordValidation.isValid
+ }
+ />
+ setShowNewPassword(!showNewPassword)}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+ aria-label={
+ showNewPassword ? "Ocultar nova senha" : "Mostrar nova senha"
+ }
+ >
+ {showNewPassword ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Indicadores de requisitos da senha */}
+ {newPassword.length > 0 && (
+
+ )}
+
- {/* Confirmar nova senha */}
-
-
- Confirmar nova senha *
-
-
-
setConfirmPassword(e.target.value)}
- disabled={isPending}
- placeholder="Repita a senha"
- required
- minLength={6}
- aria-required="true"
- aria-describedby="confirm-password-help"
- aria-invalid={passwordsMatch === false}
- className={
- passwordsMatch === false
- ? "border-red-500 focus-visible:ring-red-500"
- : passwordsMatch === true
- ? "border-green-500 focus-visible:ring-green-500"
- : ""
- }
- />
-
setShowConfirmPassword(!showConfirmPassword)}
- className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
- aria-label={
- showConfirmPassword
- ? "Ocultar confirmação de senha"
- : "Mostrar confirmação de senha"
- }
- >
- {showConfirmPassword ? (
-
- ) : (
-
- )}
-
- {/* Indicador visual de match */}
- {passwordsMatch !== null && (
-
- {passwordsMatch ? (
-
- ) : (
-
- )}
-
- )}
-
- {/* Mensagem de erro em tempo real */}
- {passwordsMatch === false && (
-
-
- As senhas não coincidem
-
- )}
- {passwordsMatch === true && (
-
-
- As senhas coincidem
-
- )}
-
-
+ {/* Confirmar nova senha */}
+
+
+ Confirmar nova senha *
+
+
+
setConfirmPassword(e.target.value)}
+ disabled={isPending}
+ placeholder="Repita a senha"
+ required
+ minLength={6}
+ aria-required="true"
+ aria-describedby="confirm-password-help"
+ aria-invalid={passwordsMatch === false}
+ className={
+ passwordsMatch === false
+ ? "border-red-500 focus-visible:ring-red-500"
+ : passwordsMatch === true
+ ? "border-green-500 focus-visible:ring-green-500"
+ : ""
+ }
+ />
+
setShowConfirmPassword(!showConfirmPassword)}
+ className="absolute right-10 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+ aria-label={
+ showConfirmPassword
+ ? "Ocultar confirmação de senha"
+ : "Mostrar confirmação de senha"
+ }
+ >
+ {showConfirmPassword ? (
+
+ ) : (
+
+ )}
+
+ {/* Indicador visual de match */}
+ {passwordsMatch !== null && (
+
+ {passwordsMatch ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* Mensagem de erro em tempo real */}
+ {passwordsMatch === false && (
+
+
+ As senhas não coincidem
+
+ )}
+ {passwordsMatch === true && (
+
+
+ As senhas coincidem
+
+ )}
+
+
-
- 0 && !passwordValidation.isValid)
- }
- className="w-fit"
- >
- {isPending ? "Atualizando..." : "Atualizar senha"}
-
-
-
- );
+
+ 0 && !passwordValidation.isValid)
+ }
+ className="w-fit"
+ >
+ {isPending ? "Atualizando..." : "Atualizar senha"}
+
+
+
+ );
}
diff --git a/components/animated-theme-toggler.tsx b/components/animated-theme-toggler.tsx
index 39a6da2..f97cac8 100644
--- a/components/animated-theme-toggler.tsx
+++ b/components/animated-theme-toggler.tsx
@@ -1,122 +1,122 @@
"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";
import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
-import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
interface AnimatedThemeTogglerProps
- extends React.ComponentPropsWithoutRef<"button"> {
- duration?: number;
+ extends React.ComponentPropsWithoutRef<"button"> {
+ duration?: number;
}
export const AnimatedThemeToggler = ({
- className,
- duration = 400,
- ...props
+ className,
+ duration = 400,
+ ...props
}: AnimatedThemeTogglerProps) => {
- const [isDark, setIsDark] = useState(false);
- const buttonRef = useRef(null);
+ const [isDark, setIsDark] = useState(false);
+ const buttonRef = useRef(null);
- useEffect(() => {
- const updateTheme = () => {
- setIsDark(document.documentElement.classList.contains("dark"));
- };
+ useEffect(() => {
+ const updateTheme = () => {
+ setIsDark(document.documentElement.classList.contains("dark"));
+ };
- updateTheme();
+ updateTheme();
- const observer = new MutationObserver(updateTheme);
- observer.observe(document.documentElement, {
- attributes: true,
- attributeFilter: ["class"],
- });
+ const observer = new MutationObserver(updateTheme);
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
- return () => observer.disconnect();
- }, []);
+ return () => observer.disconnect();
+ }, []);
- const toggleTheme = useCallback(async () => {
- if (!buttonRef.current) return;
+ const toggleTheme = useCallback(async () => {
+ if (!buttonRef.current) return;
- await document.startViewTransition(() => {
- flushSync(() => {
- const newTheme = !isDark;
- setIsDark(newTheme);
- document.documentElement.classList.toggle("dark");
- localStorage.setItem("theme", newTheme ? "dark" : "light");
- });
- }).ready;
+ await document.startViewTransition(() => {
+ flushSync(() => {
+ const newTheme = !isDark;
+ setIsDark(newTheme);
+ document.documentElement.classList.toggle("dark");
+ localStorage.setItem("theme", newTheme ? "dark" : "light");
+ });
+ }).ready;
- const { top, left, width, height } =
- buttonRef.current.getBoundingClientRect();
- const x = left + width / 2;
- const y = top + height / 2;
- const maxRadius = Math.hypot(
- Math.max(left, window.innerWidth - left),
- Math.max(top, window.innerHeight - top)
- );
+ const { top, left, width, height } =
+ buttonRef.current.getBoundingClientRect();
+ const x = left + width / 2;
+ const y = top + height / 2;
+ const maxRadius = Math.hypot(
+ Math.max(left, window.innerWidth - left),
+ Math.max(top, window.innerHeight - top),
+ );
- document.documentElement.animate(
- {
- clipPath: [
- `circle(0px at ${x}px ${y}px)`,
- `circle(${maxRadius}px at ${x}px ${y}px)`,
- ],
- },
- {
- duration,
- easing: "ease-in-out",
- pseudoElement: "::view-transition-new(root)",
- }
- );
- }, [isDark, duration]);
+ document.documentElement.animate(
+ {
+ clipPath: [
+ `circle(0px at ${x}px ${y}px)`,
+ `circle(${maxRadius}px at ${x}px ${y}px)`,
+ ],
+ },
+ {
+ duration,
+ easing: "ease-in-out",
+ pseudoElement: "::view-transition-new(root)",
+ },
+ );
+ }, [isDark, duration]);
- return (
-
-
-
-
-
-
- {isDark ? (
-
- ) : (
-
- )}
-
- {isDark ? "Ativar tema claro" : "Ativar tema escuro"}
-
-
-
-
- {isDark ? "Tema claro" : "Tema escuro"}
-
-
- );
+ return (
+
+
+
+
+
+
+ {isDark ? (
+
+ ) : (
+
+ )}
+
+ {isDark ? "Ativar tema claro" : "Ativar tema escuro"}
+
+
+
+
+ {isDark ? "Tema claro" : "Tema escuro"}
+
+
+ );
};
diff --git a/components/anotacoes/note-card.tsx b/components/anotacoes/note-card.tsx
index 94dc85e..6b2e7ea 100644
--- a/components/anotacoes/note-card.tsx
+++ b/components/anotacoes/note-card.tsx
@@ -1,158 +1,158 @@
"use client";
-import { Badge } from "@/components/ui/badge";
-import { Card, CardContent, CardFooter } from "@/components/ui/card";
import {
- RiArchiveLine,
- RiCheckLine,
- RiDeleteBin5Line,
- RiEyeLine,
- RiInboxUnarchiveLine,
- RiPencilLine,
+ RiArchiveLine,
+ RiCheckLine,
+ RiDeleteBin5Line,
+ RiEyeLine,
+ RiInboxUnarchiveLine,
+ 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", {
- dateStyle: "medium",
+ dateStyle: "medium",
});
interface NoteCardProps {
- note: Note;
- onEdit?: (note: Note) => void;
- onDetails?: (note: Note) => void;
- onRemove?: (note: Note) => void;
- onArquivar?: (note: Note) => void;
- isArquivadas?: boolean;
+ note: Note;
+ onEdit?: (note: Note) => void;
+ onDetails?: (note: Note) => void;
+ onRemove?: (note: Note) => void;
+ onArquivar?: (note: Note) => void;
+ isArquivadas?: boolean;
}
export function NoteCard({
- note,
- onEdit,
- onDetails,
- onRemove,
- onArquivar,
- isArquivadas = false,
+ note,
+ onEdit,
+ onDetails,
+ onRemove,
+ onArquivar,
+ isArquivadas = false,
}: NoteCardProps) {
- const { formattedDate, displayTitle } = useMemo(() => {
- const resolvedTitle = note.title.trim().length
- ? note.title
- : "Anotação sem título";
+ const { formattedDate, displayTitle } = useMemo(() => {
+ const resolvedTitle = note.title.trim().length
+ ? note.title
+ : "Anotação sem título";
- return {
- displayTitle: resolvedTitle,
- formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)),
- };
- }, [note.createdAt, note.title]);
+ return {
+ displayTitle: resolvedTitle,
+ formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)),
+ };
+ }, [note.createdAt, note.title]);
- const isTask = note.type === "tarefa";
- const tasks = note.tasks || [];
- const completedCount = tasks.filter((t) => t.completed).length;
- const totalCount = tasks.length;
+ const isTask = note.type === "tarefa";
+ const tasks = note.tasks || [];
+ const completedCount = tasks.filter((t) => t.completed).length;
+ const totalCount = tasks.length;
- const actions = [
- {
- label: "editar",
- icon: ,
- onClick: onEdit,
- variant: "default" as const,
- },
- {
- label: "detalhes",
- icon: ,
- onClick: onDetails,
- variant: "default" as const,
- },
- {
- label: isArquivadas ? "desarquivar" : "arquivar",
- icon: isArquivadas ? (
-
- ) : (
-
- ),
- onClick: onArquivar,
- variant: "default" as const,
- },
- {
- label: "remover",
- icon: ,
- onClick: onRemove,
- variant: "destructive" as const,
- },
- ].filter((action) => typeof action.onClick === "function");
+ const actions = [
+ {
+ label: "editar",
+ icon: ,
+ onClick: onEdit,
+ variant: "default" as const,
+ },
+ {
+ label: "detalhes",
+ icon: ,
+ onClick: onDetails,
+ variant: "default" as const,
+ },
+ {
+ label: isArquivadas ? "desarquivar" : "arquivar",
+ icon: isArquivadas ? (
+
+ ) : (
+
+ ),
+ onClick: onArquivar,
+ variant: "default" as const,
+ },
+ {
+ label: "remover",
+ icon: ,
+ onClick: onRemove,
+ variant: "destructive" as const,
+ },
+ ].filter((action) => typeof action.onClick === "function");
- return (
-
-
-
-
-
- {displayTitle}
-
-
- {isTask && (
-
- {completedCount}/{totalCount} concluídas
-
- )}
-
+ return (
+
+
+
+
+
+ {displayTitle}
+
+
+ {isTask && (
+
+ {completedCount}/{totalCount} concluídas
+
+ )}
+
- {isTask ? (
-
- {tasks.slice(0, 5).map((task) => (
-
-
- {task.completed && (
-
- )}
-
-
- {task.text}
-
-
- ))}
- {tasks.length > 5 && (
-
- +{tasks.length - 5}
- {tasks.length - 5 === 1 ? "tarefa" : "tarefas"}...
-
- )}
-
- ) : (
-
- {note.description}
-
- )}
-
+ {isTask ? (
+
+ {tasks.slice(0, 5).map((task) => (
+
+
+ {task.completed && (
+
+ )}
+
+
+ {task.text}
+
+
+ ))}
+ {tasks.length > 5 && (
+
+ +{tasks.length - 5}
+ {tasks.length - 5 === 1 ? "tarefa" : "tarefas"}...
+
+ )}
+
+ ) : (
+
+ {note.description}
+
+ )}
+
- {actions.length > 0 ? (
-
- {actions.map(({ label, icon, onClick, variant }) => (
- onClick?.(note)}
- className={`flex items-center gap-1 font-medium transition-opacity hover:opacity-80 ${
- variant === "destructive" ? "text-destructive" : "text-primary"
- }`}
- aria-label={`${label} anotação`}
- >
- {icon}
- {label}
-
- ))}
-
- ) : null}
-
- );
+ {actions.length > 0 ? (
+
+ {actions.map(({ label, icon, onClick, variant }) => (
+ onClick?.(note)}
+ className={`flex items-center gap-1 font-medium transition-opacity hover:opacity-80 ${
+ variant === "destructive" ? "text-destructive" : "text-primary"
+ }`}
+ aria-label={`${label} anotação`}
+ >
+ {icon}
+ {label}
+
+ ))}
+
+ ) : null}
+
+ );
}
diff --git a/components/anotacoes/note-details-dialog.tsx b/components/anotacoes/note-details-dialog.tsx
index 45addf4..51d49e0 100644
--- a/components/anotacoes/note-details-dialog.tsx
+++ b/components/anotacoes/note-details-dialog.tsx
@@ -1,116 +1,116 @@
"use client";
+import { RiCheckLine } from "@remixicon/react";
+import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ 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";
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
- dateStyle: "long",
- timeStyle: "short",
+ dateStyle: "long",
+ timeStyle: "short",
});
interface NoteDetailsDialogProps {
- note: Note | null;
- open: boolean;
- onOpenChange: (open: boolean) => void;
+ note: Note | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
}
export function NoteDetailsDialog({
- note,
- open,
- onOpenChange,
+ note,
+ open,
+ onOpenChange,
}: NoteDetailsDialogProps) {
- const { formattedDate, displayTitle } = useMemo(() => {
- if (!note) {
- return { formattedDate: "", displayTitle: "" };
- }
+ const { formattedDate, displayTitle } = useMemo(() => {
+ if (!note) {
+ return { formattedDate: "", displayTitle: "" };
+ }
- const title = note.title.trim().length ? note.title : "Anotação sem título";
+ const title = note.title.trim().length ? note.title : "Anotação sem título";
- return {
- formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)),
- displayTitle: title,
- };
- }, [note]);
+ return {
+ formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)),
+ displayTitle: title,
+ };
+ }, [note]);
- if (!note) {
- return null;
- }
+ if (!note) {
+ return null;
+ }
- const isTask = note.type === "tarefa";
- const tasks = note.tasks || [];
- const completedCount = tasks.filter((t) => t.completed).length;
- const totalCount = tasks.length;
+ const isTask = note.type === "tarefa";
+ const tasks = note.tasks || [];
+ const completedCount = tasks.filter((t) => t.completed).length;
+ const totalCount = tasks.length;
- return (
-
-
-
-
- {displayTitle}
- {isTask && (
-
- {completedCount}/{totalCount}
-
- )}
-
- {formattedDate}
-
+ return (
+
+
+
+
+ {displayTitle}
+ {isTask && (
+
+ {completedCount}/{totalCount}
+
+ )}
+
+ {formattedDate}
+
- {isTask ? (
-
- {tasks.map((task) => (
-
-
- {task.completed && (
-
- )}
-
-
- {task.text}
-
-
- ))}
-
- ) : (
-
- {note.description}
-
- )}
+ {isTask ? (
+
+ {tasks.map((task) => (
+
+
+ {task.completed && (
+
+ )}
+
+
+ {task.text}
+
+
+ ))}
+
+ ) : (
+
+ {note.description}
+
+ )}
-
-
-
- Fechar
-
-
-
-
-
- );
+
+
+
+ Fechar
+
+
+
+
+
+ );
}
diff --git a/components/anotacoes/note-dialog.tsx b/components/anotacoes/note-dialog.tsx
index c3b2900..4e201af 100644
--- a/components/anotacoes/note-dialog.tsx
+++ b/components/anotacoes/note-dialog.tsx
@@ -1,45 +1,45 @@
"use client";
+import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import {
- createNoteAction,
- updateNoteAction,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ useTransition,
+} from "react";
+import { toast } from "sonner";
+import {
+ createNoteAction,
+ updateNoteAction,
} from "@/app/(dashboard)/anotacoes/actions";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
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";
type NoteDialogMode = "create" | "update";
interface NoteDialogProps {
- mode: NoteDialogMode;
- trigger?: ReactNode;
- note?: Note;
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
+ mode: NoteDialogMode;
+ trigger?: ReactNode;
+ note?: Note;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
}
const MAX_TITLE = 30;
@@ -47,426 +47,426 @@ const MAX_DESC = 350;
const normalize = (s: string) => s.replace(/\s+/g, " ").trim();
const buildInitialValues = (note?: Note): NoteFormValues => ({
- title: note?.title ?? "",
- description: note?.description ?? "",
- type: note?.type ?? "nota",
- tasks: note?.tasks ?? [],
+ title: note?.title ?? "",
+ description: note?.description ?? "",
+ type: note?.type ?? "nota",
+ tasks: note?.tasks ?? [],
});
const generateTaskId = () => {
- return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+ return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
export function NoteDialog({
- mode,
- trigger,
- note,
- open,
- onOpenChange,
+ mode,
+ trigger,
+ note,
+ open,
+ onOpenChange,
}: NoteDialogProps) {
- const [isPending, startTransition] = useTransition();
- const [errorMessage, setErrorMessage] = useState(null);
- const [newTaskText, setNewTaskText] = useState("");
+ const [isPending, startTransition] = useTransition();
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [newTaskText, setNewTaskText] = useState("");
- const titleRef = useRef(null);
- const descRef = useRef(null);
- const newTaskRef = useRef(null);
+ const titleRef = useRef(null);
+ const descRef = useRef(null);
+ const newTaskRef = useRef(null);
- // Use controlled state hook for dialog open state
- const [dialogOpen, setDialogOpen] = useControlledState(
- open,
- false,
- onOpenChange
- );
+ // Use controlled state hook for dialog open state
+ const [dialogOpen, setDialogOpen] = useControlledState(
+ open,
+ false,
+ onOpenChange,
+ );
- const initialState = buildInitialValues(note);
+ const initialState = buildInitialValues(note);
- // Use form state hook for form management
- const { formState, updateField, setFormState } =
- useFormState(initialState);
+ // Use form state hook for form management
+ const { formState, updateField, setFormState } =
+ useFormState(initialState);
- useEffect(() => {
- if (dialogOpen) {
- setFormState(buildInitialValues(note));
- setErrorMessage(null);
- setNewTaskText("");
- requestAnimationFrame(() => titleRef.current?.focus());
- }
- }, [dialogOpen, note, setFormState]);
+ useEffect(() => {
+ if (dialogOpen) {
+ setFormState(buildInitialValues(note));
+ setErrorMessage(null);
+ setNewTaskText("");
+ requestAnimationFrame(() => titleRef.current?.focus());
+ }
+ }, [dialogOpen, note, setFormState]);
- const title = mode === "create" ? "Nova anotação" : "Editar anotação";
- const description =
- mode === "create"
- ? "Escolha entre uma nota simples ou uma lista de tarefas."
- : "Altere o título e/ou conteúdo desta anotação.";
- const submitLabel =
- mode === "create" ? "Salvar anotação" : "Atualizar anotação";
+ const title = mode === "create" ? "Nova anotação" : "Editar anotação";
+ const description =
+ mode === "create"
+ ? "Escolha entre uma nota simples ou uma lista de tarefas."
+ : "Altere o título e/ou conteúdo desta anotação.";
+ const submitLabel =
+ mode === "create" ? "Salvar anotação" : "Atualizar anotação";
- const titleCount = formState.title.length;
- const descCount = formState.description.length;
- const isNote = formState.type === "nota";
+ const titleCount = formState.title.length;
+ const descCount = formState.description.length;
+ const isNote = formState.type === "nota";
- const onlySpaces =
- normalize(formState.title).length === 0 ||
- (isNote && normalize(formState.description).length === 0) ||
- (!isNote && (!formState.tasks || formState.tasks.length === 0));
+ const onlySpaces =
+ normalize(formState.title).length === 0 ||
+ (isNote && normalize(formState.description).length === 0) ||
+ (!isNote && (!formState.tasks || formState.tasks.length === 0));
- const invalidLen = titleCount > MAX_TITLE || descCount > MAX_DESC;
+ const invalidLen = titleCount > MAX_TITLE || descCount > MAX_DESC;
- const unchanged =
- mode === "update" &&
- normalize(formState.title) === normalize(note?.title ?? "") &&
- normalize(formState.description) === normalize(note?.description ?? "") &&
- JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
+ const unchanged =
+ mode === "update" &&
+ normalize(formState.title) === normalize(note?.title ?? "") &&
+ normalize(formState.description) === normalize(note?.description ?? "") &&
+ JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
- const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
+ const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
- const handleOpenChange = useCallback(
- (v: boolean) => {
- setDialogOpen(v);
- if (!v) setErrorMessage(null);
- },
- [setDialogOpen]
- );
+ const handleOpenChange = useCallback(
+ (v: boolean) => {
+ setDialogOpen(v);
+ if (!v) setErrorMessage(null);
+ },
+ [setDialogOpen],
+ );
- const handleKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
- (e.currentTarget as HTMLFormElement).requestSubmit();
- if (e.key === "Escape") handleOpenChange(false);
- },
- [handleOpenChange]
- );
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
+ (e.currentTarget as HTMLFormElement).requestSubmit();
+ if (e.key === "Escape") handleOpenChange(false);
+ },
+ [handleOpenChange],
+ );
- const handleAddTask = useCallback(() => {
- const text = normalize(newTaskText);
- if (!text) return;
+ const handleAddTask = useCallback(() => {
+ const text = normalize(newTaskText);
+ if (!text) return;
- const newTask: Task = {
- id: generateTaskId(),
- text,
- completed: false,
- };
+ const newTask: Task = {
+ id: generateTaskId(),
+ text,
+ completed: false,
+ };
- updateField("tasks", [...(formState.tasks || []), newTask]);
- setNewTaskText("");
- requestAnimationFrame(() => newTaskRef.current?.focus());
- }, [newTaskText, formState.tasks, updateField]);
+ updateField("tasks", [...(formState.tasks || []), newTask]);
+ setNewTaskText("");
+ requestAnimationFrame(() => newTaskRef.current?.focus());
+ }, [newTaskText, formState.tasks, updateField]);
- const handleRemoveTask = useCallback(
- (taskId: string) => {
- updateField(
- "tasks",
- (formState.tasks || []).filter((t) => t.id !== taskId)
- );
- },
- [formState.tasks, updateField]
- );
+ const handleRemoveTask = useCallback(
+ (taskId: string) => {
+ updateField(
+ "tasks",
+ (formState.tasks || []).filter((t) => t.id !== taskId),
+ );
+ },
+ [formState.tasks, updateField],
+ );
- const handleToggleTask = useCallback(
- (taskId: string) => {
- updateField(
- "tasks",
- (formState.tasks || []).map((t) =>
- t.id === taskId ? { ...t, completed: !t.completed } : t
- )
- );
- },
- [formState.tasks, updateField]
- );
+ const handleToggleTask = useCallback(
+ (taskId: string) => {
+ updateField(
+ "tasks",
+ (formState.tasks || []).map((t) =>
+ t.id === taskId ? { ...t, completed: !t.completed } : t,
+ ),
+ );
+ },
+ [formState.tasks, updateField],
+ );
- const handleSubmit = useCallback(
- (e: React.FormEvent) => {
- e.preventDefault();
- setErrorMessage(null);
+ const handleSubmit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+ setErrorMessage(null);
- const payload = {
- title: normalize(formState.title),
- description: normalize(formState.description),
- type: formState.type,
- tasks: formState.tasks,
- };
+ const payload = {
+ title: normalize(formState.title),
+ description: normalize(formState.description),
+ type: formState.type,
+ tasks: formState.tasks,
+ };
- if (onlySpaces || invalidLen) {
- setErrorMessage("Preencha os campos respeitando os limites.");
- titleRef.current?.focus();
- return;
- }
+ if (onlySpaces || invalidLen) {
+ setErrorMessage("Preencha os campos respeitando os limites.");
+ titleRef.current?.focus();
+ return;
+ }
- if (mode === "update" && !note?.id) {
- const msg = "Não foi possível identificar a anotação a ser editada.";
- setErrorMessage(msg);
- toast.error(msg);
- return;
- }
+ if (mode === "update" && !note?.id) {
+ const msg = "Não foi possível identificar a anotação a ser editada.";
+ setErrorMessage(msg);
+ toast.error(msg);
+ return;
+ }
- if (unchanged) {
- toast.info("Nada para atualizar.");
- return;
- }
+ if (unchanged) {
+ toast.info("Nada para atualizar.");
+ return;
+ }
- startTransition(async () => {
- let result;
- if (mode === "create") {
- result = await createNoteAction(payload);
- } else {
- if (!note?.id) {
- const msg = "ID da anotação não encontrado.";
- setErrorMessage(msg);
- toast.error(msg);
- return;
- }
- result = await updateNoteAction({ id: note.id, ...payload });
- }
+ startTransition(async () => {
+ let result;
+ if (mode === "create") {
+ result = await createNoteAction(payload);
+ } else {
+ if (!note?.id) {
+ const msg = "ID da anotação não encontrado.";
+ setErrorMessage(msg);
+ toast.error(msg);
+ return;
+ }
+ result = await updateNoteAction({ id: note.id, ...payload });
+ }
- if (result.success) {
- toast.success(result.message);
- setDialogOpen(false);
- return;
- }
- setErrorMessage(result.error);
- toast.error(result.error);
- titleRef.current?.focus();
- });
- },
- [
- formState.title,
- formState.description,
- formState.type,
- formState.tasks,
- mode,
- note,
- setDialogOpen,
- onlySpaces,
- unchanged,
- invalidLen,
- ]
- );
+ if (result.success) {
+ toast.success(result.message);
+ setDialogOpen(false);
+ return;
+ }
+ setErrorMessage(result.error);
+ toast.error(result.error);
+ titleRef.current?.focus();
+ });
+ },
+ [
+ formState.title,
+ formState.description,
+ formState.type,
+ formState.tasks,
+ mode,
+ note,
+ setDialogOpen,
+ onlySpaces,
+ unchanged,
+ invalidLen,
+ ],
+ );
- return (
-
- {trigger ? {trigger} : null}
-
-
- {title}
- {description}
-
+ return (
+
+ {trigger ? {trigger} : null}
+
+
+