feat: adição de novos ícones SVG e configuração do ambiente

- Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter
- Implementados ícones para modos claro e escuro do ChatGPT
- Criado script de inicialização para PostgreSQL com extensão pgcrypto
- Adicionado script de configuração de ambiente que faz backup do .env
- Configurado tsconfig.json para TypeScript com opções de compilação
This commit is contained in:
Felipe Coutinho
2025-11-15 15:49:36 -03:00
commit ea0b8618e0
441 changed files with 53569 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
"use server";
import {
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
} from "@/db/schema";
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
PERIOD_FORMAT_REGEX,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const updateInvoicePaymentStatusSchema = z.object({
cartaoId: z
.string({ message: "Cartão inválido." })
.uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
status: z.enum(
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]]
),
paymentDate: z.string().optional(),
});
type UpdateInvoicePaymentStatusInput = z.infer<
typeof updateInvoicePaymentStatusSchema
>;
type ActionResult =
| { success: true; message: string }
| { success: false; error: string };
const successMessageByStatus: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.",
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
};
const formatDecimal = (value: number) =>
(Math.round(value * 100) / 100).toFixed(2);
export async function updateInvoicePaymentStatusAction(
input: UpdateInvoicePaymentStatusInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateInvoicePaymentStatusSchema.parse(input);
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({
columns: { id: true, contaId: true, name: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
});
if (!card) {
throw new Error("Cartão não encontrado.");
}
const existingInvoice = await tx.query.faturas.findFirst({
columns: {
id: true,
},
where: and(
eq(faturas.cartaoId, data.cartaoId),
eq(faturas.userId, user.id),
eq(faturas.period, data.period)
),
});
if (existingInvoice) {
await tx
.update(faturas)
.set({
paymentStatus: data.status,
})
.where(eq(faturas.id, existingInvoice.id));
} else {
await tx.insert(faturas).values({
cartaoId: data.cartaoId,
period: data.period,
paymentStatus: data.status,
userId: user.id,
});
}
const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID;
await tx
.update(lancamentos)
.set({ isSettled: shouldMarkAsPaid })
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period)
)
);
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
if (shouldMarkAsPaid) {
const [adminShareRow] = await tx
.select({
total: sql<number>`
coalesce(
sum(
case
when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount}
else 0
end
),
0
)
`,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.cartaoId, card.id),
eq(lancamentos.period, data.period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
)
);
const adminShare = Math.abs(Number(adminShareRow?.total ?? 0));
if (adminShare > 0 && card.contaId) {
const adminPagador = await tx.query.pagadores.findFirst({
columns: { id: true },
where: and(
eq(pagadores.userId, user.id),
eq(pagadores.role, PAGADOR_ROLE_ADMIN)
),
});
const paymentCategory = await tx.query.categorias.findFirst({
columns: { id: true },
where: and(
eq(categorias.userId, user.id),
eq(categorias.name, "Pagamentos")
),
});
if (adminPagador) {
// Usar a data customizada ou a data atual como data de pagamento
const invoiceDate = data.paymentDate
? new Date(data.paymentDate)
: new Date();
const amount = `-${formatDecimal(adminShare)}`;
const payload = {
condition: "À vista",
name: `Pagamento fatura - ${card.name}`,
paymentMethod: "Pix",
note: invoiceNote,
amount,
purchaseDate: invoiceDate,
transactionType: "Despesa" as const,
period: data.period,
isSettled: true,
userId: user.id,
contaId: card.contaId,
categoriaId: paymentCategory?.id ?? null,
pagadorId: adminPagador.id,
};
const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true },
where: and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote)
),
});
if (existingPayment) {
await tx
.update(lancamentos)
.set(payload)
.where(eq(lancamentos.id, existingPayment.id));
} else {
await tx.insert(lancamentos).values(payload);
}
}
}
} else {
await tx
.delete(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote)
)
);
}
});
revalidatePath(`/cartoes/${data.cartaoId}/fatura`);
revalidatePath("/cartoes");
revalidatePath("/contas");
return { success: true, message: successMessageByStatus[data.status] };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}
const updatePaymentDateSchema = z.object({
cartaoId: z
.string({ message: "Cartão inválido." })
.uuid("Cartão inválido."),
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
paymentDate: z.string({ message: "Data de pagamento inválida." }),
});
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;
export async function updatePaymentDateAction(
input: UpdatePaymentDateInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updatePaymentDateSchema.parse(input);
await db.transaction(async (tx: typeof db) => {
const card = await tx.query.cartoes.findFirst({
columns: { id: true },
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
});
if (!card) {
throw new Error("Cartão não encontrado.");
}
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
const existingPayment = await tx.query.lancamentos.findFirst({
columns: { id: true },
where: and(
eq(lancamentos.userId, user.id),
eq(lancamentos.note, invoiceNote)
),
});
if (!existingPayment) {
throw new Error("Pagamento não encontrado.");
}
await tx
.update(lancamentos)
.set({
purchaseDate: new Date(data.paymentDate),
})
.where(eq(lancamentos.id, existingPayment.id));
});
revalidatePath(`/cartoes/${data.cartaoId}/fatura`);
revalidatePath("/cartoes");
revalidatePath("/contas");
return { success: true, message: "Data de pagamento atualizada." };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}

