feat(finance): refina fluxos de transacoes e pagadores

This commit is contained in:
Felipe Coutinho
2026-03-09 17:13:44 +00:00
parent 69da27276c
commit ada1377640
58 changed files with 1288 additions and 1559 deletions

View File

@@ -20,7 +20,10 @@ import {
PERIOD_FORMAT_REGEX, PERIOD_FORMAT_REGEX,
} from "@/lib/faturas"; } from "@/lib/faturas";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { parseLocalDateString } from "@/lib/utils/date"; import { getBusinessTodayDate, parseLocalDateString } from "@/lib/utils/date";
const isValidPaymentDate = (value: string) =>
!Number.isNaN(parseLocalDateString(value).getTime());
const updateInvoicePaymentStatusSchema = z.object({ const updateInvoicePaymentStatusSchema = z.object({
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."), cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
@@ -30,7 +33,12 @@ const updateInvoicePaymentStatusSchema = z.object({
status: z.enum( status: z.enum(
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]], INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
), ),
paymentDate: z.string().optional(), paymentDate: z
.string()
.optional()
.refine((value) => !value || isValidPaymentDate(value), {
message: "Data de pagamento inválida.",
}),
}); });
type UpdateInvoicePaymentStatusInput = z.infer< type UpdateInvoicePaymentStatusInput = z.infer<
@@ -157,7 +165,7 @@ export async function updateInvoicePaymentStatusAction(
// Usar a data customizada ou a data atual como data de pagamento // Usar a data customizada ou a data atual como data de pagamento
const invoiceDate = data.paymentDate const invoiceDate = data.paymentDate
? parseLocalDateString(data.paymentDate) ? parseLocalDateString(data.paymentDate)
: new Date(); : getBusinessTodayDate();
const amount = `-${formatDecimal(adminShare)}`; const amount = `-${formatDecimal(adminShare)}`;
const payload = { const payload = {
@@ -229,7 +237,11 @@ const updatePaymentDateSchema = z.object({
period: z period: z
.string({ message: "Período inválido." }) .string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."), .regex(PERIOD_FORMAT_REGEX, "Período inválido."),
paymentDate: z.string({ message: "Data de pagamento inválida." }), paymentDate: z
.string({ message: "Data de pagamento inválida." })
.refine((value) => isValidPaymentDate(value), {
message: "Data de pagamento inválida.",
}),
}); });
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>; type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;

View File

@@ -11,7 +11,6 @@ import {
pagadores, pagadores,
} from "@/db/schema"; } from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { import {
INITIAL_BALANCE_CONDITION, INITIAL_BALANCE_CONDITION,
@@ -30,8 +29,10 @@ import {
sendPagadorAutoEmails, sendPagadorAutoEmails,
} from "@/lib/pagadores/notifications"; } from "@/lib/pagadores/notifications";
import { noteSchema, uuidSchema } from "@/lib/schemas/common"; import { noteSchema, uuidSchema } from "@/lib/schemas/common";
import type { ActionResult } from "@/lib/types/actions";
import { formatDecimalForDbRequired } from "@/lib/utils/currency"; import { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { getTodayDate, parseLocalDateString } from "@/lib/utils/date"; import { getBusinessTodayDate, parseLocalDateString } from "@/lib/utils/date";
import { addMonthsToPeriod } from "@/lib/utils/period";
// ============================================================================ // ============================================================================
// Authorization Validation Functions // Authorization Validation Functions
@@ -108,11 +109,14 @@ const resolvePeriod = (purchaseDate: string, period?: string | null) => {
return `${year}-${month}`; return `${year}-${month}`;
}; };
const isValidDateInput = (value: string) =>
!Number.isNaN(parseLocalDateString(value).getTime());
const baseFields = z.object({ const baseFields = z.object({
purchaseDate: z purchaseDate: z
.string({ message: "Informe a data da transação." }) .string({ message: "Informe a data da transação." })
.trim() .trim()
.refine((value) => !Number.isNaN(new Date(value).getTime()), { .refine((value) => isValidDateInput(value), {
message: "Data da transação inválida.", message: "Data da transação inválida.",
}), }),
period: z period: z
@@ -164,14 +168,14 @@ const baseFields = z.object({
dueDate: z dueDate: z
.string() .string()
.trim() .trim()
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { .refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de vencimento válida.", message: "Informe uma data de vencimento válida.",
}) })
.optional(), .optional(),
boletoPaymentDate: z boletoPaymentDate: z
.string() .string()
.trim() .trim()
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { .refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de pagamento válida.", message: "Informe uma data de pagamento válida.",
}) })
.optional(), .optional(),
@@ -353,23 +357,6 @@ const splitAmount = (totalCents: number, parts: number) => {
); );
}; };
const addMonthsToPeriod = (period: string, offset: number) => {
const [yearStr, monthStr] = period.split("-");
const baseYear = Number(yearStr);
const baseMonth = Number(monthStr);
if (!baseYear || !baseMonth) {
throw new Error("Período inválido.");
}
const date = new Date(baseYear, baseMonth - 1, 1);
date.setMonth(date.getMonth() + offset);
const nextYear = date.getFullYear();
const nextMonth = String(date.getMonth() + 1).padStart(2, "0");
return `${nextYear}-${nextMonth}`;
};
const addMonthsToDate = (value: Date, offset: number) => { const addMonthsToDate = (value: Date, offset: number) => {
const result = new Date(value); const result = new Date(value);
const originalDay = result.getDate(); const originalDay = result.getDate();
@@ -648,7 +635,7 @@ export async function createLancamentoAction(
const boletoPaymentDate = shouldSetBoletoPaymentDate const boletoPaymentDate = shouldSetBoletoPaymentDate
? data.boletoPaymentDate ? data.boletoPaymentDate
? parseLocalDateString(data.boletoPaymentDate) ? parseLocalDateString(data.boletoPaymentDate)
: getTodayDate() : getBusinessTodayDate()
: null; : null;
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
@@ -828,7 +815,7 @@ export async function updateLancamentoAction(
const boletoPaymentDateValue = shouldSetBoletoPaymentDate const boletoPaymentDateValue = shouldSetBoletoPaymentDate
? data.boletoPaymentDate ? data.boletoPaymentDate
? parseLocalDateString(data.boletoPaymentDate) ? parseLocalDateString(data.boletoPaymentDate)
: getTodayDate() : getBusinessTodayDate()
: null; : null;
await db await db
@@ -982,7 +969,7 @@ export async function toggleLancamentoSettlementAction(
const isBoleto = existing.paymentMethod === "Boleto"; const isBoleto = existing.paymentMethod === "Boleto";
const boletoPaymentDate = isBoleto const boletoPaymentDate = isBoleto
? data.value ? data.value
? getTodayDate() ? getBusinessTodayDate()
: null : null
: null; : null;
@@ -1118,7 +1105,7 @@ const updateBulkSchema = z.object({
dueDate: z dueDate: z
.string() .string()
.trim() .trim()
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { .refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de vencimento válida.", message: "Informe uma data de vencimento válida.",
}) })
.optional() .optional()
@@ -1126,7 +1113,7 @@ const updateBulkSchema = z.object({
boletoPaymentDate: z boletoPaymentDate: z
.string() .string()
.trim() .trim()
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { .refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de pagamento válida.", message: "Informe uma data de pagamento válida.",
}) })
.optional() .optional()
@@ -1284,7 +1271,7 @@ export async function updateLancamentoBulkAction(
}); });
await applyUpdates( await applyUpdates(
futureLancamentos.map((item) => ({ futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
id: item.id, id: item.id,
purchaseDate: item.purchaseDate ?? null, purchaseDate: item.purchaseDate ?? null,
})), })),
@@ -1311,7 +1298,7 @@ export async function updateLancamentoBulkAction(
}); });
await applyUpdates( await applyUpdates(
allLancamentos.map((item) => ({ allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
id: item.id, id: item.id,
purchaseDate: item.purchaseDate ?? null, purchaseDate: item.purchaseDate ?? null,
})), })),
@@ -1335,7 +1322,7 @@ const massAddTransactionSchema = z.object({
purchaseDate: z purchaseDate: z
.string({ message: "Informe a data da transação." }) .string({ message: "Informe a data da transação." })
.trim() .trim()
.refine((value) => !Number.isNaN(new Date(value).getTime()), { .refine((value) => isValidDateInput(value), {
message: "Data da transação inválida.", message: "Data da transação inválida.",
}), }),
name: z name: z
@@ -1598,12 +1585,12 @@ export async function deleteMultipleLancamentosAction(
const notificationData = existing const notificationData = existing
.filter( .filter(
( (
item, item: (typeof existing)[number],
): item is typeof item & { ): item is typeof item & {
pagadorId: NonNullable<typeof item.pagadorId>; pagadorId: NonNullable<typeof item.pagadorId>;
} => Boolean(item.pagadorId), } => Boolean(item.pagadorId),
) )
.map((item) => ({ .map((item: (typeof existing)[number]) => ({
pagadorId: item.pagadorId, pagadorId: item.pagadorId,
name: item.name ?? null, name: item.name ?? null,
amount: item.amount ?? null, amount: item.amount ?? null,
@@ -1662,11 +1649,11 @@ export async function getRecentEstablishmentsAction(): Promise<string[]> {
// Remove duplicates and filter empty names // Remove duplicates and filter empty names
const uniqueNames = Array.from( const uniqueNames = Array.from(
new Set( new Set<string>(
results results
.map((r) => r.name) .map((r: (typeof results)[number]) => r.name)
.filter( .filter(
(name): name is string => (name: string | null): name is string =>
name != null && name != null &&
name.trim().length > 0 && name.trim().length > 0 &&
!name.toLowerCase().startsWith("pagamento fatura"), !name.toLowerCase().startsWith("pagamento fatura"),

View File

@@ -9,7 +9,7 @@ import {
pagadores, pagadores,
} from "@/db/schema"; } from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types"; import type { ActionResult } from "@/lib/types/actions";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { import {

View File

@@ -3,18 +3,16 @@
import { and, eq, ne } from "drizzle-orm"; import { and, eq, ne } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { categorias, orcamentos } from "@/db/schema"; import { categorias, orcamentos } from "@/db/schema";
import { import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { periodSchema, uuidSchema } from "@/lib/schemas/common"; import { periodSchema, uuidSchema } from "@/lib/schemas/common";
import type { ActionResult } from "@/lib/types/actions";
import { import {
formatDecimalForDbRequired, formatDecimalForDbRequired,
normalizeDecimalInput, normalizeDecimalInput,
} from "@/lib/utils/currency"; } from "@/lib/utils/currency";
import { getPreviousPeriod } from "@/lib/utils/period";
const budgetBaseSchema = z.object({ const budgetBaseSchema = z.object({
categoriaId: uuidSchema("Categoria"), categoriaId: uuidSchema("Categoria"),
@@ -43,9 +41,13 @@ const deleteBudgetSchema = z.object({
id: uuidSchema("Orçamento"), id: uuidSchema("Orçamento"),
}); });
type BudgetCreateInput = z.infer<typeof createBudgetSchema>; type BudgetCreateInput = z.input<typeof createBudgetSchema>;
type BudgetUpdateInput = z.infer<typeof updateBudgetSchema>; type BudgetUpdateInput = z.input<typeof updateBudgetSchema>;
type BudgetDeleteInput = z.infer<typeof deleteBudgetSchema>; type BudgetDeleteInput = z.input<typeof deleteBudgetSchema>;
type BudgetCopyRow = {
categoriaId: string | null;
amount: unknown;
};
const ensureCategory = async (userId: string, categoriaId: string) => { const ensureCategory = async (userId: string, categoriaId: string) => {
const category = await db.query.categorias.findFirst({ const category = await db.query.categorias.findFirst({
@@ -193,7 +195,7 @@ const duplicatePreviousMonthSchema = z.object({
period: periodSchema, period: periodSchema,
}); });
type DuplicatePreviousMonthInput = z.infer<typeof duplicatePreviousMonthSchema>; type DuplicatePreviousMonthInput = z.input<typeof duplicatePreviousMonthSchema>;
export async function duplicatePreviousMonthBudgetsAction( export async function duplicatePreviousMonthBudgetsAction(
input: DuplicatePreviousMonthInput, input: DuplicatePreviousMonthInput,
@@ -203,22 +205,15 @@ export async function duplicatePreviousMonthBudgetsAction(
const data = duplicatePreviousMonthSchema.parse(input); const data = duplicatePreviousMonthSchema.parse(input);
// Calcular mês anterior // Calcular mês anterior
const [year, month] = data.period.split("-").map(Number); const previousPeriod = getPreviousPeriod(data.period);
const currentDate = new Date(year, month - 1, 1);
const previousDate = new Date(currentDate);
previousDate.setMonth(previousDate.getMonth() - 1);
const prevYear = previousDate.getFullYear();
const prevMonth = String(previousDate.getMonth() + 1).padStart(2, "0");
const previousPeriod = `${prevYear}-${prevMonth}`;
// Buscar orçamentos do mês anterior // Buscar orçamentos do mês anterior
const previousBudgets = await db.query.orcamentos.findMany({ const previousBudgets = (await db.query.orcamentos.findMany({
where: and( where: and(
eq(orcamentos.userId, user.id), eq(orcamentos.userId, user.id),
eq(orcamentos.period, previousPeriod), eq(orcamentos.period, previousPeriod),
), ),
}); })) as BudgetCopyRow[];
if (previousBudgets.length === 0) { if (previousBudgets.length === 0) {
return { return {
@@ -228,12 +223,12 @@ export async function duplicatePreviousMonthBudgetsAction(
} }
// Buscar orçamentos existentes do mês atual // Buscar orçamentos existentes do mês atual
const currentBudgets = await db.query.orcamentos.findMany({ const currentBudgets = (await db.query.orcamentos.findMany({
where: and( where: and(
eq(orcamentos.userId, user.id), eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period), eq(orcamentos.period, data.period),
), ),
}); })) as BudgetCopyRow[];
// Filtrar para evitar duplicatas // Filtrar para evitar duplicatas
const existingCategoryIds = new Set( const existingCategoryIds = new Set(

View File

@@ -14,6 +14,8 @@ import {
fetchPagadorHistory, fetchPagadorHistory,
fetchPagadorMonthlyBreakdown, fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details"; } from "@/lib/pagadores/details";
import { formatCurrency } from "@/lib/utils/currency";
import { formatDateTime } from "@/lib/utils/date";
import { displayPeriod } from "@/lib/utils/period"; import { displayPeriod } from "@/lib/utils/period";
const inputSchema = z.object({ const inputSchema = z.object({
@@ -27,20 +29,14 @@ type ActionResult =
| { success: true; message: string } | { success: true; message: string }
| { success: false; error: string }; | { success: false; error: string };
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
const formatDate = (value: Date | null | undefined) => { const formatDate = (value: Date | null | undefined) => {
if (!value) return "—"; return (
return value.toLocaleDateString("pt-BR", { formatDateTime(value, {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
year: "numeric", year: "numeric",
}); }) ?? "—"
);
}; };
// Escapa HTML para prevenir XSS // Escapa HTML para prevenir XSS
@@ -515,25 +511,47 @@ export async function sendPagadorSummaryAction(
.orderBy(desc(lancamentos.purchaseDate)), .orderBy(desc(lancamentos.purchaseDate)),
]); ]);
const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({ const normalizedBoletos: BoletoItem[] = (
boletoRows as Array<{
name: string | null;
amount: unknown;
dueDate: Date | null;
}>
).map((row) => ({
name: row.name ?? "Sem descrição", name: row.name ?? "Sem descrição",
amount: Math.abs(Number(row.amount ?? 0)), amount: Math.abs(Number(row.amount ?? 0)),
dueDate: row.dueDate, dueDate: row.dueDate,
})); }));
const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map( const normalizedLancamentos: LancamentoRow[] = (
(row) => ({ lancamentoRows as Array<{
id: row.id, id: string;
name: row.name, name: string | null;
paymentMethod: row.paymentMethod, paymentMethod: string | null;
condition: row.condition, condition: string | null;
transactionType: row.transactionType, transactionType: string | null;
purchaseDate: row.purchaseDate, purchaseDate: Date | null;
amount: Number(row.amount ?? 0), amount: unknown;
}), }>
); ).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 normalizedParcelados: ParceladoItem[] = (
parceladoRows as Array<{
name: string | null;
amount: unknown;
installmentCount: number | null;
currentInstallment: number | null;
purchaseDate: Date | null;
}>
).map((row) => {
const installmentAmount = Math.abs(Number(row.amount ?? 0)); const installmentAmount = Math.abs(Number(row.amount ?? 0));
const installmentCount = row.installmentCount ?? 1; const installmentCount = row.installmentCount ?? 1;
const totalAmount = installmentAmount * installmentCount; const totalAmount = installmentAmount * installmentCount;

View File

@@ -1,56 +1,47 @@
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
/** /**
* Loading state para a página de detalhes do pagador * Loading state para a página de detalhes do pagador.
* Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos) * Layout: navegação mensal + tabs com card compartilhado do pagador.
*/ */
export default function PagadorDetailsLoading() { export default function PagadorDetailsLoading() {
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" /> <div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Info do Pagador (sempre visível) */}
<div className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start gap-4">
{/* Avatar */}
<Skeleton className="size-20 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-3">
{/* Nome + Badge */}
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
</div>
{/* Email */}
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
{/* Status */}
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Botões de ação */}
<div className="flex gap-2">
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
{/* Tabs */}
<div className="space-y-6 pt-4"> <div className="space-y-6 pt-4">
<div className="flex gap-2 border-b"> <div className="flex gap-2 border-b">
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" /> <Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" /> <Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
<Skeleton className="h-10 w-36 rounded-t-2xl bg-foreground/10" />
</div>
<div className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start gap-4">
<Skeleton className="size-20 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
</div> </div>
{/* Conteúdo da aba Visão Geral (grid de cards) */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Card de resumo mensal */}
<div className="rounded-2xl border p-6 space-y-4 lg:col-span-2"> <div className="rounded-2xl border p-6 space-y-4 lg:col-span-2">
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" /> <Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
<div className="grid grid-cols-3 gap-4 pt-4"> <div className="grid grid-cols-3 gap-4 pt-4">
@@ -63,7 +54,6 @@ export default function PagadorDetailsLoading() {
</div> </div>
</div> </div>
{/* Outros cards */}
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4"> <div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -6,6 +6,7 @@ import {
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data"; import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { ExpandableWidgetCard } from "@/components/shared/expandable-widget-card";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page"; import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import type { import type {
ContaCartaoFilterOption, ContaCartaoFilterOption,
@@ -15,6 +16,7 @@ import type {
} from "@/components/lancamentos/types"; } from "@/components/lancamentos/types";
import MonthNavigation from "@/components/month-picker/month-navigation"; import MonthNavigation from "@/components/month-picker/month-navigation";
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card"; import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
import { PagadorHeaderCard } from "@/components/pagadores/details/pagador-header-card";
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card"; import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card"; import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card"; import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
@@ -25,7 +27,6 @@ import {
} from "@/components/pagadores/details/pagador-payment-method-cards"; } from "@/components/pagadores/details/pagador-payment-method-cards";
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card"; import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import WidgetCard from "@/components/widget-card";
import type { pagadores } from "@/db/schema"; import type { pagadores } from "@/db/schema";
import { getUserId } from "@/lib/auth/server"; import { getUserId } from "@/lib/auth/server";
import { import {
@@ -50,6 +51,7 @@ import {
fetchPagadorHistory, fetchPagadorHistory,
fetchPagadorMonthlyBreakdown, fetchPagadorMonthlyBreakdown,
fetchPagadorPaymentStatus, fetchPagadorPaymentStatus,
type PagadorCardUsageItem,
} from "@/lib/pagadores/details"; } from "@/lib/pagadores/details";
import { parsePeriodParam } from "@/lib/utils/period"; import { parsePeriodParam } from "@/lib/utils/period";
import { import {
@@ -232,6 +234,7 @@ export default async function Page({ params, searchParams }: PageProps) {
label: pagador.name, label: pagador.name,
slug: pagador.id, slug: pagador.id,
role: pagador.role, role: pagador.role,
avatarUrl: pagador.avatarUrl,
}, },
], ],
categoriaFiltersRaw: [], categoriaFiltersRaw: [],
@@ -284,7 +287,7 @@ export default async function Page({ params, searchParams }: PageProps) {
periodLabel, periodLabel,
totalExpenses: monthlyBreakdown.totalExpenses, totalExpenses: monthlyBreakdown.totalExpenses,
paymentSplits: monthlyBreakdown.paymentSplits, paymentSplits: monthlyBreakdown.paymentSplits,
cardUsage: cardUsage.slice(0, 3).map((item) => ({ cardUsage: cardUsage.slice(0, 3).map((item: PagadorCardUsageItem) => ({
name: item.name, name: item.name,
amount: item.amount, amount: item.amount,
})), })),
@@ -308,15 +311,14 @@ export default async function Page({ params, searchParams }: PageProps) {
<TabsTrigger value="painel">Painel</TabsTrigger> <TabsTrigger value="painel">Painel</TabsTrigger>
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger> <TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
</TabsList> </TabsList>
<PagadorHeaderCard
pagador={pagadorData}
selectedPeriod={selectedPeriod}
summary={summaryPreview}
/>
<TabsContent value="profile" className="space-y-4"> <TabsContent value="profile" className="space-y-4">
<section> <PagadorInfoCard pagador={pagadorData} />
<PagadorInfoCard
pagador={pagadorData}
selectedPeriod={selectedPeriod}
summary={summaryPreview}
/>
</section>
{canEdit && pagadorData.shareCode ? ( {canEdit && pagadorData.shareCode ? (
<PagadorSharingCard <PagadorSharingCard
pagadorId={pagador.id} pagadorId={pagador.id}
@@ -343,27 +345,27 @@ export default async function Page({ params, searchParams }: PageProps) {
</section> </section>
<section className="grid gap-3 lg:grid-cols-3"> <section className="grid gap-3 lg:grid-cols-3">
<WidgetCard <ExpandableWidgetCard
title="Minhas Faturas" title="Minhas Faturas"
subtitle="Valores por cartão neste período" subtitle="Valores por cartão neste período"
icon={<RiBankCard2Line className="size-4" />} icon={<RiBankCard2Line className="size-4" />}
> >
<PagadorCardUsageCard items={cardUsage} /> <PagadorCardUsageCard items={cardUsage} />
</WidgetCard> </ExpandableWidgetCard>
<WidgetCard <ExpandableWidgetCard
title="Boletos" title="Boletos"
subtitle="Boletos registrados neste período" subtitle="Boletos registrados neste período"
icon={<RiBarcodeLine className="size-4" />} icon={<RiBarcodeLine className="size-4" />}
> >
<PagadorBoletoCard items={boletoItems} /> <PagadorBoletoCard items={boletoItems} />
</WidgetCard> </ExpandableWidgetCard>
<WidgetCard <ExpandableWidgetCard
title="Status de Pagamento" title="Status de Pagamento"
subtitle="Situação das despesas no período" subtitle="Situação das despesas no período"
icon={<RiWallet3Line className="size-4" />} icon={<RiWallet3Line className="size-4" />}
> >
<PagadorPaymentStatusCard data={paymentStatus} /> <PagadorPaymentStatusCard data={paymentStatus} />
</WidgetCard> </ExpandableWidgetCard>
</section> </section>
</TabsContent> </TabsContent>

View File

@@ -6,7 +6,7 @@ import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { compartilhamentosPagador, pagadores, user } from "@/db/schema"; import { compartilhamentosPagador, pagadores, user } from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types"; import type { ActionResult } from "@/lib/types/actions";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { import {

View File

@@ -4,7 +4,7 @@ import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { preLancamentos } from "@/db/schema"; import { preLancamentos } from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types"; import type { ActionResult } from "@/lib/types/actions";
import { getUser } from "@/lib/auth/server"; import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";

View File

@@ -20,7 +20,7 @@ import {
DAYS_IN_MONTH, DAYS_IN_MONTH,
DEFAULT_CARD_BRANDS, DEFAULT_CARD_BRANDS,
DEFAULT_CARD_STATUS, DEFAULT_CARD_STATUS,
} from "./constants"; } from "@/lib/cartoes/constants";
import type { CardFormValues } from "./types"; import type { CardFormValues } from "./types";
interface AccountOption { interface AccountOption {

View File

@@ -7,7 +7,7 @@ import {
RiPencilLine, RiPencilLine,
} from "@remixicon/react"; } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import { useMemo } from "react"; import MoneyValues from "@/components/shared/money-values";
import { import {
Card, Card,
CardContent, CardContent,
@@ -20,8 +20,9 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { resolveCardBrandAsset } from "@/lib/cartoes/brand-assets";
import { resolveLogoSrc } from "@/lib/logo";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import MoneyValues from "../money-values";
interface CardItemProps { interface CardItemProps {
name: string; name: string;
@@ -40,26 +41,6 @@ interface CardItemProps {
onRemove?: () => void; onRemove?: () => void;
} }
const BRAND_ASSETS: Record<string, string> = {
visa: "/bandeiras/visa.svg",
mastercard: "/bandeiras/mastercard.svg",
amex: "/bandeiras/amex.svg",
american: "/bandeiras/amex.svg",
elo: "/bandeiras/elo.svg",
hipercard: "/bandeiras/hipercard.svg",
hiper: "/bandeiras/hipercard.svg",
};
const resolveBrandAsset = (brand: string) => {
const normalized = brand.trim().toLowerCase();
const match = (
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
).find((entry) => normalized.includes(entry));
return match ? BRAND_ASSETS[match] : null;
};
const formatDay = (value: string) => value.padStart(2, "0"); const formatDay = (value: string) => value.padStart(2, "0");
export function CardItem({ export function CardItem({
@@ -83,7 +64,7 @@ export function CardItem({
const limitTotal = limit ?? null; const limitTotal = limit ?? null;
const used = const used =
limitInUse ?? limitInUse ??
(limitTotal !== null && limitAvailable !== null (limitTotal !== null && limitAvailable != null
? Math.max(limitTotal - limitAvailable, 0) ? Math.max(limitTotal - limitAvailable, 0)
: limitTotal !== null : limitTotal !== null
? 0 ? 0
@@ -100,62 +81,38 @@ export function CardItem({
? Math.min(Math.max((used / limitTotal) * 100, 0), 100) ? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
: 0; : 0;
const logoPath = useMemo(() => { const logoPath = resolveLogoSrc(logo);
if (!logo) { const brandAsset = resolveCardBrandAsset(brand);
return null; const isInactive = status?.toLowerCase() === "inativo";
} const metrics =
limitTotal === null || used === null || available === null
? null
: [
{ label: "Limite Total", value: limitTotal },
{ label: "Em uso", value: used },
{ label: "Disponível", value: available },
];
if ( const actions = [
logo.startsWith("http://") || {
logo.startsWith("https://") || label: "editar",
logo.startsWith("data:") icon: <RiPencilLine className="size-4" aria-hidden />,
) { onClick: onEdit,
return logo; className: "text-primary",
} },
{
return logo.startsWith("/") ? logo : `/logos/${logo}`; label: "ver fatura",
}, [logo]); icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onInvoice,
const brandAsset = useMemo(() => resolveBrandAsset(brand), [brand]); className: "text-primary",
},
const isInactive = useMemo( {
() => status?.toLowerCase() === "inativo", label: "remover",
[status], icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
); onClick: onRemove,
className: "text-destructive",
const metrics = useMemo(() => { },
if (limitTotal === null) return null; ];
return [
{ label: "Limite Total", value: limitTotal },
{ label: "Em uso", value: used },
{ label: "Disponível", value: available },
];
}, [available, limitTotal, used]);
const actions = useMemo(
() => [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
className: "text-primary",
},
{
label: "ver fatura",
icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onInvoice,
className: "text-primary",
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
className: "text-destructive",
},
],
[onEdit, onInvoice, onRemove],
);
return ( return (
<Card className="flex flex-col p-6 w-full"> <Card className="flex flex-col p-6 w-full">

View File

@@ -2,35 +2,17 @@
import { RiBankLine } from "@remixicon/react"; import { RiBankLine } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import DotIcon from "@/components/dot-icon"; import StatusDot from "@/components/shared/status-dot";
import { resolveCardBrandLogoSrc } from "@/lib/cartoes/brand-assets";
import { resolveLogoSrc } from "@/lib/logo";
type SelectItemContentProps = { type SelectItemContentProps = {
label: string; label: string;
logo?: string | null; logo?: string | null;
}; };
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return null;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
};
const getBrandLogo = (brand: string): string | null => {
const brandMap: Record<string, string> = {
Visa: "visa.png",
Mastercard: "mastercard.png",
Elo: "elo.png",
};
return brandMap[brand] ?? null;
};
export function BrandSelectContent({ label }: { label: string }) { export function BrandSelectContent({ label }: { label: string }) {
const brandLogo = getBrandLogo(label); const logoSrc = resolveCardBrandLogoSrc(label);
const logoSrc = brandLogo ? `/logos/${brandLogo}` : null;
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@@ -55,7 +37,7 @@ export function StatusSelectContent({ label }: { label: string }) {
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<DotIcon <StatusDot
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"} color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
/> />
<span>{label}</span> <span>{label}</span>

View File

@@ -2,25 +2,27 @@
import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react"; import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteCardAction } from "@/app/(dashboard)/cartoes/actions"; import { deleteCardAction } from "@/app/(dashboard)/cartoes/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { EmptyState } from "@/components/shared/empty-state"; import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card as UiCard } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CardDialog } from "./card-dialog"; import { CardDialog } from "./card-dialog";
import { CardItem } from "./card-item"; import { CardItem } from "./card-item";
import type { Card as CreditCard } from "./types";
type AccountOption = { type AccountOption = {
id: string; id: string;
name: string; name: string;
logo: string | null;
}; };
interface CardsPageProps { interface CardsPageProps {
cards: Card[]; cards: CreditCard[];
archivedCards: Card[]; archivedCards: CreditCard[];
accounts: AccountOption[]; accounts: AccountOption[];
logoOptions: string[]; logoOptions: string[];
} }
@@ -34,56 +36,54 @@ export function CardsPage({
const router = useRouter(); const router = useRouter();
const [activeTab, setActiveTab] = useState("ativos"); const [activeTab, setActiveTab] = useState("ativos");
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [selectedCard, setSelectedCard] = useState<Card | null>(null); const [selectedCard, setSelectedCard] = useState<CreditCard | null>(null);
const [removeOpen, setRemoveOpen] = useState(false); const [removeOpen, setRemoveOpen] = useState(false);
const [cardToRemove, setCardToRemove] = useState<Card | null>(null); const [cardToRemove, setCardToRemove] = useState<CreditCard | null>(null);
const sortCards = useCallback( const orderedCards = useMemo(
(list: Card[]) => () =>
[...list].sort((a, b) => [...cards].sort((a, b) =>
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }), a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
), ),
[], [cards],
); );
const orderedCards = useMemo(() => sortCards(cards), [cards, sortCards]);
const orderedArchivedCards = useMemo( const orderedArchivedCards = useMemo(
() => sortCards(archivedCards), () =>
[archivedCards, sortCards], [...archivedCards].sort((a, b) =>
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
),
[archivedCards],
); );
const handleEdit = useCallback((card: Card) => { const handleEdit = (card: CreditCard) => {
setSelectedCard(card); setSelectedCard(card);
setEditOpen(true); setEditOpen(true);
}, []); };
const handleEditOpenChange = useCallback((open: boolean) => { const handleEditOpenChange = (open: boolean) => {
setEditOpen(open); setEditOpen(open);
if (!open) { if (!open) {
setSelectedCard(null); setSelectedCard(null);
} }
}, []); };
const handleRemoveRequest = useCallback((card: Card) => { const handleRemoveRequest = (card: CreditCard) => {
setCardToRemove(card); setCardToRemove(card);
setRemoveOpen(true); setRemoveOpen(true);
}, []); };
const handleInvoice = useCallback( const handleInvoice = (card: CreditCard) => {
(card: Card) => { router.push(`/cartoes/${card.id}/fatura`);
router.push(`/cartoes/${card.id}/fatura`); };
},
[router],
);
const handleRemoveOpenChange = useCallback((open: boolean) => { const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open); setRemoveOpen(open);
if (!open) { if (!open) {
setCardToRemove(null); setCardToRemove(null);
} }
}, []); };
const handleRemoveConfirm = useCallback(async () => { const handleRemoveConfirm = async () => {
if (!cardToRemove) { if (!cardToRemove) {
return; return;
} }
@@ -97,16 +97,16 @@ export function CardsPage({
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
}, [cardToRemove]); };
const removeTitle = cardToRemove const removeTitle = cardToRemove
? `Remover cartão "${cardToRemove.name}"?` ? `Remover cartão "${cardToRemove.name}"?`
: "Remover cartão?"; : "Remover cartão?";
const renderCardList = (list: Card[], isArchived: boolean) => { const renderCardList = (list: CreditCard[], isArchived: boolean) => {
if (list.length === 0) { if (list.length === 0) {
return ( return (
<Card className="flex w-full items-center justify-center py-12"> <UiCard className="flex w-full items-center justify-center py-12">
<EmptyState <EmptyState
media={<RiBankCard2Line className="size-6 text-primary" />} media={<RiBankCard2Line className="size-6 text-primary" />}
title={ title={
@@ -120,7 +120,7 @@ export function CardsPage({
: "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle." : "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
} }
/> />
</Card> </UiCard>
); );
} }

View File

@@ -1,7 +0,0 @@
export const DEFAULT_CARD_BRANDS = ["Visa", "Mastercard", "Elo"] as const;
export const DEFAULT_CARD_STATUS = ["Ativo", "Inativo"] as const;
export const DAYS_IN_MONTH = Array.from({ length: 31 }, (_, index) =>
String(index + 1).padStart(2, "0"),
);

View File

@@ -7,10 +7,10 @@ import {
RiPencilLine, RiPencilLine,
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import { useCallback, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteCategoryAction } from "@/app/(dashboard)/categorias/actions"; import { deleteCategoryAction } from "@/app/(dashboard)/categorias/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
@@ -69,31 +69,31 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
return base; return base;
}, [categories]); }, [categories]);
const handleEdit = useCallback((category: Category) => { const handleEdit = (category: Category) => {
setSelectedCategory(category); setSelectedCategory(category);
setEditOpen(true); setEditOpen(true);
}, []); };
const handleEditOpenChange = useCallback((open: boolean) => { const handleEditOpenChange = (open: boolean) => {
setEditOpen(open); setEditOpen(open);
if (!open) { if (!open) {
setSelectedCategory(null); setSelectedCategory(null);
} }
}, []); };
const handleRemoveRequest = useCallback((category: Category) => { const handleRemoveRequest = (category: Category) => {
setCategoryToRemove(category); setCategoryToRemove(category);
setRemoveOpen(true); setRemoveOpen(true);
}, []); };
const handleRemoveOpenChange = useCallback((open: boolean) => { const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open); setRemoveOpen(open);
if (!open) { if (!open) {
setCategoryToRemove(null); setCategoryToRemove(null);
} }
}, []); };
const handleRemoveConfirm = useCallback(async () => { const handleRemoveConfirm = async () => {
if (!categoryToRemove) { if (!categoryToRemove) {
return; return;
} }
@@ -107,7 +107,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
}, [categoryToRemove]); };
const removeTitle = categoryToRemove const removeTitle = categoryToRemove
? `Remover categoria "${categoryToRemove.name}"?` ? `Remover categoria "${categoryToRemove.name}"?`

View File

@@ -1,8 +1,9 @@
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react"; import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react";
import { TypeBadge } from "@/components/shared/type-badge";
import type { CategoryType } from "@/lib/categorias/constants"; import type { CategoryType } from "@/lib/categorias/constants";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers"; import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { formatPercentage } from "@/lib/utils/percentage";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { TypeBadge } from "../type-badge";
import { Card } from "../ui/card"; import { Card } from "../ui/card";
import { CategoryIconBadge } from "./category-icon-badge"; import { CategoryIconBadge } from "./category-icon-badge";
@@ -61,9 +62,12 @@ export function CategoryDetailHeader({
const variationLabel = const variationLabel =
typeof percentageChange === "number" typeof percentageChange === "number"
? `${percentageChange > 0 ? "+" : ""}${Math.abs(percentageChange).toFixed( ? formatPercentage(percentageChange, {
1, minimumFractionDigits: 1,
)}%` maximumFractionDigits: 1,
absolute: true,
signDisplay: percentageChange === 0 ? "auto" : "always",
})
: "—"; : "—";
return ( return (

View File

@@ -1,13 +1,13 @@
"use client"; "use client";
import DotIcon from "@/components/dot-icon"; import StatusDot from "@/components/shared/status-dot";
export function TypeSelectContent({ label }: { label: string }) { export function TypeSelectContent({ label }: { label: string }) {
const isReceita = label === "Receita"; const isReceita = label === "Receita";
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<DotIcon color={isReceita ? "bg-success" : "bg-destructive"} /> <StatusDot color={isReceita ? "bg-success" : "bg-destructive"} />
<span>{label}</span> <span>{label}</span>
</span> </span>
); );

View File

@@ -1,3 +1,5 @@
import type { CategoryType } from "@/lib/categorias/constants";
export type { CategoryType } from "@/lib/categorias/constants"; export type { CategoryType } from "@/lib/categorias/constants";
export { export {
CATEGORY_TYPE_LABEL, CATEGORY_TYPE_LABEL,

View File

@@ -8,7 +8,7 @@ import {
} from "@remixicon/react"; } from "@remixicon/react";
import type React from "react"; import type React from "react";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import MoneyValues from "../money-values"; import MoneyValues from "@/components/shared/money-values";
import { Card, CardContent, CardFooter } from "../ui/card"; import { Card, CardContent, CardFooter } from "../ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";

View File

@@ -1,13 +1,13 @@
"use client"; "use client";
import DotIcon from "@/components/dot-icon"; import StatusDot from "@/components/shared/status-dot";
export function StatusSelectContent({ label }: { label: string }) { export function StatusSelectContent({ label }: { label: string }) {
const isActive = label === "Ativa"; const isActive = label === "Ativa";
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<DotIcon <StatusDot
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"} color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
/> />
<span>{label}</span> <span>{label}</span>

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { RiInformationLine } from "@remixicon/react"; import { RiInformationLine } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import { type ReactNode, useMemo } from "react"; import type { ReactNode } from "react";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
@@ -10,6 +10,8 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { resolveLogoSrc } from "@/lib/logo";
import { formatCurrency } from "@/lib/utils/currency";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
type DetailValue = string | number | ReactNode; type DetailValue = string | number | ReactNode;
@@ -27,22 +29,9 @@ type AccountStatementCardProps = {
actions?: React.ReactNode; actions?: React.ReactNode;
}; };
const resolveLogoPath = (logo?: string | null) => {
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
const getAccountStatusBadgeVariant = ( const getAccountStatusBadgeVariant = (
status: string, status: string,
): "success" | "secondary" => { ): "success" | "outline" => {
const normalizedStatus = status.toLowerCase(); const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativa") { if (normalizedStatus === "ativa") {
return "success"; return "success";
@@ -62,13 +51,7 @@ export function AccountStatementCard({
logo, logo,
actions, actions,
}: AccountStatementCardProps) { }: AccountStatementCardProps) {
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]); const logoPath = resolveLogoSrc(logo);
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
});
return ( return (
<Card className="border"> <Card className="border">

View File

@@ -6,11 +6,12 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteAccountAction } from "@/app/(dashboard)/contas/actions"; import { deleteAccountAction } from "@/app/(dashboard)/contas/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { AccountCard } from "@/components/contas/account-card"; import { AccountCard } from "@/components/contas/account-card";
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { EmptyState } from "@/components/shared/empty-state"; import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { resolveLogoSrc } from "@/lib/logo";
import { getCurrentPeriod } from "@/lib/utils/period"; import { getCurrentPeriod } from "@/lib/utils/period";
import { Card } from "../ui/card"; import { Card } from "../ui/card";
import { AccountDialog } from "./account-dialog"; import { AccountDialog } from "./account-dialog";
@@ -23,15 +24,6 @@ interface AccountsPageProps {
logoOptions: string[]; logoOptions: string[];
} }
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return undefined;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
};
export function AccountsPage({ export function AccountsPage({
accounts, accounts,
archivedAccounts, archivedAccounts,
@@ -135,7 +127,7 @@ export function AccountsPage({
return ( return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{list.map((account) => { {list.map((account) => {
const logoSrc = resolveLogoSrc(account.logo); const logoSrc = resolveLogoSrc(account.logo) ?? undefined;
return ( return (
<AccountCard <AccountCard
@@ -229,6 +221,8 @@ export function AccountsPage({
...a, ...a,
balance: a.balance ?? a.initialBalance ?? 0, balance: a.balance ?? a.initialBalance ?? 0,
excludeFromBalance: a.excludeFromBalance ?? false, excludeFromBalance: a.excludeFromBalance ?? false,
excludeInitialBalanceFromIncome:
a.excludeInitialBalanceFromIncome ?? false,
}))} }))}
fromAccountId={transferFromAccount.id} fromAccountId={transferFromAccount.id}
currentPeriod={getCurrentPeriod()} currentPeriod={getCurrentPeriod()}

View File

@@ -3,17 +3,18 @@
import { RiEditLine } from "@remixicon/react"; import { RiEditLine } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
updateInvoicePaymentStatusAction, updateInvoicePaymentStatusAction,
updatePaymentDateAction, updatePaymentDateAction,
} from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions"; } from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
import DotIcon from "@/components/dot-icon"; import MoneyValues from "@/components/shared/money-values";
import MoneyValues from "@/components/money-values"; import StatusDot from "@/components/shared/status-dot";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { resolveCardBrandAsset } from "@/lib/cartoes/brand-assets";
import { import {
INVOICE_PAYMENT_STATUS, INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_BADGE_VARIANT, INVOICE_STATUS_BADGE_VARIANT,
@@ -21,6 +22,8 @@ import {
INVOICE_STATUS_LABEL, INVOICE_STATUS_LABEL,
type InvoicePaymentStatus, type InvoicePaymentStatus,
} from "@/lib/faturas"; } from "@/lib/faturas";
import { resolveLogoSrc } from "@/lib/logo";
import { formatCurrency } from "@/lib/utils/currency";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { EditPaymentDateDialog } from "./edit-payment-date-dialog"; import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
@@ -41,26 +44,6 @@ type InvoiceSummaryCardProps = {
actions?: React.ReactNode; actions?: React.ReactNode;
}; };
const BRAND_ASSETS: Record<string, string> = {
visa: "/bandeiras/visa.svg",
mastercard: "/bandeiras/mastercard.svg",
amex: "/bandeiras/amex.svg",
american: "/bandeiras/amex.svg",
elo: "/bandeiras/elo.svg",
hipercard: "/bandeiras/hipercard.svg",
hiper: "/bandeiras/hipercard.svg",
};
const resolveBrandAsset = (brand: string) => {
const normalized = brand.trim().toLowerCase();
const match = (
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
).find((entry) => normalized.includes(entry));
return match ? BRAND_ASSETS[match] : null;
};
const actionLabelByStatus: Record<InvoicePaymentStatus, string> = { const actionLabelByStatus: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "Marcar como paga", [INVOICE_PAYMENT_STATUS.PENDING]: "Marcar como paga",
[INVOICE_PAYMENT_STATUS.PAID]: "Desfazer pagamento", [INVOICE_PAYMENT_STATUS.PAID]: "Desfazer pagamento",
@@ -76,19 +59,6 @@ const actionVariantByStatus: Record<
const formatDay = (value: string) => value.padStart(2, "0"); const formatDay = (value: string) => value.padStart(2, "0");
const resolveLogoPath = (logo?: string | null) => {
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
const getCardStatusDotColor = (status: string | null) => { const getCardStatusDotColor = (status: string | null) => {
if (!status) return "bg-gray-400"; if (!status) return "bg-gray-400";
const normalizedStatus = status.toLowerCase(); const normalizedStatus = status.toLowerCase();
@@ -122,26 +92,13 @@ export function InvoiceSummaryCard({
// Atualizar estado quando initialPaymentDate mudar // Atualizar estado quando initialPaymentDate mudar
useEffect(() => { useEffect(() => {
if (initialPaymentDate) { setPaymentDate(initialPaymentDate ?? new Date());
setPaymentDate(initialPaymentDate);
}
}, [initialPaymentDate]); }, [initialPaymentDate]);
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]); const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(cardBrand);
const brandAsset = useMemo( const limitLabel =
() => (cardBrand ? resolveBrandAsset(cardBrand) : null), typeof limitAmount === "number" ? formatCurrency(limitAmount) : "—";
[cardBrand],
);
const limitLabel = useMemo(() => {
if (typeof limitAmount !== "number") return "—";
return limitAmount.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
}, [limitAmount]);
const targetStatus = const targetStatus =
invoiceStatus === INVOICE_PAYMENT_STATUS.PAID invoiceStatus === INVOICE_PAYMENT_STATUS.PAID
@@ -286,7 +243,7 @@ export function InvoiceSummaryCard({
value={ value={
cardStatus ? ( cardStatus ? (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<DotIcon color={getCardStatusDotColor(cardStatus)} /> <StatusDot color={getCardStatusDotColor(cardStatus)} />
<span className="truncate">{cardStatus}</span> <span className="truncate">{cardStatus}</span>
</div> </div>
) : ( ) : (

View File

@@ -2,7 +2,7 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { import {
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { EligibleInstallment } from "@/lib/installments/anticipation-types"; import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
import { formatCurrentInstallment } from "@/lib/installments/utils"; import { formatCurrentInstallment } from "@/lib/installments/utils";
import { formatShortPeriodLabel } from "@/lib/utils/period";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
interface InstallmentSelectionTableProps { interface InstallmentSelectionTableProps {
@@ -43,12 +44,6 @@ export function InstallmentSelectionTable({
} }
}; };
const formatPeriod = (period: string) => {
const [year, month] = period.split("-");
const date = new Date(Number(year), Number(month) - 1);
return format(date, "MMM/yyyy", { locale: ptBR });
};
const formatDate = (date: Date | null) => { const formatDate = (date: Date | null) => {
if (!date) return "—"; if (!date) return "—";
return format(date, "dd/MM/yyyy", { locale: ptBR }); return format(date, "dd/MM/yyyy", { locale: ptBR });
@@ -116,7 +111,7 @@ export function InstallmentSelectionTable({
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="font-medium"> <TableCell className="font-medium">
{formatPeriod(inst.period)} {formatShortPeriodLabel(inst.period)}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{formatDate(inst.dueDate)} {formatDate(inst.dueDate)}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { createLancamentoAction } from "@/app/(dashboard)/lancamentos/actions"; import { createLancamentoAction } from "@/app/(dashboard)/lancamentos/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -58,20 +58,18 @@ export function BulkImportDialog({
const [contaId, setContaId] = useState<string | undefined>(undefined); const [contaId, setContaId] = useState<string | undefined>(undefined);
const [cartaoId, setCartaoId] = useState<string | undefined>(undefined); const [cartaoId, setCartaoId] = useState<string | undefined>(undefined);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
type CreateLancamentoInput = Parameters<typeof createLancamentoAction>[0];
// Reset form when dialog opens/closes // Reset form when dialog opens/closes
const handleOpenChange = useCallback( const handleOpenChange = (newOpen: boolean) => {
(newOpen: boolean) => { if (!newOpen) {
if (!newOpen) { setPagadorId(defaultPagadorId ?? undefined);
setPagadorId(defaultPagadorId ?? undefined); setCategoriaId(undefined);
setCategoriaId(undefined); setContaId(undefined);
setContaId(undefined); setCartaoId(undefined);
setCartaoId(undefined); }
} onOpenChange(newOpen);
onOpenChange(newOpen); };
},
[onOpenChange, defaultPagadorId],
);
const categoriaGroups = useMemo(() => { const categoriaGroups = useMemo(() => {
// Get unique transaction types from items // Get unique transaction types from items
@@ -88,111 +86,100 @@ export function BulkImportDialog({
return groupAndSortCategorias(filtered); return groupAndSortCategorias(filtered);
}, [categoriaOptions, items]); }, [categoriaOptions, items]);
const handleSubmit = useCallback( const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
async (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault();
event.preventDefault();
if (!pagadorId) { if (!pagadorId) {
toast.error("Selecione o pagador."); toast.error("Selecione o pagador.");
return; return;
} }
if (!categoriaId) { if (!categoriaId) {
toast.error("Selecione a categoria."); toast.error("Selecione a categoria.");
return; return;
} }
startTransition(async () => { startTransition(async () => {
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
for (const item of items) { for (const item of items) {
const sanitizedAmount = Math.abs(item.amount); const sanitizedAmount = Math.abs(item.amount);
// Determine payment method based on original item // Determine payment method based on original item
const isCredit = item.paymentMethod === "Cartão de crédito"; const isCredit = item.paymentMethod === "Cartão de crédito";
// Validate payment method fields // Validate payment method fields
if (isCredit && !cartaoId) { if (isCredit && !cartaoId) {
toast.error("Selecione um cartão de crédito."); toast.error("Selecione um cartão de crédito.");
return; return;
}
if (!isCredit && !contaId) {
toast.error("Selecione uma conta.");
return;
}
const payload = {
purchaseDate: item.purchaseDate,
period: item.period,
name: item.name,
transactionType: item.transactionType as
| "Despesa"
| "Receita"
| "Transferência",
amount: sanitizedAmount,
condition: item.condition as "À vista" | "Parcelado" | "Recorrente",
paymentMethod: item.paymentMethod as
| "Cartão de crédito"
| "Cartão de débito"
| "Pix"
| "Dinheiro"
| "Boleto"
| "Pré-Pago | VR/VA"
| "Transferência bancária",
pagadorId,
secondaryPagadorId: undefined,
isSplit: false,
contaId: isCredit ? undefined : contaId,
cartaoId: isCredit ? cartaoId : undefined,
categoriaId,
note: item.note || undefined,
isSettled: isCredit ? null : Boolean(item.isSettled),
installmentCount:
item.condition === "Parcelado" && item.installmentCount
? Number(item.installmentCount)
: undefined,
recurrenceCount:
item.condition === "Recorrente" && item.recurrenceCount
? Number(item.recurrenceCount)
: undefined,
dueDate:
item.paymentMethod === "Boleto" && item.dueDate
? item.dueDate
: undefined,
};
const result = await createLancamentoAction(payload);
if (result.success) {
successCount++;
} else {
errorCount++;
console.error(`Failed to import ${item.name}:`, result.error);
}
} }
if (errorCount === 0) { if (!isCredit && !contaId) {
toast.success( toast.error("Selecione uma conta.");
`${successCount} ${ return;
successCount === 1 }
? "lançamento importado"
: "lançamentos importados" const payload: CreateLancamentoInput = {
} com sucesso!`, purchaseDate: item.purchaseDate,
); period: item.period,
handleOpenChange(false); name: item.name,
} else if (successCount > 0) { transactionType:
toast.warning( item.transactionType as CreateLancamentoInput["transactionType"],
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`, amount: sanitizedAmount,
); condition: item.condition as CreateLancamentoInput["condition"],
paymentMethod:
item.paymentMethod as CreateLancamentoInput["paymentMethod"],
pagadorId: pagadorId ?? null,
secondaryPagadorId: undefined,
isSplit: false,
contaId: isCredit ? null : (contaId ?? null),
cartaoId: isCredit ? (cartaoId ?? null) : null,
categoriaId: categoriaId ?? null,
note: item.note ?? null,
isSettled: isCredit ? null : Boolean(item.isSettled),
installmentCount:
item.condition === "Parcelado" && item.installmentCount
? Number(item.installmentCount)
: undefined,
recurrenceCount:
item.condition === "Recorrente" && item.recurrenceCount
? Number(item.recurrenceCount)
: undefined,
dueDate:
item.paymentMethod === "Boleto" && item.dueDate
? item.dueDate
: undefined,
};
const result = await createLancamentoAction(payload);
if (result.success) {
successCount++;
} else { } else {
toast.error("Falha ao importar lançamentos. Verifique o console."); errorCount++;
console.error(`Failed to import ${item.name}:`, result.error);
} }
}); }
},
[items, pagadorId, categoriaId, contaId, cartaoId, handleOpenChange], if (errorCount === 0) {
); toast.success(
`${successCount} ${
successCount === 1
? "lançamento importado"
: "lançamentos importados"
} com sucesso!`,
);
handleOpenChange(false);
} else if (successCount > 0) {
toast.warning(
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`,
);
} else {
toast.error("Falha ao importar lançamentos. Verifique o console.");
}
});
};
const itemCount = items.length; const itemCount = items.length;
const hasCredit = items.some( const hasCredit = items.some(

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { useCallback, useMemo } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
Select, Select,
@@ -10,73 +9,48 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { LANCAMENTO_CONDITIONS } from "@/lib/lancamentos/constants"; import { LANCAMENTO_CONDITIONS } from "@/lib/lancamentos/constants";
import { formatCurrency } from "@/lib/utils/currency";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { ConditionSelectContent } from "../../select-items"; import { ConditionSelectContent } from "../../select-items";
import type { ConditionSectionProps } from "./lancamento-dialog-types"; import type { ConditionSectionProps } from "./lancamento-dialog-types";
function formatCurrency(value: number): string {
return value.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function ConditionSection({ export function ConditionSection({
formState, formState,
onFieldChange, onFieldChange,
showInstallments, showInstallments,
showRecurrence, showRecurrence,
}: ConditionSectionProps) { }: ConditionSectionProps) {
const amount = useMemo(() => { const parsedAmount = Number(formState.amount);
const value = Number(formState.amount); const amount =
return Number.isNaN(value) || value <= 0 ? null : value; Number.isNaN(parsedAmount) || parsedAmount <= 0 ? null : parsedAmount;
}, [formState.amount]);
const getInstallmentLabel = useCallback( const getInstallmentLabel = (count: number) => {
(count: number) => { if (amount) {
if (amount) { const installmentValue = amount / count;
const installmentValue = amount / count; return `${count}x de R$ ${formatCurrency(installmentValue)}`;
return `${count}x de R$ ${formatCurrency(installmentValue)}`; }
}
return `${count}x`;
},
[amount],
);
const _getRecurrenceLabel = (count: number) => { return `${count}x`;
return `${count} meses`;
}; };
const installmentSummary = useMemo(() => { const installmentCount = Number(formState.installmentCount);
if (!showInstallments || !formState.installmentCount || !amount) { const installmentSummary =
return null; showInstallments &&
} formState.installmentCount &&
amount &&
!Number.isNaN(installmentCount) &&
installmentCount > 0
? getInstallmentLabel(installmentCount)
: null;
const count = Number(formState.installmentCount); const recurrenceCount = Number(formState.recurrenceCount);
if (Number.isNaN(count) || count <= 0) { const recurrenceSummary =
return null; showRecurrence &&
} formState.recurrenceCount &&
!Number.isNaN(recurrenceCount) &&
return getInstallmentLabel(count); recurrenceCount > 0
}, [ ? `Por ${recurrenceCount} meses`
showInstallments, : null;
formState.installmentCount,
amount,
getInstallmentLabel,
]);
const recurrenceSummary = useMemo(() => {
if (!showRecurrence || !formState.recurrenceCount) {
return null;
}
const count = Number(formState.recurrenceCount);
if (Number.isNaN(count) || count <= 0) {
return null;
}
return `Por ${count} meses`;
}, [showRecurrence, formState.recurrenceCount]);
return ( return (
<div className="flex w-full flex-col gap-2 md:flex-row"> <div className="flex w-full flex-col gap-2 md:flex-row">

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { useCallback } from "react";
import { CurrencyInput } from "@/components/ui/currency-input"; import { CurrencyInput } from "@/components/ui/currency-input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@@ -20,25 +19,19 @@ export function PagadorSection({
secondaryPagadorOptions, secondaryPagadorOptions,
totalAmount, totalAmount,
}: PagadorSectionProps) { }: PagadorSectionProps) {
const handlePrimaryAmountChange = useCallback( const handlePrimaryAmountChange = (value: string) => {
(value: string) => { onFieldChange("primarySplitAmount", value);
onFieldChange("primarySplitAmount", value); const numericValue = Number.parseFloat(value) || 0;
const numericValue = Number.parseFloat(value) || 0; const remaining = Math.max(0, totalAmount - numericValue);
const remaining = Math.max(0, totalAmount - numericValue); onFieldChange("secondarySplitAmount", remaining.toFixed(2));
onFieldChange("secondarySplitAmount", remaining.toFixed(2)); };
},
[totalAmount, onFieldChange],
);
const handleSecondaryAmountChange = useCallback( const handleSecondaryAmountChange = (value: string) => {
(value: string) => { onFieldChange("secondarySplitAmount", value);
onFieldChange("secondarySplitAmount", value); const numericValue = Number.parseFloat(value) || 0;
const numericValue = Number.parseFloat(value) || 0; const remaining = Math.max(0, totalAmount - numericValue);
const remaining = Math.max(0, totalAmount - numericValue); onFieldChange("primarySplitAmount", remaining.toFixed(2));
onFieldChange("primarySplitAmount", remaining.toFixed(2)); };
},
[totalAmount, onFieldChange],
);
return ( return (
<div className="flex w-full flex-col gap-2 md:flex-row"> <div className="flex w-full flex-col gap-2 md:flex-row">

View File

@@ -16,7 +16,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants"; import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
import { displayPeriod } from "@/lib/utils/period"; import { dateToPeriod, displayPeriod, periodToDate } from "@/lib/utils/period";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { import {
ContaCartaoSelectContent, ContaCartaoSelectContent,
@@ -24,17 +24,6 @@ import {
} from "../../select-items"; } from "../../select-items";
import type { PaymentMethodSectionProps } from "./lancamento-dialog-types"; import type { PaymentMethodSectionProps } from "./lancamento-dialog-types";
function periodToDate(period: string): Date {
const [year, month] = period.split("-").map(Number);
return new Date(year, month - 1, 1);
}
function dateToPeriod(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
function InlinePeriodPicker({ function InlinePeriodPicker({
period, period,
onPeriodChange, onPeriodChange,

View File

@@ -33,9 +33,12 @@ import {
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers"; import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants"; import {
LANCAMENTO_PAYMENT_METHODS,
type LANCAMENTO_TRANSACTION_TYPES,
} from "@/lib/lancamentos/constants";
import { getTodayDateString } from "@/lib/utils/date"; import { getTodayDateString } from "@/lib/utils/date";
import { displayPeriod } from "@/lib/utils/period"; import { dateToPeriod, displayPeriod, periodToDate } from "@/lib/utils/period";
import { import {
CategoriaSelectContent, CategoriaSelectContent,
ContaCartaoSelectContent, ContaCartaoSelectContent,
@@ -50,17 +53,8 @@ import type { SelectOption } from "../types";
const MASS_ADD_PAYMENT_METHODS = LANCAMENTO_PAYMENT_METHODS.filter( const MASS_ADD_PAYMENT_METHODS = LANCAMENTO_PAYMENT_METHODS.filter(
(m) => m !== "Boleto", (m) => m !== "Boleto",
); );
type MassAddTransactionType = (typeof LANCAMENTO_TRANSACTION_TYPES)[number];
function periodToDate(period: string): Date { type MassAddPaymentMethod = (typeof LANCAMENTO_PAYMENT_METHODS)[number];
const [year, month] = period.split("-").map(Number);
return new Date(year, month - 1, 1);
}
function dateToPeriod(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
function InlinePeriodPicker({ function InlinePeriodPicker({
period, period,
@@ -111,23 +105,9 @@ interface MassAddDialogProps {
defaultCartaoId?: string | null; defaultCartaoId?: string | null;
} }
export interface MassAddFormData { export type MassAddFormData = Parameters<
fixedFields: { typeof import("@/app/(dashboard)/lancamentos/actions").createMassLancamentosAction
transactionType?: string; >[0];
paymentMethod?: string;
condition?: string;
period?: string;
contaId?: string;
cartaoId?: string;
};
transactions: Array<{
purchaseDate: string;
name: string;
amount: string;
categoriaId?: string;
pagadorId?: string;
}>;
}
interface TransactionRow { interface TransactionRow {
id: string; id: string;
@@ -154,8 +134,9 @@ export function MassAddDialog({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Fixed fields state (sempre ativos, sem checkboxes) // Fixed fields state (sempre ativos, sem checkboxes)
const [transactionType, setTransactionType] = useState<string>("Despesa"); const [transactionType, setTransactionType] =
const [paymentMethod, setPaymentMethod] = useState<string>( useState<MassAddTransactionType>("Despesa");
const [paymentMethod, setPaymentMethod] = useState<MassAddPaymentMethod>(
LANCAMENTO_PAYMENT_METHODS[0], LANCAMENTO_PAYMENT_METHODS[0],
); );
const [period, setPeriod] = useState<string>(selectedPeriod); const [period, setPeriod] = useState<string>(selectedPeriod);
@@ -257,7 +238,7 @@ export function MassAddDialog({
transactions: transactions.map((t) => ({ transactions: transactions.map((t) => ({
purchaseDate: t.purchaseDate, purchaseDate: t.purchaseDate,
name: t.name.trim(), name: t.name.trim(),
amount: t.amount.trim(), amount: Number(t.amount.trim()),
categoriaId: t.categoriaId, categoriaId: t.categoriaId,
pagadorId: t.pagadorId, pagadorId: t.pagadorId,
})), })),
@@ -312,7 +293,9 @@ export function MassAddDialog({
<Label htmlFor="transaction-type">Tipo de Transação</Label> <Label htmlFor="transaction-type">Tipo de Transação</Label>
<Select <Select
value={transactionType} value={transactionType}
onValueChange={setTransactionType} onValueChange={(value) =>
setTransactionType(value as MassAddTransactionType)
}
> >
<SelectTrigger id="transaction-type" className="w-full"> <SelectTrigger id="transaction-type" className="w-full">
<SelectValue> <SelectValue>
@@ -338,7 +321,7 @@ export function MassAddDialog({
<Select <Select
value={paymentMethod} value={paymentMethod}
onValueChange={(value) => { onValueChange={(value) => {
setPaymentMethod(value); setPaymentMethod(value as MassAddPaymentMethod);
// Reset conta/cartao when changing payment method // Reset conta/cartao when changing payment method
if (value === "Cartão de crédito") { if (value === "Cartão de crédito") {
setContaId(undefined); setContaId(undefined);

View File

@@ -19,10 +19,12 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { formatCurrency } from "@/lib/lancamentos/formatting-helpers"; import { formatCurrency } from "@/lib/lancamentos/formatting-helpers";
import { formatDateOnly, formatDateTime } from "@/lib/utils/date";
import { import {
getPrimaryPdfColor, getPrimaryPdfColor,
loadExportLogoDataUrl, loadExportLogoDataUrl,
} from "@/lib/utils/export-branding"; } from "@/lib/utils/export-branding";
import { displayPeriod } from "@/lib/utils/period";
import type { LancamentoItem } from "./types"; import type { LancamentoItem } from "./types";
interface LancamentosExportProps { interface LancamentosExportProps {
@@ -41,12 +43,13 @@ export function LancamentosExport({
}; };
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); return (
return date.toLocaleDateString("pt-BR", { formatDateOnly(dateString, {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
}); }) ?? dateString
);
}; };
const getContaCartaoName = (lancamento: LancamentoItem) => { const getContaCartaoName = (lancamento: LancamentoItem) => {
@@ -190,8 +193,8 @@ export function LancamentosExport({
const doc = new jsPDF({ orientation: "landscape" }); const doc = new jsPDF({ orientation: "landscape" });
const primaryColor = getPrimaryPdfColor(); const primaryColor = getPrimaryPdfColor();
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([ const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
loadExportLogoDataUrl("/logo_small.png"), loadExportLogoDataUrl("/imagens/logo_small.png"),
loadExportLogoDataUrl("/logo_text.png"), loadExportLogoDataUrl("/imagens/logo_text.png"),
]); ]);
let brandingEndX = 14; let brandingEndX = 14;
@@ -212,28 +215,15 @@ export function LancamentosExport({
doc.text("Lançamentos", titleX, 15); doc.text("Lançamentos", titleX, 15);
doc.setFontSize(10); doc.setFontSize(10);
const periodParts = period.split("-"); doc.text(`Período: ${displayPeriod(period)}`, titleX, 22);
const monthNames = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
];
const formattedPeriod =
periodParts.length === 2
? `${monthNames[Number.parseInt(periodParts[1], 10) - 1]}/${periodParts[0]}`
: period;
doc.text(`Período: ${formattedPeriod}`, titleX, 22);
doc.text( doc.text(
`Gerado em: ${new Date().toLocaleDateString("pt-BR")}`, `Gerado em: ${
formatDateTime(new Date(), {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? "—"
}`,
titleX, titleX,
27, 27,
); );

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
createMassLancamentosAction, createMassLancamentosAction,
@@ -10,7 +10,7 @@ import {
toggleLancamentoSettlementAction, toggleLancamentoSettlementAction,
updateLancamentoBulkAction, updateLancamentoBulkAction,
} from "@/app/(dashboard)/lancamentos/actions"; } from "@/app/(dashboard)/lancamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog"; import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog"; import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog";
@@ -139,7 +139,7 @@ export function LancamentosPage({
LancamentoItem[] LancamentoItem[]
>([]); >([]);
const handleToggleSettlement = useCallback(async (item: LancamentoItem) => { const handleToggleSettlement = async (item: LancamentoItem) => {
if (item.paymentMethod === "Cartão de crédito") { if (item.paymentMethod === "Cartão de crédito") {
toast.info( toast.info(
"Pagamentos com cartão são conciliados automaticamente. Ajuste pelo cartão.", "Pagamentos com cartão são conciliados automaticamente. Ajuste pelo cartão.",
@@ -182,9 +182,9 @@ export function LancamentosPage({
} finally { } finally {
setSettlementLoadingId(null); setSettlementLoadingId(null);
} }
}, []); };
const handleDelete = useCallback(async () => { const handleDelete = async () => {
if (!lancamentoToDelete) { if (!lancamentoToDelete) {
return; return;
} }
@@ -200,91 +200,82 @@ export function LancamentosPage({
toast.success(result.message); toast.success(result.message);
setDeleteOpen(false); setDeleteOpen(false);
}, [lancamentoToDelete]); };
const handleBulkDelete = useCallback( const handleBulkDelete = async (scope: BulkActionScope) => {
async (scope: BulkActionScope) => { if (!pendingDeleteData) {
if (!pendingDeleteData) { return;
return; }
}
const result = await deleteLancamentoBulkAction({ const result = await deleteLancamentoBulkAction({
id: pendingDeleteData.id, id: pendingDeleteData.id,
scope, scope,
}); });
if (!result.success) { if (!result.success) {
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
} }
toast.success(result.message); toast.success(result.message);
setBulkDeleteOpen(false); setBulkDeleteOpen(false);
setPendingDeleteData(null); setPendingDeleteData(null);
}, };
[pendingDeleteData],
);
const handleBulkEditRequest = useCallback( const handleBulkEditRequest = (data: {
(data: { id: string;
id: string; name: string;
name: string; categoriaId: string | undefined;
categoriaId: string | undefined; note: string;
note: string; pagadorId: string | undefined;
pagadorId: string | undefined; contaId: string | undefined;
contaId: string | undefined; cartaoId: string | undefined;
cartaoId: string | undefined; amount: number;
amount: number; dueDate: string | null;
dueDate: string | null; boletoPaymentDate: string | null;
boletoPaymentDate: string | null; }) => {
}) => { if (!selectedLancamento) {
if (!selectedLancamento) { return;
return; }
}
setPendingEditData({ setPendingEditData({
...data, ...data,
lancamento: selectedLancamento, lancamento: selectedLancamento,
}); });
setEditOpen(false); setEditOpen(false);
setBulkEditOpen(true); setBulkEditOpen(true);
}, };
[selectedLancamento],
);
const handleBulkEdit = useCallback( const handleBulkEdit = async (scope: BulkActionScope) => {
async (scope: BulkActionScope) => { if (!pendingEditData) {
if (!pendingEditData) { return;
return; }
}
const result = await updateLancamentoBulkAction({ const result = await updateLancamentoBulkAction({
id: pendingEditData.id, id: pendingEditData.id,
scope, scope,
name: pendingEditData.name, name: pendingEditData.name,
categoriaId: pendingEditData.categoriaId, categoriaId: pendingEditData.categoriaId,
note: pendingEditData.note, note: pendingEditData.note,
pagadorId: pendingEditData.pagadorId, pagadorId: pendingEditData.pagadorId,
contaId: pendingEditData.contaId, contaId: pendingEditData.contaId,
cartaoId: pendingEditData.cartaoId, cartaoId: pendingEditData.cartaoId,
amount: pendingEditData.amount, amount: pendingEditData.amount,
dueDate: pendingEditData.dueDate, dueDate: pendingEditData.dueDate,
boletoPaymentDate: pendingEditData.boletoPaymentDate, boletoPaymentDate: pendingEditData.boletoPaymentDate,
}); });
if (!result.success) { if (!result.success) {
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
} }
toast.success(result.message); toast.success(result.message);
setBulkEditOpen(false); setBulkEditOpen(false);
setPendingEditData(null); setPendingEditData(null);
}, };
[pendingEditData],
);
const handleMassAddSubmit = useCallback(async (data: MassAddFormData) => { const handleMassAddSubmit = async (data: MassAddFormData) => {
const result = await createMassLancamentosAction(data); const result = await createMassLancamentosAction(data);
if (!result.success) { if (!result.success) {
@@ -293,9 +284,9 @@ export function LancamentosPage({
} }
toast.success(result.message); toast.success(result.message);
}, []); };
const handleMultipleBulkDelete = useCallback((items: LancamentoItem[]) => { const handleMultipleBulkDelete = (items: LancamentoItem[]) => {
// Se todos os selecionados são da mesma série (parcelado/recorrente), abrir dialog de escopo // Se todos os selecionados são da mesma série (parcelado/recorrente), abrir dialog de escopo
const withSeries = items.filter((i) => i.seriesId); const withSeries = items.filter((i) => i.seriesId);
const sameSeries = const sameSeries =
@@ -309,9 +300,9 @@ export function LancamentosPage({
} }
setPendingMultipleDeleteData(items); setPendingMultipleDeleteData(items);
setMultipleBulkDeleteOpen(true); setMultipleBulkDeleteOpen(true);
}, []); };
const confirmMultipleBulkDelete = useCallback(async () => { const confirmMultipleBulkDelete = async () => {
if (pendingMultipleDeleteData.length === 0) { if (pendingMultipleDeleteData.length === 0) {
return; return;
} }
@@ -327,42 +318,42 @@ export function LancamentosPage({
toast.success(result.message); toast.success(result.message);
setMultipleBulkDeleteOpen(false); setMultipleBulkDeleteOpen(false);
setPendingMultipleDeleteData([]); setPendingMultipleDeleteData([]);
}, [pendingMultipleDeleteData]); };
const [transactionTypeForCreate, setTransactionTypeForCreate] = useState< const [transactionTypeForCreate, setTransactionTypeForCreate] = useState<
"Despesa" | "Receita" | null "Despesa" | "Receita" | null
>(null); >(null);
const handleCreate = useCallback((type: "Despesa" | "Receita") => { const handleCreate = (type: "Despesa" | "Receita") => {
setTransactionTypeForCreate(type); setTransactionTypeForCreate(type);
setCreateOpen(true); setCreateOpen(true);
}, []); };
const handleMassAdd = useCallback(() => { const handleMassAdd = () => {
setMassAddOpen(true); setMassAddOpen(true);
}, []); };
const handleEdit = useCallback((item: LancamentoItem) => { const handleEdit = (item: LancamentoItem) => {
setSelectedLancamento(item); setSelectedLancamento(item);
setEditOpen(true); setEditOpen(true);
}, []); };
const handleCopy = useCallback((item: LancamentoItem) => { const handleCopy = (item: LancamentoItem) => {
setLancamentoToCopy(item); setLancamentoToCopy(item);
setCopyOpen(true); setCopyOpen(true);
}, []); };
const handleImport = useCallback((item: LancamentoItem) => { const handleImport = (item: LancamentoItem) => {
setLancamentoToImport(item); setLancamentoToImport(item);
setImportOpen(true); setImportOpen(true);
}, []); };
const handleBulkImport = useCallback((items: LancamentoItem[]) => { const handleBulkImport = (items: LancamentoItem[]) => {
setLancamentosToImport(items); setLancamentosToImport(items);
setBulkImportOpen(true); setBulkImportOpen(true);
}, []); };
const handleConfirmDelete = useCallback((item: LancamentoItem) => { const handleConfirmDelete = (item: LancamentoItem) => {
if (item.seriesId) { if (item.seriesId) {
setPendingDeleteData(item); setPendingDeleteData(item);
setBulkDeleteOpen(true); setBulkDeleteOpen(true);
@@ -370,22 +361,22 @@ export function LancamentosPage({
setLancamentoToDelete(item); setLancamentoToDelete(item);
setDeleteOpen(true); setDeleteOpen(true);
} }
}, []); };
const handleViewDetails = useCallback((item: LancamentoItem) => { const handleViewDetails = (item: LancamentoItem) => {
setSelectedLancamento(item); setSelectedLancamento(item);
setDetailsOpen(true); setDetailsOpen(true);
}, []); };
const handleAnticipate = useCallback((item: LancamentoItem) => { const handleAnticipate = (item: LancamentoItem) => {
setSelectedForAnticipation(item); setSelectedForAnticipation(item);
setAnticipateOpen(true); setAnticipateOpen(true);
}, []); };
const handleViewAnticipationHistory = useCallback((item: LancamentoItem) => { const handleViewAnticipationHistory = (item: LancamentoItem) => {
setSelectedForAnticipation(item); setSelectedForAnticipation(item);
setAnticipationHistoryOpen(true); setAnticipationHistoryOpen(true);
}, []); };
return ( return (
<> <>

View File

@@ -3,8 +3,9 @@
import { RiBankCard2Line, RiBankLine } from "@remixicon/react"; import { RiBankCard2Line, RiBankLine } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import { CategoryIcon } from "@/components/categorias/category-icon"; import { CategoryIcon } from "@/components/categorias/category-icon";
import DotIcon from "@/components/dot-icon"; import StatusDot from "@/components/shared/status-dot";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { resolveLogoSrc } from "@/lib/logo";
import { getAvatarSrc } from "@/lib/pagadores/utils"; import { getAvatarSrc } from "@/lib/pagadores/utils";
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons"; import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
@@ -56,7 +57,7 @@ export function TransactionTypeSelectContent({ label }: { label: string }) {
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<DotIcon color={colorMap[label]} /> <StatusDot color={colorMap[label]} />
<span>{label}</span> <span>{label}</span>
</span> </span>
); );
@@ -89,15 +90,6 @@ export function ContaCartaoSelectContent({
logo, logo,
isCartao, isCartao,
}: SelectItemContentProps & { isCartao?: boolean }) { }: SelectItemContentProps & { isCartao?: boolean }) {
const resolveLogoSrc = (logoPath: string | null) => {
if (!logoPath) {
return null;
}
const fileName = logoPath.split("/").filter(Boolean).pop() ?? logoPath;
return `/logos/${fileName}`;
};
const logoSrc = resolveLogoSrc(logo); const logoSrc = resolveLogoSrc(logo);
const Icon = isCartao ? RiBankCard2Line : RiBankLine; const Icon = isCartao ? RiBankCard2Line : RiBankLine;

View File

@@ -6,8 +6,8 @@ import { ptBR } from "date-fns/locale";
import { useTransition } from "react"; import { useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cancelInstallmentAnticipationAction } from "@/app/(dashboard)/lancamentos/anticipation-actions"; import { cancelInstallmentAnticipationAction } from "@/app/(dashboard)/lancamentos/anticipation-actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {

View File

@@ -31,9 +31,9 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { CategoryIcon } from "@/components/categorias/category-icon"; import { CategoryIcon } from "@/components/categorias/category-icon";
import MoneyValues from "@/components/money-values";
import { EmptyState } from "@/components/shared/empty-state"; import { EmptyState } from "@/components/shared/empty-state";
import { TypeBadge } from "@/components/type-badge"; import MoneyValues from "@/components/shared/money-values";
import { TypeBadge } from "@/components/shared/type-badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -69,6 +69,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/lib/lancamentos/column-order"; import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/lib/lancamentos/column-order";
import { resolveLogoSrc } from "@/lib/logo";
import { getAvatarSrc } from "@/lib/pagadores/utils"; import { getAvatarSrc } from "@/lib/pagadores/utils";
import { formatDate } from "@/lib/utils/date"; import { formatDate } from "@/lib/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons"; import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
@@ -82,15 +83,6 @@ import type {
} from "../types"; } from "../types";
import { LancamentosFilters } from "./lancamentos-filters"; import { LancamentosFilters } from "./lancamentos-filters";
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return null;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
};
type BuildColumnsArgs = { type BuildColumnsArgs = {
currentUserId: string; currentUserId: string;
noteAsColumn: boolean; noteAsColumn: boolean;
@@ -386,7 +378,7 @@ const buildColumns = ({
cell: ({ row }) => { cell: ({ row }) => {
const { pagadorId, pagadorName, pagadorAvatar } = row.original; const { pagadorId, pagadorName, pagadorAvatar } = row.original;
const label = pagadorName.trim() || "Sem pagador"; const label = pagadorName?.trim() || "Sem pagador";
const displayName = label.split(/\s+/)[0] ?? label; const displayName = label.split(/\s+/)[0] ?? label;
const avatarSrc = getAvatarSrc(pagadorAvatar); const avatarSrc = getAvatarSrc(pagadorAvatar);
const initial = displayName.charAt(0).toUpperCase() || "?"; const initial = displayName.charAt(0).toUpperCase() || "?";

View File

@@ -38,6 +38,10 @@ import type {
BudgetNotification, BudgetNotification,
DashboardNotification, DashboardNotification,
} from "@/lib/dashboard/notifications"; } from "@/lib/dashboard/notifications";
import { resolveLogoSrc } from "@/lib/logo";
import { formatCurrency } from "@/lib/utils/currency";
import { formatDateOnly } from "@/lib/utils/date";
import { formatPercentage } from "@/lib/utils/percentage";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
type NotificationBellProps = { type NotificationBellProps = {
@@ -47,27 +51,13 @@ type NotificationBellProps = {
preLancamentosCount?: number; preLancamentosCount?: number;
}; };
const resolveLogoPath = (logo: string | null | undefined) => {
if (!logo) return null;
if (/^(https?:\/\/|data:)/.test(logo)) return logo;
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
const [year, month, day] = dateString.split("-").map(Number); return (
const date = new Date(Date.UTC(year, month - 1, day)); formatDateOnly(dateString, {
return date.toLocaleDateString("pt-BR", { day: "2-digit",
day: "2-digit", month: "short",
month: "short", }) ?? dateString
timeZone: "UTC", );
});
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(amount);
} }
function SectionLabel({ function SectionLabel({
@@ -115,9 +105,9 @@ export function NotificationBell({
aria-expanded={open} aria-expanded={open}
className={cn( className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }), buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200", "group relative border border-black/10 text-black/75 shadow-none transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40", "hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border", "data-[state=open]:bg-black/10 data-[state=open]:text-black",
)} )}
> >
<RiNotification3Line <RiNotification3Line
@@ -130,7 +120,7 @@ export function NotificationBell({
<> <>
<span <span
aria-hidden aria-hidden
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground shadow-xs ring-2 ring-background" className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-destructive-foreground "
> >
{displayCount} {displayCount}
</span> </span>
@@ -148,7 +138,7 @@ export function NotificationBell({
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
sideOffset={12} sideOffset={12}
className="w-76 overflow-hidden rounded-lg border border-border/60 bg-popover/95 p-0 shadow-lg backdrop-blur-lg supports-backdrop-filter:backdrop-blur-md" className="w-76 overflow-hidden rounded-lg border border-border/60 bg-popover p-0 shadow-none"
> >
{/* Header */} {/* Header */}
<DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-sm font-semibold"> <DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-sm font-semibold">
@@ -223,13 +213,22 @@ export function NotificationBell({
excedido {" "} excedido {" "}
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "} <strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
{formatCurrency(n.budgetAmount)} ( {formatCurrency(n.budgetAmount)} (
{Math.round(n.usedPercentage)}%) {formatPercentage(n.usedPercentage, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
})}
)
</> </>
) : ( ) : (
<> <>
<strong>{n.categoryName}</strong> atingiu{" "} <strong>{n.categoryName}</strong> atingiu{" "}
<strong>{Math.round(n.usedPercentage)}%</strong> do <strong>
orçamento {" "} {formatPercentage(n.usedPercentage, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
})}
</strong>{" "}
do orçamento {" "}
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "} <strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
{formatCurrency(n.budgetAmount)} {formatCurrency(n.budgetAmount)}
</> </>
@@ -250,7 +249,7 @@ export function NotificationBell({
/> />
<div className="mx-1 mb-1 overflow-hidden rounded-md"> <div className="mx-1 mb-1 overflow-hidden rounded-md">
{invoiceNotifications.map((n) => { {invoiceNotifications.map((n) => {
const logo = resolveLogoPath(n.cardLogo); const logo = resolveLogoSrc(n.cardLogo);
return ( return (
<div <div
key={n.id} key={n.id}

View File

@@ -7,7 +7,7 @@ import {
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/shared/money-values";
import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";

View File

@@ -1,13 +1,13 @@
"use client"; "use client";
import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react"; import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react";
import { useCallback, useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deleteBudgetAction, deleteBudgetAction,
duplicatePreviousMonthBudgetsAction, duplicatePreviousMonthBudgetsAction,
} from "@/app/(dashboard)/orcamentos/actions"; } from "@/app/(dashboard)/orcamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { EmptyState } from "@/components/shared/empty-state"; import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "../ui/card"; import { Card } from "../ui/card";
@@ -36,31 +36,31 @@ export function BudgetsPage({
const hasBudgets = budgets.length > 0; const hasBudgets = budgets.length > 0;
const handleEdit = useCallback((budget: Budget) => { const handleEdit = (budget: Budget) => {
setSelectedBudget(budget); setSelectedBudget(budget);
setEditOpen(true); setEditOpen(true);
}, []); };
const handleEditOpenChange = useCallback((open: boolean) => { const handleEditOpenChange = (open: boolean) => {
setEditOpen(open); setEditOpen(open);
if (!open) { if (!open) {
setSelectedBudget(null); setSelectedBudget(null);
} }
}, []); };
const handleRemoveRequest = useCallback((budget: Budget) => { const handleRemoveRequest = (budget: Budget) => {
setBudgetToRemove(budget); setBudgetToRemove(budget);
setRemoveOpen(true); setRemoveOpen(true);
}, []); };
const handleRemoveOpenChange = useCallback((open: boolean) => { const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open); setRemoveOpen(open);
if (!open) { if (!open) {
setBudgetToRemove(null); setBudgetToRemove(null);
} }
}, []); };
const handleRemoveConfirm = useCallback(async () => { const handleRemoveConfirm = async () => {
if (!budgetToRemove) { if (!budgetToRemove) {
return; return;
} }
@@ -74,9 +74,9 @@ export function BudgetsPage({
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
}, [budgetToRemove]); };
const handleDuplicateConfirm = useCallback(async () => { const handleDuplicateConfirm = async () => {
const result = await duplicatePreviousMonthBudgetsAction({ const result = await duplicatePreviousMonthBudgetsAction({
period: selectedPeriod, period: selectedPeriod,
}); });
@@ -89,7 +89,7 @@ export function BudgetsPage({
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
}, [selectedPeriod]); };
const removeTitle = budgetToRemove const removeTitle = budgetToRemove
? `Remover orçamento de "${ ? `Remover orçamento de "${

View File

@@ -1,22 +1,11 @@
import { RiBankCard2Line } from "@remixicon/react"; import { RiBankCard2Line } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/shared/money-values";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import { WidgetEmptyState } from "@/components/widget-empty-state"; import { resolveLogoSrc } from "@/lib/logo";
import type { PagadorCardUsageItem } from "@/lib/pagadores/details"; import type { PagadorCardUsageItem } from "@/lib/pagadores/details";
const resolveLogoPath = (logo?: string | null) => {
if (!logo) return null;
if (
logo.startsWith("http://") ||
logo.startsWith("https://") ||
logo.startsWith("data:")
) {
return logo;
}
return logo.startsWith("/") ? logo : `/logos/${logo}`;
};
const buildInitials = (value: string) => { const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean); const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "CC"; if (parts.length === 0) return "CC";
@@ -50,7 +39,7 @@ export function PagadorCardUsageCard({ items }: PagadorCardUsageCardProps) {
<CardContent className="flex flex-col gap-4 px-0"> <CardContent className="flex flex-col gap-4 px-0">
<ul className="flex flex-col"> <ul className="flex flex-col">
{items.map((item) => { {items.map((item) => {
const logoPath = resolveLogoPath(item.logo); const logoPath = resolveLogoSrc(item.logo);
const initials = buildInitials(item.name); const initials = buildInitials(item.name);
return ( return (
<li <li

View File

@@ -0,0 +1,365 @@
"use client";
import {
RiBankCard2Line,
RiBillLine,
RiExchangeDollarLine,
RiMailLine,
RiMailSendLine,
RiVerifiedBadgeFill,
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { sendPagadorSummaryAction } from "@/app/(dashboard)/pagadores/[pagadorId]/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { formatCurrency } from "@/lib/utils/currency";
import { formatDateTime } from "@/lib/utils/date";
import type { PagadorInfo, PagadorSummaryPreview } from "./types";
type PagadorHeaderCardProps = {
pagador: PagadorInfo;
selectedPeriod: string;
summary: PagadorSummaryPreview;
};
export function PagadorHeaderCard({
pagador,
selectedPeriod,
summary,
}: PagadorHeaderCardProps) {
const router = useRouter();
const [isSending, startTransition] = useTransition();
const [confirmOpen, setConfirmOpen] = useState(false);
const avatarSrc = getAvatarSrc(pagador.avatarUrl);
const createdAtLabel = formatDate(pagador.createdAt);
const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN;
const lastMailLabel =
formatDateTime(pagador.lastMailAt, {
dateStyle: "short",
timeStyle: "short",
}) ?? "Nunca enviado";
const disableSend = isSending || !pagador.email || !pagador.canEdit;
const openConfirmDialog = () => {
if (!pagador.email) {
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
return;
}
setConfirmOpen(true);
};
const handleSendSummary = () => {
if (!pagador.email) {
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
return;
}
startTransition(async () => {
const result = await sendPagadorSummaryAction({
pagadorId: pagador.id,
period: selectedPeriod,
});
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(result.message);
setConfirmOpen(false);
router.refresh();
});
};
const getStatusBadgeVariant = (status: string): "success" | "outline" => {
const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativo") {
return "success";
}
return "outline";
};
return (
<Card className="mb-2 border gap-4">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex flex-1 items-start gap-4">
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden">
<Image
src={avatarSrc}
alt={`Avatar de ${pagador.name}`}
width={64}
height={64}
className="h-full w-full rounded-full object-cover"
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl font-semibold text-foreground">
{pagador.name}
</CardTitle>
{isAdmin ? (
<RiVerifiedBadgeFill
className="size-4 text-sky-500"
aria-hidden
/>
) : null}
<Badge
variant={getStatusBadgeVariant(pagador.status)}
className="text-xs"
>
{pagador.status}
</Badge>
{pagador.isAutoSend ? (
<Badge variant="info" className="gap-1 text-xs">
<RiMailSendLine className="size-3.5" aria-hidden />
Envio automático
</Badge>
) : null}
</div>
<CardDescription className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<span>Criado em {createdAtLabel}</span>
<span className="hidden text-border/80 sm:inline"></span>
{pagador.email ? (
<Link
prefetch
href={`mailto:${pagador.email}`}
className="inline-flex items-center gap-1.5 text-primary"
>
<RiMailLine className="size-4" aria-hidden />
{pagador.email}
</Link>
) : (
<span>Sem e-mail cadastrado</span>
)}
</CardDescription>
</div>
</div>
<div className="flex w-full flex-col items-stretch gap-2 lg:w-auto lg:items-end">
{pagador.canEdit ? (
<>
<Button
type="button"
size="sm"
onClick={openConfirmDialog}
disabled={disableSend}
className="w-full min-w-[180px] lg:w-auto"
>
{isSending ? "Enviando..." : "Enviar resumo"}
</Button>
<span className="text-xs text-muted-foreground">
Último envio: {lastMailLabel}
</span>
</>
) : (
<Badge variant="outline" className="justify-center text-xs">
Acesso somente leitura
</Badge>
)}
</div>
</CardHeader>
{pagador.canEdit ? (
<Dialog
open={confirmOpen}
onOpenChange={(open) => {
if (isSending) return;
setConfirmOpen(open);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Confirmar envio do resumo</DialogTitle>
<DialogDescription>
Resumo de{" "}
<span className="font-semibold text-foreground">
{summary.periodLabel}
</span>{" "}
para{" "}
<span className="font-medium text-foreground">
{pagador.email}
</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
<RiExchangeDollarLine className="size-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Total de Despesas
</p>
<p className="text-2xl font-bold text-foreground">
{formatCurrency(summary.totalExpenses)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">
{summary.lancamentoCount} lançamentos
</p>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiBankCard2Line className="size-4" />
<span className="text-xs font-semibold uppercase">
Cartões
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.card)}
</p>
</div>
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiBillLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Boletos
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.boleto)}
</p>
</div>
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
<RiExchangeDollarLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Pix/Débito
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.instant)}
</p>
</div>
</div>
<div className="space-y-3">
{summary.cardUsage.length > 0 && (
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2">
<RiBankCard2Line className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground">
Cartões Utilizados
</span>
</div>
<div className="space-y-1">
{summary.cardUsage.map((card, index) => (
<div
key={index}
className="flex items-center justify-between text-sm"
>
<span className="text-foreground">{card.name}</span>
<span className="font-medium text-foreground">
{formatCurrency(card.amount)}
</span>
</div>
))}
</div>
</div>
)}
{(summary.boletoStats.paidCount > 0 ||
summary.boletoStats.pendingCount > 0) && (
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center gap-2">
<RiBillLine className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground">
Status de Boletos
</span>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground">Pagos</p>
<p className="text-sm font-semibold text-success">
{formatCurrency(summary.boletoStats.paidAmount)}{" "}
<span className="text-xs font-normal">
({summary.boletoStats.paidCount})
</span>
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
Pendentes
</p>
<p className="text-sm font-semibold text-warning">
{formatCurrency(summary.boletoStats.pendingAmount)}{" "}
<span className="text-xs font-normal">
({summary.boletoStats.pendingCount})
</span>
</p>
</div>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isSending}
onClick={() => setConfirmOpen(false)}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleSendSummary}
disabled={disableSend}
>
{isSending ? "Enviando..." : "Confirmar envio"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</Card>
);
}
const formatDate = (value: string) => {
return (
formatDateTime(value, {
day: "2-digit",
month: "long",
year: "numeric",
}) ?? "—"
);
};

View File

@@ -15,7 +15,7 @@ import {
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from "@/components/ui/chart"; } from "@/components/ui/chart";
import { WidgetEmptyState } from "@/components/widget-empty-state"; import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers"; import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import type { PagadorHistoryPoint } from "@/lib/pagadores/details"; import type { PagadorHistoryPoint } from "@/lib/pagadores/details";

View File

@@ -1,136 +1,26 @@
"use client"; import { RiUser3Line } from "@remixicon/react";
import type { ReactNode } from "react";
import {
RiBankCard2Line,
RiBillLine,
RiExchangeDollarLine,
RiMailLine,
RiMailSendLine,
RiUser3Line,
RiVerifiedBadgeFill,
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { type ReactNode, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { sendPagadorSummaryAction } from "@/app/(dashboard)/pagadores/[pagadorId]/actions";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Dialog, Card,
DialogContent, CardContent,
DialogDescription, CardDescription,
DialogFooter, CardHeader,
DialogHeader, CardTitle,
DialogTitle, } from "@/components/ui/card";
} from "@/components/ui/dialog";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getAvatarSrc } from "@/lib/pagadores/utils"; import { formatDateTime } from "@/lib/utils/date";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import type { PagadorInfo } from "./types";
type PagadorInfo = {
id: string;
name: string;
email: string | null;
avatarUrl: string | null;
status: string;
note: string | null;
role: string | null;
isAutoSend: boolean;
createdAt: string;
lastMailAt: string | null;
shareCode: string | null;
canEdit: boolean;
};
type PagadorSummaryPreview = {
periodLabel: string;
totalExpenses: number;
paymentSplits: {
card: number;
boleto: number;
instant: number;
};
cardUsage: { name: string; amount: number }[];
boletoStats: {
totalAmount: number;
paidAmount: number;
pendingAmount: number;
paidCount: number;
pendingCount: number;
};
lancamentoCount: number;
};
type PagadorInfoCardProps = { type PagadorInfoCardProps = {
pagador: PagadorInfo; pagador: PagadorInfo;
selectedPeriod: string;
summary: PagadorSummaryPreview;
}; };
export function PagadorInfoCard({ export function PagadorInfoCard({ pagador }: PagadorInfoCardProps) {
pagador, const showSensitiveDetails = pagador.canEdit;
selectedPeriod,
summary,
}: PagadorInfoCardProps) {
const router = useRouter();
const [isSending, startTransition] = useTransition();
const [confirmOpen, setConfirmOpen] = useState(false);
const avatarSrc = getAvatarSrc(pagador.avatarUrl); const getStatusBadgeVariant = (status: string): "success" | "outline" => {
const createdAtLabel = formatDate(pagador.createdAt);
const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN;
const lastMailLabel = useMemo(() => {
if (!pagador.lastMailAt) {
return "Nunca enviado";
}
const date = new Date(pagador.lastMailAt);
if (Number.isNaN(date.getTime())) {
return "Nunca enviado";
}
return date.toLocaleString("pt-BR", {
dateStyle: "short",
timeStyle: "short",
});
}, [pagador.lastMailAt]);
const disableSend = isSending || !pagador.email || !pagador.canEdit;
const openConfirmDialog = () => {
if (!pagador.email) {
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
return;
}
setConfirmOpen(true);
};
const handleSendSummary = () => {
if (!pagador.email) {
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
return;
}
startTransition(async () => {
const result = await sendPagadorSummaryAction({
pagadorId: pagador.id,
period: selectedPeriod,
});
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(result.message);
setConfirmOpen(false);
router.refresh();
});
};
const getStatusBadgeVariant = (status: string): "success" | "secondary" => {
const normalizedStatus = status.toLowerCase(); const normalizedStatus = status.toLowerCase();
if (normalizedStatus === "ativo") { if (normalizedStatus === "ativo") {
return "success"; return "success";
@@ -140,84 +30,18 @@ export function PagadorInfoCard({
return ( return (
<Card className="border gap-4"> <Card className="border gap-4">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <CardHeader className="gap-1.5">
<div className="flex flex-1 items-start gap-4"> <CardTitle className="text-lg font-semibold">
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden"> Detalhes do pagador
<Image </CardTitle>
src={avatarSrc} <CardDescription>
alt={`Avatar de ${pagador.name}`} {showSensitiveDetails
width={64} ? "Informações cadastrais e preferências de envio."
height={64} : "Informações cadastrais visíveis para este compartilhamento."}
className="h-full w-full object-cover rounded-full" </CardDescription>
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl font-semibold text-foreground">
{pagador.name}
</CardTitle>
{isAdmin ? (
<RiVerifiedBadgeFill
className="size-4 text-sky-500"
aria-hidden
/>
) : null}
{pagador.isAutoSend ? (
<RiMailSendLine
className="size-4 text-primary"
aria-label="Envio automático habilitado"
/>
) : null}
</div>
<span className="text-sm text-muted-foreground">
Criado em {createdAtLabel}
</span>
</div>
</div>
<div className="flex w-full flex-col items-stretch gap-2 lg:w-auto lg:items-end">
{pagador.canEdit ? (
<>
<Button
type="button"
size="sm"
onClick={openConfirmDialog}
disabled={disableSend}
className="w-full min-w-[180px] lg:w-auto"
>
{isSending ? "Enviando..." : "Enviar resumo"}
</Button>
<span className="text-xs text-muted-foreground">
Último envio: {lastMailLabel}
</span>
</>
) : (
<span className="text-xs font-medium text-warning">
Acesso somente leitura
</span>
)}
</div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 border-t border-dashed border-border/60 pt-6 text-sm sm:grid-cols-2"> <CardContent className="grid gap-4 border-t border-dashed border-border/60 pt-6 text-sm sm:grid-cols-2">
<InfoItem
label="E-mail"
value={
pagador.email ? (
<Link
prefetch
href={`mailto:${pagador.email}`}
className="inline-flex items-center gap-2 text-primary"
>
<RiMailLine className="size-4" aria-hidden />
{pagador.email}
</Link>
) : (
"Sem e-mail cadastrado"
)
}
/>
<InfoItem <InfoItem
label="Status" label="Status"
value={ value={
@@ -239,11 +63,19 @@ export function PagadorInfoCard({
</span> </span>
} }
/> />
<InfoItem {showSensitiveDetails ? (
label="Envio automático" <InfoItem
value={pagador.isAutoSend ? "Ativado" : "Desativado"} label="Envio automático"
/> value={pagador.isAutoSend ? "Ativado" : "Desativado"}
{!pagador.email ? ( />
) : null}
{showSensitiveDetails ? (
<InfoItem
label="Último envio"
value={formatDateTime(pagador.lastMailAt) ?? "Nunca enviado"}
/>
) : null}
{showSensitiveDetails && !pagador.email ? (
<InfoItem <InfoItem
label="Aviso" label="Aviso"
value={ value={
@@ -254,219 +86,29 @@ export function PagadorInfoCard({
className="sm:col-span-2" className="sm:col-span-2"
/> />
) : null} ) : null}
<InfoItem {showSensitiveDetails ? (
label="Observações" <InfoItem
value={ label="Observações"
pagador.note ? ( value={
<span className="text-muted-foreground">{pagador.note}</span> pagador.note ? (
) : ( <span className="text-muted-foreground">{pagador.note}</span>
"Sem observações" ) : (
) "Sem observações"
} )
className="sm:col-span-2" }
/> className="sm:col-span-2"
/>
) : null}
</CardContent> </CardContent>
{pagador.canEdit ? (
<Dialog
open={confirmOpen}
onOpenChange={(open) => {
if (isSending) return;
setConfirmOpen(open);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Confirmar envio do resumo</DialogTitle>
<DialogDescription>
Resumo de{" "}
<span className="font-semibold text-foreground">
{summary.periodLabel}
</span>{" "}
para{" "}
<span className="font-medium text-foreground">
{pagador.email}
</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Total Geral */}
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
<RiExchangeDollarLine className="size-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Total de Despesas
</p>
<p className="text-2xl font-bold text-foreground">
{formatCurrency(summary.totalExpenses)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">
{summary.lancamentoCount} lançamentos
</p>
</div>
</div>
</div>
{/* Grid de Formas de Pagamento */}
<div className="grid gap-3 sm:grid-cols-3">
{/* Cartões */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<RiBankCard2Line className="size-4" />
<span className="text-xs font-semibold uppercase">
Cartões
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.card)}
</p>
</div>
{/* Boletos */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<RiBillLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Boletos
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.boleto)}
</p>
</div>
{/* Instantâneo */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<RiExchangeDollarLine className="size-4" />
<span className="text-xs font-semibold uppercase">
Pix/Débito
</span>
</div>
<p className="text-lg font-bold text-foreground">
{formatCurrency(summary.paymentSplits.instant)}
</p>
</div>
</div>
{/* Detalhes Adicionais */}
<div className="space-y-3">
{/* Cartões Utilizados */}
{summary.cardUsage.length > 0 && (
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-2">
<RiBankCard2Line className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground">
Cartões Utilizados
</span>
</div>
<div className="space-y-1">
{summary.cardUsage.map((card, index) => (
<div
key={index}
className="flex items-center justify-between text-sm"
>
<span className="text-foreground">{card.name}</span>
<span className="font-medium text-foreground">
{formatCurrency(card.amount)}
</span>
</div>
))}
</div>
</div>
)}
{/* Status de Boletos */}
{(summary.boletoStats.paidCount > 0 ||
summary.boletoStats.pendingCount > 0) && (
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-2">
<RiBillLine className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground">
Status de Boletos
</span>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div>
<p className="text-xs text-muted-foreground">Pagos</p>
<p className="text-sm font-semibold text-success">
{formatCurrency(summary.boletoStats.paidAmount)}{" "}
<span className="text-xs font-normal">
({summary.boletoStats.paidCount})
</span>
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
Pendentes
</p>
<p className="text-sm font-semibold text-warning">
{formatCurrency(summary.boletoStats.pendingAmount)}{" "}
<span className="text-xs font-normal">
({summary.boletoStats.pendingCount})
</span>
</p>
</div>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={isSending}
onClick={() => setConfirmOpen(false)}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleSendSummary}
disabled={disableSend}
>
{isSending ? "Enviando..." : "Confirmar envio"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</Card> </Card>
); );
} }
const formatDate = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
year: "numeric",
});
};
const resolveRoleLabel = (role: string | null) => { const resolveRoleLabel = (role: string | null) => {
if (role === PAGADOR_ROLE_ADMIN) return "Administrador"; if (role === PAGADOR_ROLE_ADMIN) return "Administrador";
return "Pagador"; return "Pagador";
}; };
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
type InfoItemProps = { type InfoItemProps = {
label: string; label: string;
value: ReactNode; value: ReactNode;

View File

@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { deletePagadorShareAction } from "@/app/(dashboard)/pagadores/actions"; import { deletePagadorShareAction } from "@/app/(dashboard)/pagadores/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatDateTime } from "@/lib/utils/date";
interface PagadorLeaveShareCardProps { interface PagadorLeaveShareCardProps {
shareId: string; shareId: string;
@@ -37,11 +38,12 @@ export function PagadorLeaveShareCard({
}); });
}; };
const formattedDate = new Date(createdAt).toLocaleDateString("pt-BR", { const formattedDate =
day: "2-digit", formatDateTime(createdAt, {
month: "long", day: "2-digit",
year: "numeric", month: "long",
}); year: "numeric",
}) ?? "—";
return ( return (
<Card className="border"> <Card className="border">

View File

@@ -1,5 +1,5 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/shared/money-values";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { PagadorMonthlyBreakdown } from "@/lib/pagadores/details"; import type { PagadorMonthlyBreakdown } from "@/lib/pagadores/details";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";

View File

@@ -5,40 +5,17 @@ import {
RiWallet3Line, RiWallet3Line,
} from "@remixicon/react"; } from "@remixicon/react";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo"; import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/shared/money-values";
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { WidgetEmptyState } from "@/components/widget-empty-state"; import { buildBillStatusLabel } from "@/lib/dashboard/bills-helpers";
import type { import type {
PagadorBoletoItem, PagadorBoletoItem,
PagadorPaymentStatusData, PagadorPaymentStatusData,
} from "@/lib/pagadores/details"; } from "@/lib/pagadores/details";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
// --- Boleto helpers ---
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const buildDateLabel = (value: string | null, prefix?: string) => {
if (!value) return null;
const [year, month, day] = value.split("-").map((part) => Number(part));
if (!year || !month || !day) return null;
const formatted = DATE_FORMATTER.format(
new Date(Date.UTC(year, month - 1, day)),
);
return prefix ? `${prefix} ${formatted}` : formatted;
};
const buildStatusLabel = (item: PagadorBoletoItem) => {
if (item.isSettled) return buildDateLabel(item.boletoPaymentDate, "Pago em");
return buildDateLabel(item.dueDate, "Vence em");
};
// --- PagadorBoletoCard --- // --- PagadorBoletoCard ---
type PagadorBoletoCardProps = { type PagadorBoletoCardProps = {
@@ -62,7 +39,7 @@ export function PagadorBoletoCard({ items }: PagadorBoletoCardProps) {
<CardContent className="flex flex-col gap-4 px-0"> <CardContent className="flex flex-col gap-4 px-0">
<ul className="flex flex-col"> <ul className="flex flex-col">
{items.map((item) => { {items.map((item) => {
const statusLabel = buildStatusLabel(item); const statusLabel = buildBillStatusLabel(item);
return ( return (
<li <li
key={item.id} key={item.id}

View File

@@ -79,7 +79,7 @@ export function PagadorSharingCard({
return ( return (
<Card className="border"> <Card className="border">
<CardHeader> <CardHeader>
<CardTitle className="text-base font-semibold"> <CardTitle className="text-lg font-semibold">
Compartilhamentos Compartilhamentos
</CardTitle> </CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -0,0 +1,33 @@
export type PagadorInfo = {
id: string;
name: string;
email: string | null;
avatarUrl: string | null;
status: string;
note: string | null;
role: string | null;
isAutoSend: boolean;
createdAt: string;
lastMailAt: string | null;
shareCode: string | null;
canEdit: boolean;
};
export type PagadorSummaryPreview = {
periodLabel: string;
totalExpenses: number;
paymentSplits: {
card: number;
boleto: number;
instant: number;
};
cardUsage: { name: string; amount: number }[];
boletoStats: {
totalAmount: number;
paidAmount: number;
pendingAmount: number;
paidCount: number;
pendingCount: number;
};
lancamentoCount: number;
};

View File

@@ -9,7 +9,6 @@ import {
} from "@remixicon/react"; } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
@@ -23,11 +22,7 @@ interface PagadorCardProps {
} }
export function PagadorCard({ pagador, onEdit, onRemove }: PagadorCardProps) { export function PagadorCard({ pagador, onEdit, onRemove }: PagadorCardProps) {
const avatarSrc = useMemo( const avatarSrc = getAvatarSrc(pagador.avatarUrl);
() => getAvatarSrc(pagador.avatarUrl),
[pagador.avatarUrl],
);
const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN; const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN;
const isReadOnly = !pagador.canEdit; const isReadOnly = !pagador.canEdit;

View File

@@ -1,13 +1,13 @@
"use client"; "use client";
import DotIcon from "@/components/dot-icon"; import StatusDot from "@/components/shared/status-dot";
export function StatusSelectContent({ label }: { label: string }) { export function StatusSelectContent({ label }: { label: string }) {
const isActive = label === "Ativo"; const isActive = label === "Ativo";
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<DotIcon <StatusDot
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"} color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
/> />
<span>{label}</span> <span>{label}</span>

View File

@@ -2,15 +2,15 @@
import { RiAddCircleLine } from "@remixicon/react"; import { RiAddCircleLine } from "@remixicon/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deletePagadorAction, deletePagadorAction,
joinPagadorByShareCodeAction, joinPagadorByShareCodeAction,
} from "@/app/(dashboard)/pagadores/actions"; } from "@/app/(dashboard)/pagadores/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { PagadorCard } from "@/components/pagadores/pagador-card"; import { PagadorCard } from "@/components/pagadores/pagador-card";
import { PagadorDialog } from "@/components/pagadores/pagador-dialog"; import { PagadorDialog } from "@/components/pagadores/pagador-dialog";
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
@@ -49,35 +49,35 @@ export function PagadoresPage({
[pagadores], [pagadores],
); );
const handleEdit = useCallback((pagador: Pagador) => { const handleEdit = (pagador: Pagador) => {
setSelectedPagador(pagador); setSelectedPagador(pagador);
setEditOpen(true); setEditOpen(true);
}, []); };
const handleEditOpenChange = useCallback((open: boolean) => { const handleEditOpenChange = (open: boolean) => {
setEditOpen(open); setEditOpen(open);
if (!open) { if (!open) {
setSelectedPagador(null); setSelectedPagador(null);
} }
}, []); };
const handleRemoveRequest = useCallback((pagador: Pagador) => { const handleRemoveRequest = (pagador: Pagador) => {
if (pagador.role === PAGADOR_ROLE_ADMIN) { if (pagador.role === PAGADOR_ROLE_ADMIN) {
toast.error("Pagadores administradores não podem ser removidos."); toast.error("Pagadores administradores não podem ser removidos.");
return; return;
} }
setPagadorToRemove(pagador); setPagadorToRemove(pagador);
setRemoveOpen(true); setRemoveOpen(true);
}, []); };
const handleRemoveOpenChange = useCallback((open: boolean) => { const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open); setRemoveOpen(open);
if (!open) { if (!open) {
setPagadorToRemove(null); setPagadorToRemove(null);
} }
}, []); };
const handleRemoveConfirm = useCallback(async () => { const handleRemoveConfirm = async () => {
if (!pagadorToRemove) { if (!pagadorToRemove) {
return; return;
} }
@@ -91,37 +91,34 @@ export function PagadoresPage({
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
}, [pagadorToRemove]); };
const removeTitle = pagadorToRemove const removeTitle = pagadorToRemove
? `Remover pagador "${pagadorToRemove.name}"?` ? `Remover pagador "${pagadorToRemove.name}"?`
: "Remover pagador?"; : "Remover pagador?";
const handleJoinByCode = useCallback( const handleJoinByCode = (event: React.FormEvent<HTMLFormElement>) => {
(event: React.FormEvent<HTMLFormElement>) => { event.preventDefault();
event.preventDefault(); if (!shareCodeInput.trim()) {
if (!shareCodeInput.trim()) { toast.error("Informe um código válido.");
toast.error("Informe um código válido."); return;
}
startJoin(async () => {
const result = await joinPagadorByShareCodeAction({
code: shareCodeInput.trim(),
});
if (!result.success) {
toast.error(result.error);
return; return;
} }
startJoin(async () => { toast.success(result.message);
const result = await joinPagadorByShareCodeAction({ setShareCodeInput("");
code: shareCodeInput.trim(), router.refresh();
}); });
};
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(result.message);
setShareCodeInput("");
router.refresh();
});
},
[shareCodeInput, router],
);
return ( return (
<> <>

View File

@@ -10,8 +10,7 @@ import {
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import Image from "next/image"; import Image from "next/image";
import { useMemo } from "react"; import MoneyValues from "@/components/shared/money-values";
import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -28,6 +27,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { resolveLogoSrc } from "@/lib/logo";
import type { InboxItem } from "./types"; import type { InboxItem } from "./types";
interface InboxCardProps { interface InboxCardProps {
@@ -41,17 +41,6 @@ interface InboxCardProps {
onRestoreToPending?: (item: InboxItem) => void | Promise<void>; onRestoreToPending?: (item: InboxItem) => void | Promise<void>;
} }
function resolveLogoPath(logo: string): string {
if (
logo.startsWith("http") ||
logo.startsWith("data:") ||
logo.startsWith("/")
) {
return logo;
}
return `/logos/${logo}`;
}
function findMatchingLogo( function findMatchingLogo(
sourceAppName: string | null, sourceAppName: string | null,
appLogoMap: Record<string, string>, appLogoMap: Record<string, string>,
@@ -61,12 +50,12 @@ function findMatchingLogo(
const appName = sourceAppName.toLowerCase(); const appName = sourceAppName.toLowerCase();
// Exact match first // Exact match first
if (appLogoMap[appName]) return resolveLogoPath(appLogoMap[appName]); if (appLogoMap[appName]) return resolveLogoSrc(appLogoMap[appName]);
// Partial match: card/account name contains app name or vice versa // Partial match: card/account name contains app name or vice versa
for (const [name, logo] of Object.entries(appLogoMap)) { for (const [name, logo] of Object.entries(appLogoMap)) {
if (name.includes(appName) || appName.includes(name)) { if (name.includes(appName) || appName.includes(name)) {
return resolveLogoPath(logo); return resolveLogoSrc(logo);
} }
} }
@@ -83,11 +72,9 @@ export function InboxCard({
onDelete, onDelete,
onRestoreToPending, onRestoreToPending,
}: InboxCardProps) { }: InboxCardProps) {
const matchedLogo = useMemo( const matchedLogo = appLogoMap
() => ? findMatchingLogo(item.sourceAppName, appLogoMap)
appLogoMap ? findMatchingLogo(item.sourceAppName, appLogoMap) : null, : null;
[item.sourceAppName, appLogoMap],
);
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null; const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;

View File

@@ -2,7 +2,7 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/shared/money-values";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { RiAtLine, RiDeleteBinLine } from "@remixicon/react"; import { RiAtLine, RiDeleteBinLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
bulkDeleteInboxItemsAction, bulkDeleteInboxItemsAction,
@@ -10,8 +10,8 @@ import {
markInboxAsProcessedAction, markInboxAsProcessedAction,
restoreDiscardedInboxItemAction, restoreDiscardedInboxItemAction,
} from "@/app/(dashboard)/pre-lancamentos/actions"; } from "@/app/(dashboard)/pre-lancamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog"; import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
import { EmptyState } from "@/components/shared/empty-state"; import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -67,66 +67,71 @@ export function InboxPage({
"processed" | "discarded" "processed" | "discarded"
>("processed"); >("processed");
const sortByTimestamp = useCallback( const sortedPending = useMemo(
(list: InboxItem[]) => () =>
[...list].sort( [...pendingItems].sort(
(a, b) => (a, b) =>
new Date(b.notificationTimestamp).getTime() - new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(), new Date(a.notificationTimestamp).getTime(),
), ),
[], [pendingItems],
);
const sortedPending = useMemo(
() => sortByTimestamp(pendingItems),
[pendingItems, sortByTimestamp],
); );
const sortedProcessed = useMemo( const sortedProcessed = useMemo(
() => sortByTimestamp(processedItems), () =>
[processedItems, sortByTimestamp], [...processedItems].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[processedItems],
); );
const sortedDiscarded = useMemo( const sortedDiscarded = useMemo(
() => sortByTimestamp(discardedItems), () =>
[discardedItems, sortByTimestamp], [...discardedItems].sort(
(a, b) =>
new Date(b.notificationTimestamp).getTime() -
new Date(a.notificationTimestamp).getTime(),
),
[discardedItems],
); );
const handleProcessOpenChange = useCallback((open: boolean) => { const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open); setProcessOpen(open);
if (!open) { if (!open) {
setItemToProcess(null); setItemToProcess(null);
} }
}, []); };
const handleDetailsOpenChange = useCallback((open: boolean) => { const handleDetailsOpenChange = (open: boolean) => {
setDetailsOpen(open); setDetailsOpen(open);
if (!open) { if (!open) {
setItemDetails(null); setItemDetails(null);
} }
}, []); };
const handleDiscardOpenChange = useCallback((open: boolean) => { const handleDiscardOpenChange = (open: boolean) => {
setDiscardOpen(open); setDiscardOpen(open);
if (!open) { if (!open) {
setItemToDiscard(null); setItemToDiscard(null);
} }
}, []); };
const handleProcessRequest = useCallback((item: InboxItem) => { const handleProcessRequest = (item: InboxItem) => {
setItemToProcess(item); setItemToProcess(item);
setProcessOpen(true); setProcessOpen(true);
}, []); };
const handleDetailsRequest = useCallback((item: InboxItem) => { const handleDetailsRequest = (item: InboxItem) => {
setItemDetails(item); setItemDetails(item);
setDetailsOpen(true); setDetailsOpen(true);
}, []); };
const handleDiscardRequest = useCallback((item: InboxItem) => { const handleDiscardRequest = (item: InboxItem) => {
setItemToDiscard(item); setItemToDiscard(item);
setDiscardOpen(true); setDiscardOpen(true);
}, []); };
const handleDiscardConfirm = useCallback(async () => { const handleDiscardConfirm = async () => {
if (!itemToDiscard) return; if (!itemToDiscard) return;
const result = await discardInboxItemAction({ const result = await discardInboxItemAction({
@@ -140,21 +145,21 @@ export function InboxPage({
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
}, [itemToDiscard]); };
const handleDeleteOpenChange = useCallback((open: boolean) => { const handleDeleteOpenChange = (open: boolean) => {
setDeleteOpen(open); setDeleteOpen(open);
if (!open) { if (!open) {
setItemToDelete(null); setItemToDelete(null);
} }
}, []); };
const handleDeleteRequest = useCallback((item: InboxItem) => { const handleDeleteRequest = (item: InboxItem) => {
setItemToDelete(item); setItemToDelete(item);
setDeleteOpen(true); setDeleteOpen(true);
}, []); };
const handleDeleteConfirm = useCallback(async () => { const handleDeleteConfirm = async () => {
if (!itemToDelete) return; if (!itemToDelete) return;
const result = await deleteInboxItemAction({ const result = await deleteInboxItemAction({
@@ -168,21 +173,21 @@ export function InboxPage({
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
}, [itemToDelete]); };
const handleRestoreOpenChange = useCallback((open: boolean) => { const handleRestoreOpenChange = (open: boolean) => {
setRestoreOpen(open); setRestoreOpen(open);
if (!open) { if (!open) {
setItemToRestore(null); setItemToRestore(null);
} }
}, []); };
const handleRestoreRequest = useCallback((item: InboxItem) => { const handleRestoreRequest = (item: InboxItem) => {
setItemToRestore(item); setItemToRestore(item);
setRestoreOpen(true); setRestoreOpen(true);
}, []); };
const handleRestoreToPendingConfirm = useCallback(async () => { const handleRestoreToPendingConfirm = async () => {
if (!itemToRestore) return; if (!itemToRestore) return;
const result = await restoreDiscardedInboxItemAction({ const result = await restoreDiscardedInboxItemAction({
@@ -196,21 +201,18 @@ export function InboxPage({
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
}, [itemToRestore]); };
const handleBulkDeleteOpenChange = useCallback((open: boolean) => { const handleBulkDeleteOpenChange = (open: boolean) => {
setBulkDeleteOpen(open); setBulkDeleteOpen(open);
}, []); };
const handleBulkDeleteRequest = useCallback( const handleBulkDeleteRequest = (status: "processed" | "discarded") => {
(status: "processed" | "discarded") => { setBulkDeleteStatus(status);
setBulkDeleteStatus(status); setBulkDeleteOpen(true);
setBulkDeleteOpen(true); };
},
[],
);
const handleBulkDeleteConfirm = useCallback(async () => { const handleBulkDeleteConfirm = async () => {
const result = await bulkDeleteInboxItemsAction({ const result = await bulkDeleteInboxItemsAction({
status: bulkDeleteStatus, status: bulkDeleteStatus,
}); });
@@ -222,9 +224,9 @@ export function InboxPage({
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
}, [bulkDeleteStatus]); };
const handleLancamentoSuccess = useCallback(async () => { const handleLancamentoSuccess = async () => {
if (!itemToProcess) return; if (!itemToProcess) return;
const result = await markInboxAsProcessedAction({ const result = await markInboxAsProcessedAction({
@@ -236,7 +238,7 @@ export function InboxPage({
} else { } else {
toast.error(result.error); toast.error(result.error);
} }
}, [itemToProcess]); };
// Prepare default values from inbox item // Prepare default values from inbox item
const getDateString = ( const getDateString = (

32
lib/faturas/index.ts Normal file
View File

@@ -0,0 +1,32 @@
export const INVOICE_PAYMENT_STATUS = {
PENDING: "pendente",
PAID: "pago",
} as const;
export const INVOICE_STATUS_VALUES = Object.values(INVOICE_PAYMENT_STATUS);
export type InvoicePaymentStatus =
(typeof INVOICE_PAYMENT_STATUS)[keyof typeof INVOICE_PAYMENT_STATUS];
export const INVOICE_STATUS_LABEL: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "Em aberto",
[INVOICE_PAYMENT_STATUS.PAID]: "Pago",
};
export const INVOICE_STATUS_BADGE_VARIANT: Record<
InvoicePaymentStatus,
"default" | "secondary" | "success" | "info"
> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "info",
[INVOICE_PAYMENT_STATUS.PAID]: "success",
};
export const INVOICE_STATUS_DESCRIPTION: Record<InvoicePaymentStatus, string> =
{
[INVOICE_PAYMENT_STATUS.PENDING]:
"Esta fatura ainda não foi quitada. Você pode realizar o pagamento assim que revisar os lançamentos.",
[INVOICE_PAYMENT_STATUS.PAID]:
"Esta fatura está quitada. Caso tenha sido um engano, é possível desfazer o pagamento.",
};
export const PERIOD_FORMAT_REGEX = /^\d{4}-\d{2}$/;

View File

@@ -1,3 +1,5 @@
import { displayPeriod, periodToDate } from "@/lib/utils/period";
/** /**
* Calcula a data da última parcela baseado no período da parcela atual * Calcula a data da última parcela baseado no período da parcela atual
* @param currentPeriod - Período da parcela atual no formato YYYY-MM (ex: "2025-11") * @param currentPeriod - Período da parcela atual no formato YYYY-MM (ex: "2025-11")
@@ -10,18 +12,13 @@ export function calculateLastInstallmentDate(
currentInstallment: number, currentInstallment: number,
totalInstallments: number, totalInstallments: number,
): Date { ): Date {
// Parse do período atual (formato: "YYYY-MM") let currentDate: Date;
const [yearStr, monthStr] = currentPeriod.split("-"); try {
const year = Number.parseInt(yearStr ?? "", 10); currentDate = periodToDate(currentPeriod);
const monthIndex = Number.parseInt(monthStr ?? "", 10) - 1; // 0-indexed } catch {
if (Number.isNaN(year) || Number.isNaN(monthIndex)) {
return new Date(); return new Date();
} }
// Cria data do período atual (parcela atual)
const currentDate = new Date(year, monthIndex, 1);
// Calcula quantas parcelas faltam (incluindo a atual) // Calcula quantas parcelas faltam (incluindo a atual)
// Ex: parcela 2 de 6 -> restam 5 parcelas (2, 3, 4, 5, 6) // Ex: parcela 2 de 6 -> restam 5 parcelas (2, 3, 4, 5, 6)
const remainingInstallments = totalInstallments - currentInstallment + 1; const remainingInstallments = totalInstallments - currentInstallment + 1;
@@ -41,15 +38,9 @@ export function calculateLastInstallmentDate(
* Exemplo: "Março de 2026" * Exemplo: "Março de 2026"
*/ */
export function formatLastInstallmentDate(date: Date): string { export function formatLastInstallmentDate(date: Date): string {
const formatter = new Intl.DateTimeFormat("pt-BR", { return displayPeriod(
month: "long", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`,
year: "numeric", );
timeZone: "UTC",
});
const formatted = formatter.format(date);
// Capitaliza a primeira letra
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
} }
/** /**
@@ -71,6 +62,9 @@ export function formatPurchaseDate(date: Date): string {
* Formata o texto da parcela atual * Formata o texto da parcela atual
* Exemplo: "1 de 6" * Exemplo: "1 de 6"
*/ */
export function formatCurrentInstallment(current: number, total: number): string { export function formatCurrentInstallment(
current: number,
total: number,
): string {
return `${current} de ${total}`; return `${current} de ${total}`;
} }

View File

@@ -1,6 +1,14 @@
/** /**
* Formatting helpers for displaying lancamento data * Formatting helpers for displaying lancamento data
*/ */
import {
currencyFormatter,
formatCurrency as formatCurrencyValue,
} from "@/lib/utils/currency";
import { formatDateOnly } from "@/lib/utils/date";
import { formatMonthYearLabel } from "@/lib/utils/period";
export { currencyFormatter };
/** /**
* Capitalizes the first letter of a string * Capitalizes the first letter of a string
@@ -11,14 +19,6 @@ function capitalize(value: string): string {
: value; : value;
} }
/**
* Currency formatter for pt-BR locale (BRL)
*/
export const currencyFormatter = new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
});
/** /**
* Date formatter for pt-BR locale (dd/mm/yyyy) * Date formatter for pt-BR locale (dd/mm/yyyy)
*/ */
@@ -44,9 +44,13 @@ export const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
*/ */
export function formatDate(value?: string | null): string { export function formatDate(value?: string | null): string {
if (!value) return "—"; if (!value) return "—";
const date = new Date(value); return (
if (Number.isNaN(date.getTime())) return "—"; formatDateOnly(value, {
return dateFormatter.format(date); day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? "—"
);
} }
/** /**
@@ -57,10 +61,11 @@ export function formatDate(value?: string | null): string {
*/ */
export function formatPeriod(value?: string | null): string { export function formatPeriod(value?: string | null): string {
if (!value) return "—"; if (!value) return "—";
const [year, month] = value.split("-").map(Number); try {
if (!year || !month) return value; return formatMonthYearLabel(value);
const date = new Date(year, month - 1, 1); } catch {
return capitalize(monthFormatter.format(date)); return value;
}
} }
/** /**
@@ -97,5 +102,5 @@ export function getTransactionBadgeVariant(
* @example formatCurrency(1234.56) => "R$ 1.234,56" * @example formatCurrency(1234.56) => "R$ 1.234,56"
*/ */
export function formatCurrency(value: number): string { export function formatCurrency(value: number): string {
return currencyFormatter.format(value); return formatCurrencyValue(value);
} }

View File

@@ -19,6 +19,7 @@ import {
PAGADOR_ROLE_ADMIN, PAGADOR_ROLE_ADMIN,
PAGADOR_ROLE_TERCEIRO, PAGADOR_ROLE_TERCEIRO,
} from "@/lib/pagadores/constants"; } from "@/lib/pagadores/constants";
import { toDateOnlyString } from "@/lib/utils/date";
type PagadorRow = typeof pagadores.$inferSelect; type PagadorRow = typeof pagadores.$inferSelect;
type ContaRow = typeof contas.$inferSelect; type ContaRow = typeof contas.$inferSelect;
@@ -185,12 +186,10 @@ export const fetchLancamentoFilterSources = async (userId: string) => {
where: eq(pagadores.userId, userId), where: eq(pagadores.userId, userId),
}), }),
db.query.contas.findMany({ db.query.contas.findMany({
where: (contas, { eq, and }) => where: and(eq(contas.userId, userId), eq(contas.status, "Ativa")),
and(eq(contas.userId, userId), eq(contas.status, "Ativa")),
}), }),
db.query.cartoes.findMany({ db.query.cartoes.findMany({
where: (cartoes, { eq, and }) => where: and(eq(cartoes.userId, userId), eq(cartoes.status, "Ativo")),
and(eq(cartoes.userId, userId), eq(cartoes.status, "Ativo")),
}), }),
db.query.categorias.findMany({ db.query.categorias.findMany({
where: eq(categorias.userId, userId), where: eq(categorias.userId, userId),
@@ -405,7 +404,7 @@ export const mapLancamentosData = (rows: LancamentoRowWithRelations[]) =>
id: item.id, id: item.id,
userId: item.userId, userId: item.userId,
name: item.name, name: item.name,
purchaseDate: item.purchaseDate?.toISOString() ?? new Date().toISOString(), purchaseDate: toDateOnlyString(item.purchaseDate) ?? "",
period: item.period ?? "", period: item.period ?? "",
transactionType: item.transactionType, transactionType: item.transactionType,
amount: Number(item.amount ?? 0), amount: Number(item.amount ?? 0),

View File

@@ -14,6 +14,13 @@ import {
import { cartoes, lancamentos } from "@/db/schema"; import { cartoes, lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { toDateOnlyString } from "@/lib/utils/date";
import { safeToNumber as toNumber } from "@/lib/utils/number";
import {
addMonthsToPeriod,
buildPeriodRange,
formatCompactPeriodLabel,
} from "@/lib/utils/period";
const RECEITA = "Receita"; const RECEITA = "Receita";
const DESPESA = "Despesa"; const DESPESA = "Despesa";
@@ -65,76 +72,6 @@ export type PagadorPaymentStatusData = {
totalAmount: number; totalAmount: number;
}; };
const toISODate = (value: Date | string | null | undefined): string | null => {
if (!value) return null;
if (value instanceof Date) return value.toISOString().slice(0, 10);
return typeof value === "string" ? value : null;
};
const toNumber = (value: string | number | bigint | null) => {
if (typeof value === "number") {
return value;
}
if (typeof value === "bigint") {
return Number(value);
}
if (!value) {
return 0;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed;
};
const formatPeriod = (year: number, month: number) =>
`${year}-${String(month).padStart(2, "0")}`;
const normalizePeriod = (period: string) => {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
if (Number.isNaN(year) || Number.isNaN(month)) {
throw new Error(`Período inválido: ${period}`);
}
return { year, month };
};
const buildPeriodWindow = (period: string, months: number) => {
const { year, month } = normalizePeriod(period);
const items: string[] = [];
let currentYear = year;
let currentMonth = month;
for (let i = 0; i < months; i += 1) {
items.unshift(formatPeriod(currentYear, currentMonth));
currentMonth -= 1;
if (currentMonth < 1) {
currentMonth = 12;
currentYear -= 1;
}
}
return items;
};
const formatPeriodLabel = (period: string) => {
try {
const { year, month } = normalizePeriod(period);
const formatter = new Intl.DateTimeFormat("pt-BR", {
month: "short",
});
const date = new Date(year, month - 1, 1);
const rawLabel = formatter.format(date).replace(".", "");
const label =
rawLabel.length > 0
? rawLabel.charAt(0).toUpperCase().concat(rawLabel.slice(1))
: rawLabel;
const suffix = String(year).slice(-2);
return `${label}/${suffix}`;
} catch {
return period;
}
};
const excludeAutoInvoiceEntries = () => const excludeAutoInvoiceEntries = () =>
or( or(
isNull(lancamentos.note), isNull(lancamentos.note),
@@ -206,9 +143,10 @@ export async function fetchPagadorHistory({
period, period,
months = 6, months = 6,
}: BaseFilters & { months?: number }): Promise<PagadorHistoryPoint[]> { }: BaseFilters & { months?: number }): Promise<PagadorHistoryPoint[]> {
const window = buildPeriodWindow(period, months); const startPeriod = addMonthsToPeriod(period, -(Math.max(months, 1) - 1));
const start = window[0]; const windowPeriods = buildPeriodRange(startPeriod, period);
const end = window[window.length - 1]; const start = windowPeriods[0];
const end = windowPeriods[windowPeriods.length - 1];
const rows = await db const rows = await db
.select({ .select({
@@ -233,7 +171,7 @@ export async function fetchPagadorHistory({
{ receitas: number; despesas: number } { receitas: number; despesas: number }
>(); >();
for (const key of window) { for (const key of windowPeriods) {
totalsByPeriod.set(key, { receitas: 0, despesas: 0 }); totalsByPeriod.set(key, { receitas: 0, despesas: 0 });
} }
@@ -250,9 +188,9 @@ export async function fetchPagadorHistory({
} }
} }
return window.map((key) => ({ return windowPeriods.map((key) => ({
period: key, period: key,
label: formatPeriodLabel(key), label: formatCompactPeriodLabel(key),
receitas: totalsByPeriod.get(key)?.receitas ?? 0, receitas: totalsByPeriod.get(key)?.receitas ?? 0,
despesas: totalsByPeriod.get(key)?.despesas ?? 0, despesas: totalsByPeriod.get(key)?.despesas ?? 0,
})); }));
@@ -283,20 +221,22 @@ export async function fetchPagadorCardUsage({
) )
.groupBy(lancamentos.cartaoId, cartoes.name, cartoes.logo); .groupBy(lancamentos.cartaoId, cartoes.name, cartoes.logo);
return rows const items: PagadorCardUsageItem[] = [];
.filter((row) => Boolean(row.cartaoId))
.map((row) => { for (const row of rows) {
if (!row.cartaoId) { if (!row.cartaoId) {
throw new Error("cartaoId should not be null after filter"); continue;
} }
return {
id: row.cartaoId, items.push({
name: row.cardName ?? "Cartão", id: row.cartaoId,
logo: row.cardLogo ?? null, name: row.cardName ?? "Cartão",
amount: Math.abs(toNumber(row.totalAmount)), logo: row.cardLogo ?? null,
}; amount: Math.abs(toNumber(row.totalAmount)),
}) });
.sort((a, b) => b.amount - a.amount); }
return items.sort((a, b) => b.amount - a.amount);
} }
export async function fetchPagadorBoletoStats({ export async function fetchPagadorBoletoStats({
@@ -374,14 +314,20 @@ export async function fetchPagadorBoletoItems({
) )
.orderBy(asc(lancamentos.dueDate)); .orderBy(asc(lancamentos.dueDate));
return rows.map((row) => ({ const items: PagadorBoletoItem[] = [];
id: row.id,
name: row.name, for (const row of rows) {
amount: Math.abs(toNumber(row.amount)), items.push({
dueDate: toISODate(row.dueDate), id: row.id,
boletoPaymentDate: toISODate(row.boletoPaymentDate), name: row.name,
isSettled: Boolean(row.isSettled), amount: Math.abs(toNumber(row.amount)),
})); dueDate: toDateOnlyString(row.dueDate),
boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
isSettled: Boolean(row.isSettled),
});
}
return items;
} }
export async function fetchPagadorPaymentStatus({ export async function fetchPagadorPaymentStatus({

View File

@@ -3,6 +3,8 @@ import { Resend } from "resend";
import { pagadores } from "@/db/schema"; import { pagadores } from "@/db/schema";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { getResendFromEmail } from "@/lib/email/resend"; import { getResendFromEmail } from "@/lib/email/resend";
import { formatCurrency } from "@/lib/utils/currency";
import { formatDateTime } from "@/lib/utils/date";
type ActionType = "created" | "deleted"; type ActionType = "created" | "deleted";
@@ -24,20 +26,21 @@ export type PagadorNotificationRequest = {
entriesByPagador: Map<string, NotificationEntry[]>; entriesByPagador: Map<string, NotificationEntry[]>;
}; };
const formatCurrency = (value: number) => type PagadorNotificationRecipient = {
value.toLocaleString("pt-BR", { id: string;
style: "currency", name: string | null;
currency: "BRL", email: string | null;
maximumFractionDigits: 2, isAutoSend: boolean | null;
}); };
const formatDate = (value: Date | null) => { const formatDate = (value: Date | null) => {
if (!value) return "—"; return (
return value.toLocaleDateString("pt-BR", { formatDateTime(value, {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
year: "numeric", year: "numeric",
}); }) ?? "—"
);
}; };
const buildHtmlBody = ({ const buildHtmlBody = ({
@@ -133,9 +136,9 @@ export async function sendPagadorAutoEmails({
return; return;
} }
const pagadorRows = await db.query.pagadores.findMany({ const pagadorRows = (await db.query.pagadores.findMany({
where: inArray(pagadores.id, pagadorIds), where: inArray(pagadores.id, pagadorIds),
}); })) as PagadorNotificationRecipient[];
if (pagadorRows.length === 0) { if (pagadorRows.length === 0) {
return; return;
@@ -146,7 +149,7 @@ export async function sendPagadorAutoEmails({
action === "created" ? "Novo lançamento" : "Lançamento removido"; action === "created" ? "Novo lançamento" : "Lançamento removido";
const results = await Promise.allSettled( const results = await Promise.allSettled(
pagadorRows.map(async (pagador) => { pagadorRows.map(async (pagador: PagadorNotificationRecipient) => {
if (!pagador.email || !pagador.isAutoSend) { if (!pagador.email || !pagador.isAutoSend) {
return; return;
} }
@@ -172,7 +175,7 @@ export async function sendPagadorAutoEmails({
); );
// Log any failed email sends // Log any failed email sends
results.forEach((result, index) => { results.forEach((result: PromiseSettledResult<void>, index: number) => {
if (result.status === "rejected") { if (result.status === "rejected") {
const pagador = pagadorRows[index]; const pagador = pagadorRows[index];
console.error( console.error(