View File

@@ -0,0 +1,104 @@
import { cartoes, faturas, lancamentos } from "@/db/schema";
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { and, eq, sum } from "drizzle-orm";
const toNumber = (value: string | number | null | undefined) => {
if (typeof value === "number") {
return value;
}
if (value === null || value === undefined) {
return 0;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed;
};
export async function fetchCardData(userId: string, cartaoId: string) {
const card = await db.query.cartoes.findFirst({
columns: {
id: true,
name: true,
brand: true,
closingDay: true,
dueDay: true,
logo: true,
limit: true,
status: true,
note: true,
contaId: true,
},
where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)),
});
return card;
}
export async function fetchInvoiceData(
userId: string,
cartaoId: string,
selectedPeriod: string
): Promise<{
totalAmount: number;
invoiceStatus: InvoicePaymentStatus;
paymentDate: Date | null;
}> {
const [invoiceRow, totalRow] = await Promise.all([
db.query.faturas.findFirst({
columns: {
id: true,
period: true,
paymentStatus: true,
},
where: and(
eq(faturas.cartaoId, cartaoId),
eq(faturas.userId, userId),
eq(faturas.period, selectedPeriod)
),
}),
db
.select({ totalAmount: sum(lancamentos.amount) })
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cartaoId),
eq(lancamentos.period, selectedPeriod)
)
),
]);
const totalAmount = toNumber(totalRow[0]?.totalAmount);
const isInvoiceStatus = (
value: string | null | undefined
): value is InvoicePaymentStatus =>
!!value && ["pendente", "pago"].includes(value);
const invoiceStatus = isInvoiceStatus(invoiceRow?.paymentStatus)
? invoiceRow?.paymentStatus
: INVOICE_PAYMENT_STATUS.PENDING;
// Buscar data do pagamento se a fatura estiver paga
let paymentDate: Date | null = null;
if (invoiceStatus === INVOICE_PAYMENT_STATUS.PAID) {
const invoiceNote = buildInvoicePaymentNote(cartaoId, selectedPeriod);
const paymentLancamento = await db.query.lancamentos.findFirst({
columns: {
purchaseDate: true,
},
where: and(
eq(lancamentos.userId, userId),
eq(lancamentos.note, invoiceNote)
),
});
paymentDate = paymentLancamento?.purchaseDate
? new Date(paymentLancamento.purchaseDate)
: null;
}
return { totalAmount, invoiceStatus, paymentDate };
}

View File

@@ -0,0 +1,41 @@
import {
FilterSkeleton,
InvoiceSummaryCardSkeleton,
TransactionsTableSkeleton,
} from "@/components/skeletons";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state para a página de fatura de cartão
* Layout: MonthPicker + InvoiceSummaryCard + Filtros + Tabela de lançamentos
*/
export default function FaturaLoading() {
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Invoice Summary Card */}
<section className="flex flex-col gap-4">
<InvoiceSummaryCardSkeleton />
</section>
{/* Seção de lançamentos */}
<section className="flex flex-col gap-4">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Filtros */}
<FilterSkeleton />
{/* Tabela */}
<TransactionsTableSkeleton />
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,199 @@
import { CardDialog } from "@/components/cartoes/card-dialog";
import type { Card } from "@/components/cartoes/types";
import { InvoiceSummaryCard } from "@/components/faturas/invoice-summary-card";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import MonthPicker from "@/components/month-picker/month-picker";
import { Button } from "@/components/ui/button";
import { lancamentos, type Conta } from "@/db/schema";
import { db } from "@/lib/db";
import { getUserId } from "@/lib/auth/server";
import {
buildLancamentoWhere,
buildOptionSets,
buildSluggedFilters,
buildSlugMaps,
extractLancamentoSearchFilters,
fetchLancamentoFilterSources,
getSingleParam,
mapLancamentosData,
type ResolvedSearchParams,
} from "@/lib/lancamentos/page-helpers";
import { loadLogoOptions } from "@/lib/logo/options";
import { parsePeriodParam } from "@/lib/utils/period";
import { RiPencilLine } from "@remixicon/react";
import { and, desc } from "drizzle-orm";
import { notFound } from "next/navigation";
import { fetchCardData, fetchInvoiceData } from "./data";
type PageSearchParams = Promise<ResolvedSearchParams>;
type PageProps = {
params: Promise<{ cartaoId: string }>;
searchParams?: PageSearchParams;
};
export default async function Page({ params, searchParams }: PageProps) {
const { cartaoId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
const {
period: selectedPeriod,
monthName,
year,
} = parsePeriodParam(periodoParamRaw);
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
const card = await fetchCardData(userId, cartaoId);
if (!card) {
notFound();
}
const [filterSources, logoOptions, invoiceData] = await Promise.all([
fetchLancamentoFilterSources(userId),
loadLogoOptions(),
fetchInvoiceData(userId, cartaoId, selectedPeriod),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
const filters = buildLancamentoWhere({
userId,
period: selectedPeriod,
filters: searchFilters,
slugMaps,
cardId: card.id,
});
const lancamentoRows = await db.query.lancamentos.findMany({
where: and(...filters),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: desc(lancamentos.purchaseDate),
});
const lancamentosData = mapLancamentosData(lancamentoRows);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
limitCartaoId: card.id,
});
const accountOptions = filterSources.contaRows.map((conta: Conta) => ({
id: conta.id,
name: conta.name ?? "Conta",
}));
const contaName =
filterSources.contaRows.find((conta: Conta) => conta.id === card.contaId)
?.name ?? "Conta";
const cardDialogData: Card = {
id: card.id,
name: card.name,
brand: card.brand ?? "",
status: card.status ?? "",
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note ?? null,
logo: card.logo,
limit:
card.limit !== null && card.limit !== undefined
? Number(card.limit)
: null,
contaId: card.contaId,
contaName,
limitInUse: null,
limitAvailable: null,
};
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
const limitAmount =
card.limit !== null && card.limit !== undefined ? Number(card.limit) : null;
const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice(
1
)} de ${year}`;
return (
<main className="flex flex-col gap-6">
<MonthPicker />
<section className="flex flex-col gap-4">
<InvoiceSummaryCard
cartaoId={card.id}
period={selectedPeriod}
cardName={card.name}
cardBrand={card.brand ?? null}
cardStatus={card.status ?? null}
closingDay={card.closingDay}
dueDay={card.dueDay}
periodLabel={periodLabel}
totalAmount={totalAmount}
limitAmount={limitAmount}
invoiceStatus={invoiceStatus}
paymentDate={paymentDate}
logo={card.logo}
actions={
<CardDialog
mode="update"
card={cardDialogData}
logoOptions={logoOptions}
accounts={accountOptions}
trigger={
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
aria-label="Editar cartão"
>
<RiPencilLine className="size-4" />
</Button>
}
/>
}
/>
</section>
<section className="flex flex-col gap-4">
<LancamentosSection
lancamentos={lancamentosData}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
allowCreate
defaultCartaoId={card.id}
defaultPaymentMethod="Cartão de crédito"
lockCartaoSelection
lockPaymentMethod
/>
</section>
</main>
);
}

View File

@@ -0,0 +1,165 @@
"use server";
import { cartoes, contas } from "@/db/schema";
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
import { revalidateForEntity } from "@/lib/actions/helpers";
import {
dayOfMonthSchema,
noteSchema,
optionalDecimalSchema,
uuidSchema,
} from "@/lib/schemas/common";
import { formatDecimalForDb } from "@/lib/utils/currency";
import { normalizeFilePath } from "@/lib/utils/string";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
const cardBaseSchema = z.object({
name: z
.string({ message: "Informe o nome do cartão." })
.trim()
.min(1, "Informe o nome do cartão."),
brand: z
.string({ message: "Informe a bandeira." })
.trim()
.min(1, "Informe a bandeira."),
status: z
.string({ message: "Informe o status do cartão." })
.trim()
.min(1, "Informe o status do cartão."),
closingDay: dayOfMonthSchema,
dueDay: dayOfMonthSchema,
note: noteSchema,
limit: optionalDecimalSchema,
logo: z
.string({ message: "Selecione um logo." })
.trim()
.min(1, "Selecione um logo."),
contaId: uuidSchema("Conta"),
});
const createCardSchema = cardBaseSchema;
const updateCardSchema = cardBaseSchema.extend({
id: uuidSchema("Cartão"),
});
const deleteCardSchema = z.object({
id: uuidSchema("Cartão"),
});
type CardCreateInput = z.infer<typeof createCardSchema>;
type CardUpdateInput = z.infer<typeof updateCardSchema>;
type CardDeleteInput = z.infer<typeof deleteCardSchema>;
async function assertAccountOwnership(userId: string, contaId: string) {
const account = await db.query.contas.findFirst({
columns: { id: true },
where: and(eq(contas.id, contaId), eq(contas.userId, userId)),
});
if (!account) {
throw new Error("Conta vinculada não encontrada.");
}
}
export async function createCardAction(
input: CardCreateInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createCardSchema.parse(input);
await assertAccountOwnership(user.id, data.contaId);
const logoFile = normalizeFilePath(data.logo);
await db.insert(cartoes).values({
name: data.name,
brand: data.brand,
status: data.status,
closingDay: data.closingDay,
dueDay: data.dueDay,
note: data.note ?? null,
limit: formatDecimalForDb(data.limit),
logo: logoFile,
contaId: data.contaId,
userId: user.id,
});
revalidateForEntity("cartoes");
return { success: true, message: "Cartão criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateCardAction(
input: CardUpdateInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateCardSchema.parse(input);
await assertAccountOwnership(user.id, data.contaId);
const logoFile = normalizeFilePath(data.logo);
const [updated] = await db
.update(cartoes)
.set({
name: data.name,
brand: data.brand,
status: data.status,
closingDay: data.closingDay,
dueDay: data.dueDay,
note: data.note ?? null,
limit: formatDecimalForDb(data.limit),
logo: logoFile,
contaId: data.contaId,
})
.where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id)))
.returning();
if (!updated) {
return {
success: false,
error: "Cartão não encontrado.",
};
}
revalidateForEntity("cartoes");
return { success: true, message: "Cartão atualizado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteCardAction(
input: CardDeleteInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteCardSchema.parse(input);
const [deleted] = await db
.delete(cartoes)
.where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id)))
.returning({ id: cartoes.id });
if (!deleted) {
return {
success: false,
error: "Cartão não encontrado.",
};
}
revalidateForEntity("cartoes");
return { success: true, message: "Cartão removido com sucesso." };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -0,0 +1,110 @@
import { cartoes, contas, lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
import { loadLogoOptions } from "@/lib/logo/options";
import { and, eq, isNull, or, sql } from "drizzle-orm";
export type CardData = {
id: string;
name: string;
brand: string | null;
status: string | null;
closingDay: number;
dueDay: number;
note: string | null;
logo: string | null;
limit: number | null;
limitInUse: number;
limitAvailable: number | null;
contaId: string;
contaName: string;
};
export type AccountSimple = {
id: string;
name: string;
logo: string | null;
};
export async function fetchCardsForUser(userId: string): Promise<{
cards: CardData[];
accounts: AccountSimple[];
logoOptions: LogoOption[];
}> {
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
db.query.cartoes.findMany({
orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)],
where: eq(cartoes.userId, userId),
with: {
conta: {
columns: {
id: true,
name: true,
},
},
},
}),
db.query.contas.findMany({
orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)],
where: eq(contas.userId, userId),
columns: {
id: true,
name: true,
logo: true,
},
}),
loadLogoOptions(),
db
.select({
cartaoId: lancamentos.cartaoId,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false))
)
)
.groupBy(lancamentos.cartaoId),
]);
const usageMap = new Map<string, number>();
usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => {
if (!row.cartaoId) return;
usageMap.set(row.cartaoId, Number(row.total ?? 0));
});
const cards = cardRows.map((card) => ({
id: card.id,
name: card.name,
brand: card.brand,
status: card.status,
closingDay: card.closingDay,
dueDay: card.dueDay,
note: card.note,
logo: card.logo,
limit: card.limit ? Number(card.limit) : null,
limitInUse: (() => {
const total = usageMap.get(card.id) ?? 0;
return total < 0 ? Math.abs(total) : 0;
})(),
limitAvailable: (() => {
if (!card.limit) {
return null;
}
const total = usageMap.get(card.id) ?? 0;
const inUse = total < 0 ? Math.abs(total) : 0;
return Math.max(Number(card.limit) - inUse, 0);
})(),
contaId: card.contaId,
contaName: card.conta?.name ?? "Conta não encontrada",
}));
const accounts = accountRows.map((account) => ({
id: account.id,
name: account.name,
logo: account.logo,
}));
return { cards, accounts, logoOptions };
}

View File

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

View File

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

View File

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