diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts b/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts index 9a1e870..73e85e6 100644 --- a/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts @@ -20,7 +20,10 @@ import { PERIOD_FORMAT_REGEX, } from "@/lib/faturas"; 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({ cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."), @@ -30,7 +33,12 @@ const updateInvoicePaymentStatusSchema = z.object({ status: z.enum( 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< @@ -157,7 +165,7 @@ export async function updateInvoicePaymentStatusAction( // Usar a data customizada ou a data atual como data de pagamento const invoiceDate = data.paymentDate ? parseLocalDateString(data.paymentDate) - : new Date(); + : getBusinessTodayDate(); const amount = `-${formatDecimal(adminShare)}`; const payload = { @@ -229,7 +237,11 @@ const updatePaymentDateSchema = z.object({ period: z .string({ message: "Período inválido." }) .regex(PERIOD_FORMAT_REGEX, "Período inválido."), - paymentDate: z.string({ message: "Data de pagamento inválida." }), + paymentDate: z + .string({ message: "Data de pagamento inválida." }) + .refine((value) => isValidPaymentDate(value), { + message: "Data de pagamento inválida.", + }), }); type UpdatePaymentDateInput = z.infer; diff --git a/app/(dashboard)/lancamentos/actions.ts b/app/(dashboard)/lancamentos/actions.ts index e28b30c..dfc2173 100644 --- a/app/(dashboard)/lancamentos/actions.ts +++ b/app/(dashboard)/lancamentos/actions.ts @@ -11,7 +11,6 @@ import { pagadores, } from "@/db/schema"; import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; -import type { ActionResult } from "@/lib/actions/types"; import { getUser } from "@/lib/auth/server"; import { INITIAL_BALANCE_CONDITION, @@ -30,8 +29,10 @@ import { sendPagadorAutoEmails, } from "@/lib/pagadores/notifications"; import { noteSchema, uuidSchema } from "@/lib/schemas/common"; +import type { ActionResult } from "@/lib/types/actions"; 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 @@ -108,11 +109,14 @@ const resolvePeriod = (purchaseDate: string, period?: string | null) => { return `${year}-${month}`; }; +const isValidDateInput = (value: string) => + !Number.isNaN(parseLocalDateString(value).getTime()); + const baseFields = z.object({ purchaseDate: z .string({ message: "Informe a data da transação." }) .trim() - .refine((value) => !Number.isNaN(new Date(value).getTime()), { + .refine((value) => isValidDateInput(value), { message: "Data da transação inválida.", }), period: z @@ -164,14 +168,14 @@ const baseFields = z.object({ dueDate: z .string() .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + .refine((value) => !value || isValidDateInput(value), { message: "Informe uma data de vencimento válida.", }) .optional(), boletoPaymentDate: z .string() .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + .refine((value) => !value || isValidDateInput(value), { message: "Informe uma data de pagamento válida.", }) .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 result = new Date(value); const originalDay = result.getDate(); @@ -648,7 +635,7 @@ export async function createLancamentoAction( const boletoPaymentDate = shouldSetBoletoPaymentDate ? data.boletoPaymentDate ? parseLocalDateString(data.boletoPaymentDate) - : getTodayDate() + : getBusinessTodayDate() : null; const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; @@ -828,7 +815,7 @@ export async function updateLancamentoAction( const boletoPaymentDateValue = shouldSetBoletoPaymentDate ? data.boletoPaymentDate ? parseLocalDateString(data.boletoPaymentDate) - : getTodayDate() + : getBusinessTodayDate() : null; await db @@ -982,7 +969,7 @@ export async function toggleLancamentoSettlementAction( const isBoleto = existing.paymentMethod === "Boleto"; const boletoPaymentDate = isBoleto ? data.value - ? getTodayDate() + ? getBusinessTodayDate() : null : null; @@ -1118,7 +1105,7 @@ const updateBulkSchema = z.object({ dueDate: z .string() .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + .refine((value) => !value || isValidDateInput(value), { message: "Informe uma data de vencimento válida.", }) .optional() @@ -1126,7 +1113,7 @@ const updateBulkSchema = z.object({ boletoPaymentDate: z .string() .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + .refine((value) => !value || isValidDateInput(value), { message: "Informe uma data de pagamento válida.", }) .optional() @@ -1284,7 +1271,7 @@ export async function updateLancamentoBulkAction( }); await applyUpdates( - futureLancamentos.map((item) => ({ + futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({ id: item.id, purchaseDate: item.purchaseDate ?? null, })), @@ -1311,7 +1298,7 @@ export async function updateLancamentoBulkAction( }); await applyUpdates( - allLancamentos.map((item) => ({ + allLancamentos.map((item: (typeof allLancamentos)[number]) => ({ id: item.id, purchaseDate: item.purchaseDate ?? null, })), @@ -1335,7 +1322,7 @@ const massAddTransactionSchema = z.object({ purchaseDate: z .string({ message: "Informe a data da transação." }) .trim() - .refine((value) => !Number.isNaN(new Date(value).getTime()), { + .refine((value) => isValidDateInput(value), { message: "Data da transação inválida.", }), name: z @@ -1598,12 +1585,12 @@ export async function deleteMultipleLancamentosAction( const notificationData = existing .filter( ( - item, + item: (typeof existing)[number], ): item is typeof item & { pagadorId: NonNullable; } => Boolean(item.pagadorId), ) - .map((item) => ({ + .map((item: (typeof existing)[number]) => ({ pagadorId: item.pagadorId, name: item.name ?? null, amount: item.amount ?? null, @@ -1662,11 +1649,11 @@ export async function getRecentEstablishmentsAction(): Promise { // Remove duplicates and filter empty names const uniqueNames = Array.from( - new Set( + new Set( results - .map((r) => r.name) + .map((r: (typeof results)[number]) => r.name) .filter( - (name): name is string => + (name: string | null): name is string => name != null && name.trim().length > 0 && !name.toLowerCase().startsWith("pagamento fatura"), diff --git a/app/(dashboard)/lancamentos/anticipation-actions.ts b/app/(dashboard)/lancamentos/anticipation-actions.ts index 8632f71..4f9d288 100644 --- a/app/(dashboard)/lancamentos/anticipation-actions.ts +++ b/app/(dashboard)/lancamentos/anticipation-actions.ts @@ -9,7 +9,7 @@ import { pagadores, } from "@/db/schema"; 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 { db } from "@/lib/db"; import { diff --git a/app/(dashboard)/orcamentos/actions.ts b/app/(dashboard)/orcamentos/actions.ts index 8031882..72c22f8 100644 --- a/app/(dashboard)/orcamentos/actions.ts +++ b/app/(dashboard)/orcamentos/actions.ts @@ -3,18 +3,16 @@ import { and, eq, ne } from "drizzle-orm"; import { z } from "zod"; import { categorias, orcamentos } from "@/db/schema"; -import { - type ActionResult, - handleActionError, - revalidateForEntity, -} from "@/lib/actions/helpers"; +import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; import { getUser } from "@/lib/auth/server"; import { db } from "@/lib/db"; import { periodSchema, uuidSchema } from "@/lib/schemas/common"; +import type { ActionResult } from "@/lib/types/actions"; import { formatDecimalForDbRequired, normalizeDecimalInput, } from "@/lib/utils/currency"; +import { getPreviousPeriod } from "@/lib/utils/period"; const budgetBaseSchema = z.object({ categoriaId: uuidSchema("Categoria"), @@ -43,9 +41,13 @@ const deleteBudgetSchema = z.object({ id: uuidSchema("Orçamento"), }); -type BudgetCreateInput = z.infer; -type BudgetUpdateInput = z.infer; -type BudgetDeleteInput = z.infer; +type BudgetCreateInput = z.input; +type BudgetUpdateInput = z.input; +type BudgetDeleteInput = z.input; +type BudgetCopyRow = { + categoriaId: string | null; + amount: unknown; +}; const ensureCategory = async (userId: string, categoriaId: string) => { const category = await db.query.categorias.findFirst({ @@ -193,7 +195,7 @@ const duplicatePreviousMonthSchema = z.object({ period: periodSchema, }); -type DuplicatePreviousMonthInput = z.infer; +type DuplicatePreviousMonthInput = z.input; export async function duplicatePreviousMonthBudgetsAction( input: DuplicatePreviousMonthInput, @@ -203,22 +205,15 @@ export async function duplicatePreviousMonthBudgetsAction( const data = duplicatePreviousMonthSchema.parse(input); // Calcular mês anterior - const [year, month] = data.period.split("-").map(Number); - 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}`; + const previousPeriod = getPreviousPeriod(data.period); // Buscar orçamentos do mês anterior - const previousBudgets = await db.query.orcamentos.findMany({ + const previousBudgets = (await db.query.orcamentos.findMany({ where: and( eq(orcamentos.userId, user.id), eq(orcamentos.period, previousPeriod), ), - }); + })) as BudgetCopyRow[]; if (previousBudgets.length === 0) { return { @@ -228,12 +223,12 @@ export async function duplicatePreviousMonthBudgetsAction( } // Buscar orçamentos existentes do mês atual - const currentBudgets = await db.query.orcamentos.findMany({ + const currentBudgets = (await db.query.orcamentos.findMany({ where: and( eq(orcamentos.userId, user.id), eq(orcamentos.period, data.period), ), - }); + })) as BudgetCopyRow[]; // Filtrar para evitar duplicatas const existingCategoryIds = new Set( diff --git a/app/(dashboard)/pagadores/[pagadorId]/actions.ts b/app/(dashboard)/pagadores/[pagadorId]/actions.ts index 5b7cadc..bf7e06e 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/actions.ts +++ b/app/(dashboard)/pagadores/[pagadorId]/actions.ts @@ -14,6 +14,8 @@ import { fetchPagadorHistory, fetchPagadorMonthlyBreakdown, } from "@/lib/pagadores/details"; +import { formatCurrency } from "@/lib/utils/currency"; +import { formatDateTime } from "@/lib/utils/date"; import { displayPeriod } from "@/lib/utils/period"; const inputSchema = z.object({ @@ -27,20 +29,14 @@ type ActionResult = | { success: true; message: 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) => { - if (!value) return "—"; - return value.toLocaleDateString("pt-BR", { - day: "2-digit", - month: "short", - year: "numeric", - }); + return ( + formatDateTime(value, { + day: "2-digit", + month: "short", + year: "numeric", + }) ?? "—" + ); }; // Escapa HTML para prevenir XSS @@ -515,25 +511,47 @@ export async function sendPagadorSummaryAction( .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", amount: Math.abs(Number(row.amount ?? 0)), dueDate: row.dueDate, })); - const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map( - (row) => ({ - id: row.id, - name: row.name, - paymentMethod: row.paymentMethod, - condition: row.condition, - transactionType: row.transactionType, - purchaseDate: row.purchaseDate, - amount: Number(row.amount ?? 0), - }), - ); + const normalizedLancamentos: LancamentoRow[] = ( + lancamentoRows as Array<{ + id: string; + name: string | null; + paymentMethod: string | null; + condition: string | null; + transactionType: string | null; + purchaseDate: Date | null; + 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 installmentCount = row.installmentCount ?? 1; const totalAmount = installmentAmount * installmentCount; diff --git a/app/(dashboard)/pagadores/[pagadorId]/loading.tsx b/app/(dashboard)/pagadores/[pagadorId]/loading.tsx index b20b108..098904e 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/loading.tsx +++ b/app/(dashboard)/pagadores/[pagadorId]/loading.tsx @@ -1,56 +1,47 @@ import { Skeleton } from "@/components/ui/skeleton"; /** - * Loading state para a página de detalhes do pagador - * Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos) + * Loading state para a página de detalhes do pagador. + * Layout: navegação mensal + tabs com card compartilhado do pagador. */ export default function PagadorDetailsLoading() { return (
- {/* Month Picker placeholder */}
- {/* Info do Pagador (sempre visível) */} -
-
- {/* Avatar */} - - -
- {/* Nome + Badge */} -
- - -
- - {/* Email */} - - - {/* Status */} -
- - -
-
- - {/* Botões de ação */} -
- - -
-
-
- - {/* Tabs */}
+ +
+ +
+
+ + +
+
+ + +
+ + + +
+ + +
+
+ +
+ + +
+
- {/* Conteúdo da aba Visão Geral (grid de cards) */}
- {/* Card de resumo mensal */}
@@ -63,7 +54,6 @@ export default function PagadorDetailsLoading() {
- {/* Outros cards */} {Array.from({ length: 4 }).map((_, i) => (
diff --git a/app/(dashboard)/pagadores/[pagadorId]/page.tsx b/app/(dashboard)/pagadores/[pagadorId]/page.tsx index 3f40f53..c685824 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/page.tsx +++ b/app/(dashboard)/pagadores/[pagadorId]/page.tsx @@ -6,6 +6,7 @@ import { import { notFound } from "next/navigation"; import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data"; 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 type { ContaCartaoFilterOption, @@ -15,6 +16,7 @@ import type { } from "@/components/lancamentos/types"; import MonthNavigation from "@/components/month-picker/month-navigation"; 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 { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card"; import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card"; @@ -25,7 +27,6 @@ import { } from "@/components/pagadores/details/pagador-payment-method-cards"; import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import WidgetCard from "@/components/widget-card"; import type { pagadores } from "@/db/schema"; import { getUserId } from "@/lib/auth/server"; import { @@ -50,6 +51,7 @@ import { fetchPagadorHistory, fetchPagadorMonthlyBreakdown, fetchPagadorPaymentStatus, + type PagadorCardUsageItem, } from "@/lib/pagadores/details"; import { parsePeriodParam } from "@/lib/utils/period"; import { @@ -232,6 +234,7 @@ export default async function Page({ params, searchParams }: PageProps) { label: pagador.name, slug: pagador.id, role: pagador.role, + avatarUrl: pagador.avatarUrl, }, ], categoriaFiltersRaw: [], @@ -284,7 +287,7 @@ export default async function Page({ params, searchParams }: PageProps) { periodLabel, totalExpenses: monthlyBreakdown.totalExpenses, paymentSplits: monthlyBreakdown.paymentSplits, - cardUsage: cardUsage.slice(0, 3).map((item) => ({ + cardUsage: cardUsage.slice(0, 3).map((item: PagadorCardUsageItem) => ({ name: item.name, amount: item.amount, })), @@ -308,15 +311,14 @@ export default async function Page({ params, searchParams }: PageProps) { Painel Lançamentos + -
- -
+ {canEdit && pagadorData.shareCode ? (
- } > - - + } > - - + } > - +
diff --git a/app/(dashboard)/pagadores/actions.ts b/app/(dashboard)/pagadores/actions.ts index d6ebc17..ec1b45b 100644 --- a/app/(dashboard)/pagadores/actions.ts +++ b/app/(dashboard)/pagadores/actions.ts @@ -6,7 +6,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { compartilhamentosPagador, pagadores, user } from "@/db/schema"; 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 { db } from "@/lib/db"; import { diff --git a/app/(dashboard)/pre-lancamentos/actions.ts b/app/(dashboard)/pre-lancamentos/actions.ts index 6ba2f4b..ac08f42 100644 --- a/app/(dashboard)/pre-lancamentos/actions.ts +++ b/app/(dashboard)/pre-lancamentos/actions.ts @@ -4,7 +4,7 @@ import { and, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { preLancamentos } from "@/db/schema"; 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 { db } from "@/lib/db"; diff --git a/components/cartoes/card-form-fields.tsx b/components/cartoes/card-form-fields.tsx index c8b7816..87d1482 100644 --- a/components/cartoes/card-form-fields.tsx +++ b/components/cartoes/card-form-fields.tsx @@ -20,7 +20,7 @@ import { DAYS_IN_MONTH, DEFAULT_CARD_BRANDS, DEFAULT_CARD_STATUS, -} from "./constants"; +} from "@/lib/cartoes/constants"; import type { CardFormValues } from "./types"; interface AccountOption { diff --git a/components/cartoes/card-item.tsx b/components/cartoes/card-item.tsx index b16173b..fcd9e4e 100644 --- a/components/cartoes/card-item.tsx +++ b/components/cartoes/card-item.tsx @@ -7,7 +7,7 @@ import { RiPencilLine, } from "@remixicon/react"; import Image from "next/image"; -import { useMemo } from "react"; +import MoneyValues from "@/components/shared/money-values"; import { Card, CardContent, @@ -20,8 +20,9 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { resolveCardBrandAsset } from "@/lib/cartoes/brand-assets"; +import { resolveLogoSrc } from "@/lib/logo"; import { cn } from "@/lib/utils/ui"; -import MoneyValues from "../money-values"; interface CardItemProps { name: string; @@ -40,26 +41,6 @@ interface CardItemProps { onRemove?: () => void; } -const BRAND_ASSETS: Record = { - 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 - ).find((entry) => normalized.includes(entry)); - - return match ? BRAND_ASSETS[match] : null; -}; - const formatDay = (value: string) => value.padStart(2, "0"); export function CardItem({ @@ -83,7 +64,7 @@ export function CardItem({ const limitTotal = limit ?? null; const used = limitInUse ?? - (limitTotal !== null && limitAvailable !== null + (limitTotal !== null && limitAvailable != null ? Math.max(limitTotal - limitAvailable, 0) : limitTotal !== null ? 0 @@ -100,62 +81,38 @@ export function CardItem({ ? Math.min(Math.max((used / limitTotal) * 100, 0), 100) : 0; - const logoPath = useMemo(() => { - if (!logo) { - return null; - } + const logoPath = resolveLogoSrc(logo); + const brandAsset = resolveCardBrandAsset(brand); + 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 ( - logo.startsWith("http://") || - logo.startsWith("https://") || - logo.startsWith("data:") - ) { - return logo; - } - - return logo.startsWith("/") ? logo : `/logos/${logo}`; - }, [logo]); - - const brandAsset = useMemo(() => resolveBrandAsset(brand), [brand]); - - const isInactive = useMemo( - () => status?.toLowerCase() === "inativo", - [status], - ); - - 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: , - onClick: onEdit, - className: "text-primary", - }, - { - label: "ver fatura", - icon: , - onClick: onInvoice, - className: "text-primary", - }, - { - label: "remover", - icon: , - onClick: onRemove, - className: "text-destructive", - }, - ], - [onEdit, onInvoice, onRemove], - ); + const actions = [ + { + label: "editar", + icon: , + onClick: onEdit, + className: "text-primary", + }, + { + label: "ver fatura", + icon: , + onClick: onInvoice, + className: "text-primary", + }, + { + label: "remover", + icon: , + onClick: onRemove, + className: "text-destructive", + }, + ]; return ( diff --git a/components/cartoes/card-select-items.tsx b/components/cartoes/card-select-items.tsx index e512a38..d925c04 100644 --- a/components/cartoes/card-select-items.tsx +++ b/components/cartoes/card-select-items.tsx @@ -2,35 +2,17 @@ import { RiBankLine } from "@remixicon/react"; 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 = { label: string; 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 = { - Visa: "visa.png", - Mastercard: "mastercard.png", - Elo: "elo.png", - }; - - return brandMap[brand] ?? null; -}; - export function BrandSelectContent({ label }: { label: string }) { - const brandLogo = getBrandLogo(label); - const logoSrc = brandLogo ? `/logos/${brandLogo}` : null; + const logoSrc = resolveCardBrandLogoSrc(label); return ( @@ -55,7 +37,7 @@ export function StatusSelectContent({ label }: { label: string }) { return ( - {label} diff --git a/components/cartoes/cards-page.tsx b/components/cartoes/cards-page.tsx index aeef05e..7bd7dc1 100644 --- a/components/cartoes/cards-page.tsx +++ b/components/cartoes/cards-page.tsx @@ -2,25 +2,27 @@ import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react"; import { useRouter } from "next/navigation"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; 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 { 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 { CardDialog } from "./card-dialog"; import { CardItem } from "./card-item"; +import type { Card as CreditCard } from "./types"; type AccountOption = { id: string; name: string; + logo: string | null; }; interface CardsPageProps { - cards: Card[]; - archivedCards: Card[]; + cards: CreditCard[]; + archivedCards: CreditCard[]; accounts: AccountOption[]; logoOptions: string[]; } @@ -34,56 +36,54 @@ export function CardsPage({ const router = useRouter(); const [activeTab, setActiveTab] = useState("ativos"); const [editOpen, setEditOpen] = useState(false); - const [selectedCard, setSelectedCard] = useState(null); + const [selectedCard, setSelectedCard] = useState(null); const [removeOpen, setRemoveOpen] = useState(false); - const [cardToRemove, setCardToRemove] = useState(null); + const [cardToRemove, setCardToRemove] = useState(null); - const sortCards = useCallback( - (list: Card[]) => - [...list].sort((a, b) => + const orderedCards = useMemo( + () => + [...cards].sort((a, b) => a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }), ), - [], + [cards], ); - - const orderedCards = useMemo(() => sortCards(cards), [cards, sortCards]); 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); setEditOpen(true); - }, []); + }; - const handleEditOpenChange = useCallback((open: boolean) => { + const handleEditOpenChange = (open: boolean) => { setEditOpen(open); if (!open) { setSelectedCard(null); } - }, []); + }; - const handleRemoveRequest = useCallback((card: Card) => { + const handleRemoveRequest = (card: CreditCard) => { setCardToRemove(card); setRemoveOpen(true); - }, []); + }; - const handleInvoice = useCallback( - (card: Card) => { - router.push(`/cartoes/${card.id}/fatura`); - }, - [router], - ); + const handleInvoice = (card: CreditCard) => { + router.push(`/cartoes/${card.id}/fatura`); + }; - const handleRemoveOpenChange = useCallback((open: boolean) => { + const handleRemoveOpenChange = (open: boolean) => { setRemoveOpen(open); if (!open) { setCardToRemove(null); } - }, []); + }; - const handleRemoveConfirm = useCallback(async () => { + const handleRemoveConfirm = async () => { if (!cardToRemove) { return; } @@ -97,16 +97,16 @@ export function CardsPage({ toast.error(result.error); throw new Error(result.error); - }, [cardToRemove]); + }; const removeTitle = cardToRemove ? `Remover cartão "${cardToRemove.name}"?` : "Remover cartão?"; - const renderCardList = (list: Card[], isArchived: boolean) => { + const renderCardList = (list: CreditCard[], isArchived: boolean) => { if (list.length === 0) { return ( - + } title={ @@ -120,7 +120,7 @@ export function CardsPage({ : "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle." } /> - + ); } diff --git a/components/cartoes/constants.ts b/components/cartoes/constants.ts deleted file mode 100644 index 4e7cd86..0000000 --- a/components/cartoes/constants.ts +++ /dev/null @@ -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"), -); diff --git a/components/categorias/categories-page.tsx b/components/categorias/categories-page.tsx index 1b7ed3e..9d4452a 100644 --- a/components/categorias/categories-page.tsx +++ b/components/categorias/categories-page.tsx @@ -7,10 +7,10 @@ import { RiPencilLine, } from "@remixicon/react"; import Link from "next/link"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; 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 { Card, CardContent } from "@/components/ui/card"; import { @@ -69,31 +69,31 @@ export function CategoriesPage({ categories }: CategoriesPageProps) { return base; }, [categories]); - const handleEdit = useCallback((category: Category) => { + const handleEdit = (category: Category) => { setSelectedCategory(category); setEditOpen(true); - }, []); + }; - const handleEditOpenChange = useCallback((open: boolean) => { + const handleEditOpenChange = (open: boolean) => { setEditOpen(open); if (!open) { setSelectedCategory(null); } - }, []); + }; - const handleRemoveRequest = useCallback((category: Category) => { + const handleRemoveRequest = (category: Category) => { setCategoryToRemove(category); setRemoveOpen(true); - }, []); + }; - const handleRemoveOpenChange = useCallback((open: boolean) => { + const handleRemoveOpenChange = (open: boolean) => { setRemoveOpen(open); if (!open) { setCategoryToRemove(null); } - }, []); + }; - const handleRemoveConfirm = useCallback(async () => { + const handleRemoveConfirm = async () => { if (!categoryToRemove) { return; } @@ -107,7 +107,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) { toast.error(result.error); throw new Error(result.error); - }, [categoryToRemove]); + }; const removeTitle = categoryToRemove ? `Remover categoria "${categoryToRemove.name}"?` diff --git a/components/categorias/category-detail-header.tsx b/components/categorias/category-detail-header.tsx index 5fce78d..6c9f37f 100644 --- a/components/categorias/category-detail-header.tsx +++ b/components/categorias/category-detail-header.tsx @@ -1,8 +1,9 @@ import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react"; +import { TypeBadge } from "@/components/shared/type-badge"; import type { CategoryType } from "@/lib/categorias/constants"; import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers"; +import { formatPercentage } from "@/lib/utils/percentage"; import { cn } from "@/lib/utils/ui"; -import { TypeBadge } from "../type-badge"; import { Card } from "../ui/card"; import { CategoryIconBadge } from "./category-icon-badge"; @@ -61,9 +62,12 @@ export function CategoryDetailHeader({ const variationLabel = typeof percentageChange === "number" - ? `${percentageChange > 0 ? "+" : ""}${Math.abs(percentageChange).toFixed( - 1, - )}%` + ? formatPercentage(percentageChange, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + absolute: true, + signDisplay: percentageChange === 0 ? "auto" : "always", + }) : "—"; return ( diff --git a/components/categorias/category-select-items.tsx b/components/categorias/category-select-items.tsx index affc164..5c2d508 100644 --- a/components/categorias/category-select-items.tsx +++ b/components/categorias/category-select-items.tsx @@ -1,13 +1,13 @@ "use client"; -import DotIcon from "@/components/dot-icon"; +import StatusDot from "@/components/shared/status-dot"; export function TypeSelectContent({ label }: { label: string }) { const isReceita = label === "Receita"; return ( - + {label} ); diff --git a/components/categorias/types.ts b/components/categorias/types.ts index efc2565..671dd4d 100644 --- a/components/categorias/types.ts +++ b/components/categorias/types.ts @@ -1,3 +1,5 @@ +import type { CategoryType } from "@/lib/categorias/constants"; + export type { CategoryType } from "@/lib/categorias/constants"; export { CATEGORY_TYPE_LABEL, diff --git a/components/contas/account-card.tsx b/components/contas/account-card.tsx index 97375a3..fe32cc1 100644 --- a/components/contas/account-card.tsx +++ b/components/contas/account-card.tsx @@ -8,7 +8,7 @@ import { } from "@remixicon/react"; import type React from "react"; 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 { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; diff --git a/components/contas/account-select-items.tsx b/components/contas/account-select-items.tsx index 2237493..114c55f 100644 --- a/components/contas/account-select-items.tsx +++ b/components/contas/account-select-items.tsx @@ -1,13 +1,13 @@ "use client"; -import DotIcon from "@/components/dot-icon"; +import StatusDot from "@/components/shared/status-dot"; export function StatusSelectContent({ label }: { label: string }) { const isActive = label === "Ativa"; return ( - {label} diff --git a/components/contas/account-statement-card.tsx b/components/contas/account-statement-card.tsx index 496884b..b405361 100644 --- a/components/contas/account-statement-card.tsx +++ b/components/contas/account-statement-card.tsx @@ -1,8 +1,8 @@ "use client"; import { RiInformationLine } from "@remixicon/react"; import Image from "next/image"; -import { type ReactNode, useMemo } from "react"; -import MoneyValues from "@/components/money-values"; +import type { ReactNode } from "react"; +import MoneyValues from "@/components/shared/money-values"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -10,6 +10,8 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { resolveLogoSrc } from "@/lib/logo"; +import { formatCurrency } from "@/lib/utils/currency"; import { cn } from "@/lib/utils/ui"; type DetailValue = string | number | ReactNode; @@ -27,22 +29,9 @@ type AccountStatementCardProps = { 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 = ( status: string, -): "success" | "secondary" => { +): "success" | "outline" => { const normalizedStatus = status.toLowerCase(); if (normalizedStatus === "ativa") { return "success"; @@ -62,13 +51,7 @@ export function AccountStatementCard({ logo, actions, }: AccountStatementCardProps) { - const logoPath = useMemo(() => resolveLogoPath(logo), [logo]); - - const formatCurrency = (value: number) => - value.toLocaleString("pt-BR", { - style: "currency", - currency: "BRL", - }); + const logoPath = resolveLogoSrc(logo); return ( diff --git a/components/contas/accounts-page.tsx b/components/contas/accounts-page.tsx index 1b6f34b..33bb90b 100644 --- a/components/contas/accounts-page.tsx +++ b/components/contas/accounts-page.tsx @@ -6,11 +6,12 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; import { deleteAccountAction } from "@/app/(dashboard)/contas/actions"; -import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { AccountCard } from "@/components/contas/account-card"; +import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog"; import { EmptyState } from "@/components/shared/empty-state"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { resolveLogoSrc } from "@/lib/logo"; import { getCurrentPeriod } from "@/lib/utils/period"; import { Card } from "../ui/card"; import { AccountDialog } from "./account-dialog"; @@ -23,15 +24,6 @@ interface AccountsPageProps { 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({ accounts, archivedAccounts, @@ -135,7 +127,7 @@ export function AccountsPage({ return (
{list.map((account) => { - const logoSrc = resolveLogoSrc(account.logo); + const logoSrc = resolveLogoSrc(account.logo) ?? undefined; return ( = { - 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 - ).find((entry) => normalized.includes(entry)); - - return match ? BRAND_ASSETS[match] : null; -}; - const actionLabelByStatus: Record = { [INVOICE_PAYMENT_STATUS.PENDING]: "Marcar como paga", [INVOICE_PAYMENT_STATUS.PAID]: "Desfazer pagamento", @@ -76,19 +59,6 @@ const actionVariantByStatus: Record< 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) => { if (!status) return "bg-gray-400"; const normalizedStatus = status.toLowerCase(); @@ -122,26 +92,13 @@ export function InvoiceSummaryCard({ // Atualizar estado quando initialPaymentDate mudar useEffect(() => { - if (initialPaymentDate) { - setPaymentDate(initialPaymentDate); - } + setPaymentDate(initialPaymentDate ?? new Date()); }, [initialPaymentDate]); - const logoPath = useMemo(() => resolveLogoPath(logo), [logo]); - - const brandAsset = useMemo( - () => (cardBrand ? resolveBrandAsset(cardBrand) : null), - [cardBrand], - ); - - const limitLabel = useMemo(() => { - if (typeof limitAmount !== "number") return "—"; - return limitAmount.toLocaleString("pt-BR", { - style: "currency", - currency: "BRL", - maximumFractionDigits: 2, - }); - }, [limitAmount]); + const logoPath = resolveLogoSrc(logo); + const brandAsset = resolveCardBrandAsset(cardBrand); + const limitLabel = + typeof limitAmount === "number" ? formatCurrency(limitAmount) : "—"; const targetStatus = invoiceStatus === INVOICE_PAYMENT_STATUS.PAID @@ -286,7 +243,7 @@ export function InvoiceSummaryCard({ value={ cardStatus ? (
- + {cardStatus}
) : ( diff --git a/components/lancamentos/dialogs/anticipate-installments-dialog/installment-selection-table.tsx b/components/lancamentos/dialogs/anticipate-installments-dialog/installment-selection-table.tsx index 0dbc933..f1437fb 100644 --- a/components/lancamentos/dialogs/anticipate-installments-dialog/installment-selection-table.tsx +++ b/components/lancamentos/dialogs/anticipate-installments-dialog/installment-selection-table.tsx @@ -2,7 +2,7 @@ import { format } from "date-fns"; 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 { Checkbox } from "@/components/ui/checkbox"; import { @@ -15,6 +15,7 @@ import { } from "@/components/ui/table"; import type { EligibleInstallment } from "@/lib/installments/anticipation-types"; import { formatCurrentInstallment } from "@/lib/installments/utils"; +import { formatShortPeriodLabel } from "@/lib/utils/period"; import { cn } from "@/lib/utils/ui"; 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) => { if (!date) return "—"; return format(date, "dd/MM/yyyy", { locale: ptBR }); @@ -116,7 +111,7 @@ export function InstallmentSelectionTable({ - {formatPeriod(inst.period)} + {formatShortPeriodLabel(inst.period)} {formatDate(inst.dueDate)} diff --git a/components/lancamentos/dialogs/bulk-import-dialog.tsx b/components/lancamentos/dialogs/bulk-import-dialog.tsx index 144cfb3..ec6eda6 100644 --- a/components/lancamentos/dialogs/bulk-import-dialog.tsx +++ b/components/lancamentos/dialogs/bulk-import-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { createLancamentoAction } from "@/app/(dashboard)/lancamentos/actions"; import { Button } from "@/components/ui/button"; @@ -58,20 +58,18 @@ export function BulkImportDialog({ const [contaId, setContaId] = useState(undefined); const [cartaoId, setCartaoId] = useState(undefined); const [isPending, startTransition] = useTransition(); + type CreateLancamentoInput = Parameters[0]; // Reset form when dialog opens/closes - const handleOpenChange = useCallback( - (newOpen: boolean) => { - if (!newOpen) { - setPagadorId(defaultPagadorId ?? undefined); - setCategoriaId(undefined); - setContaId(undefined); - setCartaoId(undefined); - } - onOpenChange(newOpen); - }, - [onOpenChange, defaultPagadorId], - ); + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + setPagadorId(defaultPagadorId ?? undefined); + setCategoriaId(undefined); + setContaId(undefined); + setCartaoId(undefined); + } + onOpenChange(newOpen); + }; const categoriaGroups = useMemo(() => { // Get unique transaction types from items @@ -88,111 +86,100 @@ export function BulkImportDialog({ return groupAndSortCategorias(filtered); }, [categoriaOptions, items]); - const handleSubmit = useCallback( - async (event: React.FormEvent) => { - event.preventDefault(); + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); - if (!pagadorId) { - toast.error("Selecione o pagador."); - return; - } + if (!pagadorId) { + toast.error("Selecione o pagador."); + return; + } - if (!categoriaId) { - toast.error("Selecione a categoria."); - return; - } + if (!categoriaId) { + toast.error("Selecione a categoria."); + return; + } - startTransition(async () => { - let successCount = 0; - let errorCount = 0; + startTransition(async () => { + let successCount = 0; + let errorCount = 0; - for (const item of items) { - const sanitizedAmount = Math.abs(item.amount); + for (const item of items) { + const sanitizedAmount = Math.abs(item.amount); - // Determine payment method based on original item - const isCredit = item.paymentMethod === "Cartão de crédito"; + // Determine payment method based on original item + const isCredit = item.paymentMethod === "Cartão de crédito"; - // Validate payment method fields - if (isCredit && !cartaoId) { - toast.error("Selecione um cartão de crédito."); - 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); - } + // Validate payment method fields + if (isCredit && !cartaoId) { + toast.error("Selecione um cartão de crédito."); + return; } - 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.`, - ); + if (!isCredit && !contaId) { + toast.error("Selecione uma conta."); + return; + } + + const payload: CreateLancamentoInput = { + purchaseDate: item.purchaseDate, + period: item.period, + name: item.name, + transactionType: + item.transactionType as CreateLancamentoInput["transactionType"], + 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 { - 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 hasCredit = items.some( diff --git a/components/lancamentos/dialogs/lancamento-dialog/condition-section.tsx b/components/lancamentos/dialogs/lancamento-dialog/condition-section.tsx index e2a9022..d60c7a7 100644 --- a/components/lancamentos/dialogs/lancamento-dialog/condition-section.tsx +++ b/components/lancamentos/dialogs/lancamento-dialog/condition-section.tsx @@ -1,6 +1,5 @@ "use client"; -import { useCallback, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Select, @@ -10,73 +9,48 @@ import { SelectValue, } from "@/components/ui/select"; import { LANCAMENTO_CONDITIONS } from "@/lib/lancamentos/constants"; +import { formatCurrency } from "@/lib/utils/currency"; import { cn } from "@/lib/utils/ui"; import { ConditionSelectContent } from "../../select-items"; import type { ConditionSectionProps } from "./lancamento-dialog-types"; -function formatCurrency(value: number): string { - return value.toLocaleString("pt-BR", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); -} - export function ConditionSection({ formState, onFieldChange, showInstallments, showRecurrence, }: ConditionSectionProps) { - const amount = useMemo(() => { - const value = Number(formState.amount); - return Number.isNaN(value) || value <= 0 ? null : value; - }, [formState.amount]); + const parsedAmount = Number(formState.amount); + const amount = + Number.isNaN(parsedAmount) || parsedAmount <= 0 ? null : parsedAmount; - const getInstallmentLabel = useCallback( - (count: number) => { - if (amount) { - const installmentValue = amount / count; - return `${count}x de R$ ${formatCurrency(installmentValue)}`; - } - return `${count}x`; - }, - [amount], - ); + const getInstallmentLabel = (count: number) => { + if (amount) { + const installmentValue = amount / count; + return `${count}x de R$ ${formatCurrency(installmentValue)}`; + } - const _getRecurrenceLabel = (count: number) => { - return `${count} meses`; + return `${count}x`; }; - const installmentSummary = useMemo(() => { - if (!showInstallments || !formState.installmentCount || !amount) { - return null; - } + const installmentCount = Number(formState.installmentCount); + const installmentSummary = + showInstallments && + formState.installmentCount && + amount && + !Number.isNaN(installmentCount) && + installmentCount > 0 + ? getInstallmentLabel(installmentCount) + : null; - const count = Number(formState.installmentCount); - if (Number.isNaN(count) || count <= 0) { - return null; - } - - return getInstallmentLabel(count); - }, [ - showInstallments, - 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]); + const recurrenceCount = Number(formState.recurrenceCount); + const recurrenceSummary = + showRecurrence && + formState.recurrenceCount && + !Number.isNaN(recurrenceCount) && + recurrenceCount > 0 + ? `Por ${recurrenceCount} meses` + : null; return (
diff --git a/components/lancamentos/dialogs/lancamento-dialog/pagador-section.tsx b/components/lancamentos/dialogs/lancamento-dialog/pagador-section.tsx index 7de52e3..d9405fc 100644 --- a/components/lancamentos/dialogs/lancamento-dialog/pagador-section.tsx +++ b/components/lancamentos/dialogs/lancamento-dialog/pagador-section.tsx @@ -1,6 +1,5 @@ "use client"; -import { useCallback } from "react"; import { CurrencyInput } from "@/components/ui/currency-input"; import { Label } from "@/components/ui/label"; import { @@ -20,25 +19,19 @@ export function PagadorSection({ secondaryPagadorOptions, totalAmount, }: PagadorSectionProps) { - const handlePrimaryAmountChange = useCallback( - (value: string) => { - onFieldChange("primarySplitAmount", value); - const numericValue = Number.parseFloat(value) || 0; - const remaining = Math.max(0, totalAmount - numericValue); - onFieldChange("secondarySplitAmount", remaining.toFixed(2)); - }, - [totalAmount, onFieldChange], - ); + const handlePrimaryAmountChange = (value: string) => { + onFieldChange("primarySplitAmount", value); + const numericValue = Number.parseFloat(value) || 0; + const remaining = Math.max(0, totalAmount - numericValue); + onFieldChange("secondarySplitAmount", remaining.toFixed(2)); + }; - const handleSecondaryAmountChange = useCallback( - (value: string) => { - onFieldChange("secondarySplitAmount", value); - const numericValue = Number.parseFloat(value) || 0; - const remaining = Math.max(0, totalAmount - numericValue); - onFieldChange("primarySplitAmount", remaining.toFixed(2)); - }, - [totalAmount, onFieldChange], - ); + const handleSecondaryAmountChange = (value: string) => { + onFieldChange("secondarySplitAmount", value); + const numericValue = Number.parseFloat(value) || 0; + const remaining = Math.max(0, totalAmount - numericValue); + onFieldChange("primarySplitAmount", remaining.toFixed(2)); + }; return (
diff --git a/components/lancamentos/dialogs/lancamento-dialog/payment-method-section.tsx b/components/lancamentos/dialogs/lancamento-dialog/payment-method-section.tsx index e7af072..005046b 100644 --- a/components/lancamentos/dialogs/lancamento-dialog/payment-method-section.tsx +++ b/components/lancamentos/dialogs/lancamento-dialog/payment-method-section.tsx @@ -16,7 +16,7 @@ import { SelectValue, } from "@/components/ui/select"; 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 { ContaCartaoSelectContent, @@ -24,17 +24,6 @@ import { } from "../../select-items"; 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({ period, onPeriodChange, diff --git a/components/lancamentos/dialogs/mass-add-dialog.tsx b/components/lancamentos/dialogs/mass-add-dialog.tsx index 7b922ce..17bebb0 100644 --- a/components/lancamentos/dialogs/mass-add-dialog.tsx +++ b/components/lancamentos/dialogs/mass-add-dialog.tsx @@ -33,9 +33,12 @@ import { import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; 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 { displayPeriod } from "@/lib/utils/period"; +import { dateToPeriod, displayPeriod, periodToDate } from "@/lib/utils/period"; import { CategoriaSelectContent, ContaCartaoSelectContent, @@ -50,17 +53,8 @@ import type { SelectOption } from "../types"; const MASS_ADD_PAYMENT_METHODS = LANCAMENTO_PAYMENT_METHODS.filter( (m) => m !== "Boleto", ); - -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}`; -} +type MassAddTransactionType = (typeof LANCAMENTO_TRANSACTION_TYPES)[number]; +type MassAddPaymentMethod = (typeof LANCAMENTO_PAYMENT_METHODS)[number]; function InlinePeriodPicker({ period, @@ -111,23 +105,9 @@ interface MassAddDialogProps { defaultCartaoId?: string | null; } -export interface MassAddFormData { - fixedFields: { - transactionType?: string; - paymentMethod?: string; - condition?: string; - period?: string; - contaId?: string; - cartaoId?: string; - }; - transactions: Array<{ - purchaseDate: string; - name: string; - amount: string; - categoriaId?: string; - pagadorId?: string; - }>; -} +export type MassAddFormData = Parameters< + typeof import("@/app/(dashboard)/lancamentos/actions").createMassLancamentosAction +>[0]; interface TransactionRow { id: string; @@ -154,8 +134,9 @@ export function MassAddDialog({ const [loading, setLoading] = useState(false); // Fixed fields state (sempre ativos, sem checkboxes) - const [transactionType, setTransactionType] = useState("Despesa"); - const [paymentMethod, setPaymentMethod] = useState( + const [transactionType, setTransactionType] = + useState("Despesa"); + const [paymentMethod, setPaymentMethod] = useState( LANCAMENTO_PAYMENT_METHODS[0], ); const [period, setPeriod] = useState(selectedPeriod); @@ -257,7 +238,7 @@ export function MassAddDialog({ transactions: transactions.map((t) => ({ purchaseDate: t.purchaseDate, name: t.name.trim(), - amount: t.amount.trim(), + amount: Number(t.amount.trim()), categoriaId: t.categoriaId, pagadorId: t.pagadorId, })), @@ -312,7 +293,9 @@ export function MassAddDialog({ { - setPaymentMethod(value); + setPaymentMethod(value as MassAddPaymentMethod); // Reset conta/cartao when changing payment method if (value === "Cartão de crédito") { setContaId(undefined); diff --git a/components/lancamentos/lancamentos-export.tsx b/components/lancamentos/lancamentos-export.tsx index 298aef9..f23071c 100644 --- a/components/lancamentos/lancamentos-export.tsx +++ b/components/lancamentos/lancamentos-export.tsx @@ -19,10 +19,12 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { formatCurrency } from "@/lib/lancamentos/formatting-helpers"; +import { formatDateOnly, formatDateTime } from "@/lib/utils/date"; import { getPrimaryPdfColor, loadExportLogoDataUrl, } from "@/lib/utils/export-branding"; +import { displayPeriod } from "@/lib/utils/period"; import type { LancamentoItem } from "./types"; interface LancamentosExportProps { @@ -41,12 +43,13 @@ export function LancamentosExport({ }; const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString("pt-BR", { - day: "2-digit", - month: "2-digit", - year: "numeric", - }); + return ( + formatDateOnly(dateString, { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) ?? dateString + ); }; const getContaCartaoName = (lancamento: LancamentoItem) => { @@ -190,8 +193,8 @@ export function LancamentosExport({ const doc = new jsPDF({ orientation: "landscape" }); const primaryColor = getPrimaryPdfColor(); const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([ - loadExportLogoDataUrl("/logo_small.png"), - loadExportLogoDataUrl("/logo_text.png"), + loadExportLogoDataUrl("/imagens/logo_small.png"), + loadExportLogoDataUrl("/imagens/logo_text.png"), ]); let brandingEndX = 14; @@ -212,28 +215,15 @@ export function LancamentosExport({ doc.text("Lançamentos", titleX, 15); doc.setFontSize(10); - const periodParts = period.split("-"); - 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(`Período: ${displayPeriod(period)}`, titleX, 22); doc.text( - `Gerado em: ${new Date().toLocaleDateString("pt-BR")}`, + `Gerado em: ${ + formatDateTime(new Date(), { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) ?? "—" + }`, titleX, 27, ); diff --git a/components/lancamentos/page/lancamentos-page.tsx b/components/lancamentos/page/lancamentos-page.tsx index 41e123b..355ce0e 100644 --- a/components/lancamentos/page/lancamentos-page.tsx +++ b/components/lancamentos/page/lancamentos-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { toast } from "sonner"; import { createMassLancamentosAction, @@ -10,7 +10,7 @@ import { toggleLancamentoSettlementAction, updateLancamentoBulkAction, } 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 { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog"; @@ -139,7 +139,7 @@ export function LancamentosPage({ LancamentoItem[] >([]); - const handleToggleSettlement = useCallback(async (item: LancamentoItem) => { + const handleToggleSettlement = async (item: LancamentoItem) => { if (item.paymentMethod === "Cartão de crédito") { toast.info( "Pagamentos com cartão são conciliados automaticamente. Ajuste pelo cartão.", @@ -182,9 +182,9 @@ export function LancamentosPage({ } finally { setSettlementLoadingId(null); } - }, []); + }; - const handleDelete = useCallback(async () => { + const handleDelete = async () => { if (!lancamentoToDelete) { return; } @@ -200,91 +200,82 @@ export function LancamentosPage({ toast.success(result.message); setDeleteOpen(false); - }, [lancamentoToDelete]); + }; - const handleBulkDelete = useCallback( - async (scope: BulkActionScope) => { - if (!pendingDeleteData) { - return; - } + const handleBulkDelete = async (scope: BulkActionScope) => { + if (!pendingDeleteData) { + return; + } - const result = await deleteLancamentoBulkAction({ - id: pendingDeleteData.id, - scope, - }); + const result = await deleteLancamentoBulkAction({ + id: pendingDeleteData.id, + scope, + }); - if (!result.success) { - toast.error(result.error); - throw new Error(result.error); - } + if (!result.success) { + toast.error(result.error); + throw new Error(result.error); + } - toast.success(result.message); - setBulkDeleteOpen(false); - setPendingDeleteData(null); - }, - [pendingDeleteData], - ); + toast.success(result.message); + setBulkDeleteOpen(false); + setPendingDeleteData(null); + }; - const handleBulkEditRequest = useCallback( - (data: { - id: string; - name: string; - categoriaId: string | undefined; - note: string; - pagadorId: string | undefined; - contaId: string | undefined; - cartaoId: string | undefined; - amount: number; - dueDate: string | null; - boletoPaymentDate: string | null; - }) => { - if (!selectedLancamento) { - return; - } + const handleBulkEditRequest = (data: { + id: string; + name: string; + categoriaId: string | undefined; + note: string; + pagadorId: string | undefined; + contaId: string | undefined; + cartaoId: string | undefined; + amount: number; + dueDate: string | null; + boletoPaymentDate: string | null; + }) => { + if (!selectedLancamento) { + return; + } - setPendingEditData({ - ...data, - lancamento: selectedLancamento, - }); - setEditOpen(false); - setBulkEditOpen(true); - }, - [selectedLancamento], - ); + setPendingEditData({ + ...data, + lancamento: selectedLancamento, + }); + setEditOpen(false); + setBulkEditOpen(true); + }; - const handleBulkEdit = useCallback( - async (scope: BulkActionScope) => { - if (!pendingEditData) { - return; - } + const handleBulkEdit = async (scope: BulkActionScope) => { + if (!pendingEditData) { + return; + } - const result = await updateLancamentoBulkAction({ - id: pendingEditData.id, - scope, - name: pendingEditData.name, - categoriaId: pendingEditData.categoriaId, - note: pendingEditData.note, - pagadorId: pendingEditData.pagadorId, - contaId: pendingEditData.contaId, - cartaoId: pendingEditData.cartaoId, - amount: pendingEditData.amount, - dueDate: pendingEditData.dueDate, - boletoPaymentDate: pendingEditData.boletoPaymentDate, - }); + const result = await updateLancamentoBulkAction({ + id: pendingEditData.id, + scope, + name: pendingEditData.name, + categoriaId: pendingEditData.categoriaId, + note: pendingEditData.note, + pagadorId: pendingEditData.pagadorId, + contaId: pendingEditData.contaId, + cartaoId: pendingEditData.cartaoId, + amount: pendingEditData.amount, + dueDate: pendingEditData.dueDate, + boletoPaymentDate: pendingEditData.boletoPaymentDate, + }); - if (!result.success) { - toast.error(result.error); - throw new Error(result.error); - } + if (!result.success) { + toast.error(result.error); + throw new Error(result.error); + } - toast.success(result.message); - setBulkEditOpen(false); - setPendingEditData(null); - }, - [pendingEditData], - ); + toast.success(result.message); + setBulkEditOpen(false); + setPendingEditData(null); + }; - const handleMassAddSubmit = useCallback(async (data: MassAddFormData) => { + const handleMassAddSubmit = async (data: MassAddFormData) => { const result = await createMassLancamentosAction(data); if (!result.success) { @@ -293,9 +284,9 @@ export function LancamentosPage({ } 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 const withSeries = items.filter((i) => i.seriesId); const sameSeries = @@ -309,9 +300,9 @@ export function LancamentosPage({ } setPendingMultipleDeleteData(items); setMultipleBulkDeleteOpen(true); - }, []); + }; - const confirmMultipleBulkDelete = useCallback(async () => { + const confirmMultipleBulkDelete = async () => { if (pendingMultipleDeleteData.length === 0) { return; } @@ -327,42 +318,42 @@ export function LancamentosPage({ toast.success(result.message); setMultipleBulkDeleteOpen(false); setPendingMultipleDeleteData([]); - }, [pendingMultipleDeleteData]); + }; const [transactionTypeForCreate, setTransactionTypeForCreate] = useState< "Despesa" | "Receita" | null >(null); - const handleCreate = useCallback((type: "Despesa" | "Receita") => { + const handleCreate = (type: "Despesa" | "Receita") => { setTransactionTypeForCreate(type); setCreateOpen(true); - }, []); + }; - const handleMassAdd = useCallback(() => { + const handleMassAdd = () => { setMassAddOpen(true); - }, []); + }; - const handleEdit = useCallback((item: LancamentoItem) => { + const handleEdit = (item: LancamentoItem) => { setSelectedLancamento(item); setEditOpen(true); - }, []); + }; - const handleCopy = useCallback((item: LancamentoItem) => { + const handleCopy = (item: LancamentoItem) => { setLancamentoToCopy(item); setCopyOpen(true); - }, []); + }; - const handleImport = useCallback((item: LancamentoItem) => { + const handleImport = (item: LancamentoItem) => { setLancamentoToImport(item); setImportOpen(true); - }, []); + }; - const handleBulkImport = useCallback((items: LancamentoItem[]) => { + const handleBulkImport = (items: LancamentoItem[]) => { setLancamentosToImport(items); setBulkImportOpen(true); - }, []); + }; - const handleConfirmDelete = useCallback((item: LancamentoItem) => { + const handleConfirmDelete = (item: LancamentoItem) => { if (item.seriesId) { setPendingDeleteData(item); setBulkDeleteOpen(true); @@ -370,22 +361,22 @@ export function LancamentosPage({ setLancamentoToDelete(item); setDeleteOpen(true); } - }, []); + }; - const handleViewDetails = useCallback((item: LancamentoItem) => { + const handleViewDetails = (item: LancamentoItem) => { setSelectedLancamento(item); setDetailsOpen(true); - }, []); + }; - const handleAnticipate = useCallback((item: LancamentoItem) => { + const handleAnticipate = (item: LancamentoItem) => { setSelectedForAnticipation(item); setAnticipateOpen(true); - }, []); + }; - const handleViewAnticipationHistory = useCallback((item: LancamentoItem) => { + const handleViewAnticipationHistory = (item: LancamentoItem) => { setSelectedForAnticipation(item); setAnticipationHistoryOpen(true); - }, []); + }; return ( <> diff --git a/components/lancamentos/select-items.tsx b/components/lancamentos/select-items.tsx index d4a6e65..f2e54e5 100644 --- a/components/lancamentos/select-items.tsx +++ b/components/lancamentos/select-items.tsx @@ -3,8 +3,9 @@ import { RiBankCard2Line, RiBankLine } from "@remixicon/react"; import Image from "next/image"; 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 { resolveLogoSrc } from "@/lib/logo"; import { getAvatarSrc } from "@/lib/pagadores/utils"; import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons"; @@ -56,7 +57,7 @@ export function TransactionTypeSelectContent({ label }: { label: string }) { return ( - + {label} ); @@ -89,15 +90,6 @@ export function ContaCartaoSelectContent({ logo, isCartao, }: 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 Icon = isCartao ? RiBankCard2Line : RiBankLine; diff --git a/components/lancamentos/shared/anticipation-card.tsx b/components/lancamentos/shared/anticipation-card.tsx index 5259db2..c426630 100644 --- a/components/lancamentos/shared/anticipation-card.tsx +++ b/components/lancamentos/shared/anticipation-card.tsx @@ -6,8 +6,8 @@ import { ptBR } from "date-fns/locale"; import { useTransition } from "react"; import { toast } from "sonner"; import { cancelInstallmentAnticipationAction } from "@/app/(dashboard)/lancamentos/anticipation-actions"; -import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; -import MoneyValues from "@/components/money-values"; +import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog"; +import MoneyValues from "@/components/shared/money-values"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { diff --git a/components/lancamentos/table/lancamentos-table.tsx b/components/lancamentos/table/lancamentos-table.tsx index a4ae2b5..b983b7c 100644 --- a/components/lancamentos/table/lancamentos-table.tsx +++ b/components/lancamentos/table/lancamentos-table.tsx @@ -31,9 +31,9 @@ import Image from "next/image"; import Link from "next/link"; import { useMemo, useState } from "react"; import { CategoryIcon } from "@/components/categorias/category-icon"; -import MoneyValues from "@/components/money-values"; 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 { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -69,6 +69,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/lib/lancamentos/column-order"; +import { resolveLogoSrc } from "@/lib/logo"; import { getAvatarSrc } from "@/lib/pagadores/utils"; import { formatDate } from "@/lib/utils/date"; import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons"; @@ -82,15 +83,6 @@ import type { } from "../types"; 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 = { currentUserId: string; noteAsColumn: boolean; @@ -386,7 +378,7 @@ const buildColumns = ({ cell: ({ row }) => { 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 avatarSrc = getAvatarSrc(pagadorAvatar); const initial = displayName.charAt(0).toUpperCase() || "?"; diff --git a/components/notificacoes/notification-bell.tsx b/components/notificacoes/notification-bell.tsx index 1bc07b3..33ec411 100644 --- a/components/notificacoes/notification-bell.tsx +++ b/components/notificacoes/notification-bell.tsx @@ -38,6 +38,10 @@ import type { BudgetNotification, DashboardNotification, } 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"; type NotificationBellProps = { @@ -47,27 +51,13 @@ type NotificationBellProps = { 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 { - const [year, month, day] = dateString.split("-").map(Number); - const date = new Date(Date.UTC(year, month - 1, day)); - return date.toLocaleDateString("pt-BR", { - day: "2-digit", - month: "short", - timeZone: "UTC", - }); -} - -function formatCurrency(amount: number): string { - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - }).format(amount); + return ( + formatDateOnly(dateString, { + day: "2-digit", + month: "short", + }) ?? dateString + ); } function SectionLabel({ @@ -115,9 +105,9 @@ export function NotificationBell({ aria-expanded={open} className={cn( buttonVariants({ variant: "ghost", size: "icon-sm" }), - "group relative text-muted-foreground transition-all duration-200", - "hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40", - "data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border", + "group relative border border-black/10 text-black/75 shadow-none transition-all duration-200", + "hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20", + "data-[state=open]:bg-black/10 data-[state=open]:text-black", )} > {displayCount} @@ -148,7 +138,7 @@ export function NotificationBell({ {/* Header */} @@ -223,13 +213,22 @@ export function NotificationBell({ excedido —{" "} {formatCurrency(n.spentAmount)} de{" "} {formatCurrency(n.budgetAmount)} ( - {Math.round(n.usedPercentage)}%) + {formatPercentage(n.usedPercentage, { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + })} + ) ) : ( <> {n.categoryName} atingiu{" "} - {Math.round(n.usedPercentage)}% do - orçamento —{" "} + + {formatPercentage(n.usedPercentage, { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + })} + {" "} + do orçamento —{" "} {formatCurrency(n.spentAmount)} de{" "} {formatCurrency(n.budgetAmount)} @@ -250,7 +249,7 @@ export function NotificationBell({ />
{invoiceNotifications.map((n) => { - const logo = resolveLogoPath(n.cardLogo); + const logo = resolveLogoSrc(n.cardLogo); return (
0; - const handleEdit = useCallback((budget: Budget) => { + const handleEdit = (budget: Budget) => { setSelectedBudget(budget); setEditOpen(true); - }, []); + }; - const handleEditOpenChange = useCallback((open: boolean) => { + const handleEditOpenChange = (open: boolean) => { setEditOpen(open); if (!open) { setSelectedBudget(null); } - }, []); + }; - const handleRemoveRequest = useCallback((budget: Budget) => { + const handleRemoveRequest = (budget: Budget) => { setBudgetToRemove(budget); setRemoveOpen(true); - }, []); + }; - const handleRemoveOpenChange = useCallback((open: boolean) => { + const handleRemoveOpenChange = (open: boolean) => { setRemoveOpen(open); if (!open) { setBudgetToRemove(null); } - }, []); + }; - const handleRemoveConfirm = useCallback(async () => { + const handleRemoveConfirm = async () => { if (!budgetToRemove) { return; } @@ -74,9 +74,9 @@ export function BudgetsPage({ toast.error(result.error); throw new Error(result.error); - }, [budgetToRemove]); + }; - const handleDuplicateConfirm = useCallback(async () => { + const handleDuplicateConfirm = async () => { const result = await duplicatePreviousMonthBudgetsAction({ period: selectedPeriod, }); @@ -89,7 +89,7 @@ export function BudgetsPage({ toast.error(result.error); throw new Error(result.error); - }, [selectedPeriod]); + }; const removeTitle = budgetToRemove ? `Remover orçamento de "${ diff --git a/components/pagadores/details/pagador-card-usage-card.tsx b/components/pagadores/details/pagador-card-usage-card.tsx index 1d58f69..94ea70b 100644 --- a/components/pagadores/details/pagador-card-usage-card.tsx +++ b/components/pagadores/details/pagador-card-usage-card.tsx @@ -1,22 +1,11 @@ import { RiBankCard2Line } from "@remixicon/react"; 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 { WidgetEmptyState } from "@/components/widget-empty-state"; +import { resolveLogoSrc } from "@/lib/logo"; 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 parts = value.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "CC"; @@ -50,7 +39,7 @@ export function PagadorCardUsageCard({ items }: PagadorCardUsageCardProps) {
    {items.map((item) => { - const logoPath = resolveLogoPath(item.logo); + const logoPath = resolveLogoSrc(item.logo); const initials = buildInitials(item.name); return (
  • { + 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 ( + + +
    +
    + {`Avatar +
    + +
    +
    + + {pagador.name} + + {isAdmin ? ( + + ) : null} + + {pagador.status} + + {pagador.isAutoSend ? ( + + + Envio automático + + ) : null} +
    + + + Criado em {createdAtLabel} + + {pagador.email ? ( + + + {pagador.email} + + ) : ( + Sem e-mail cadastrado + )} + +
    +
    + +
    + {pagador.canEdit ? ( + <> + + + Último envio: {lastMailLabel} + + + ) : ( + + Acesso somente leitura + + )} +
    +
    + + {pagador.canEdit ? ( + { + if (isSending) return; + setConfirmOpen(open); + }} + > + + + Confirmar envio do resumo + + Resumo de{" "} + + {summary.periodLabel} + {" "} + para{" "} + + {pagador.email} + + + + +
    +
    +
    +
    +
    + +
    +
    +

    + Total de Despesas +

    +

    + {formatCurrency(summary.totalExpenses)} +

    +
    +
    +
    +

    + {summary.lancamentoCount} lançamentos +

    +
    +
    +
    + +
    +
    +
    + + + Cartões + +
    +

    + {formatCurrency(summary.paymentSplits.card)} +

    +
    + +
    +
    + + + Boletos + +
    +

    + {formatCurrency(summary.paymentSplits.boleto)} +

    +
    + +
    +
    + + + Pix/Débito + +
    +

    + {formatCurrency(summary.paymentSplits.instant)} +

    +
    +
    + +
    + {summary.cardUsage.length > 0 && ( +
    +
    + + + Cartões Utilizados + +
    +
    + {summary.cardUsage.map((card, index) => ( +
    + {card.name} + + {formatCurrency(card.amount)} + +
    + ))} +
    +
    + )} + + {(summary.boletoStats.paidCount > 0 || + summary.boletoStats.pendingCount > 0) && ( +
    +
    + + + Status de Boletos + +
    +
    +
    +

    Pagos

    +

    + {formatCurrency(summary.boletoStats.paidAmount)}{" "} + + ({summary.boletoStats.paidCount}) + +

    +
    +
    +

    + Pendentes +

    +

    + {formatCurrency(summary.boletoStats.pendingAmount)}{" "} + + ({summary.boletoStats.pendingCount}) + +

    +
    +
    +
    + )} +
    +
    + + + + + +
    +
    + ) : null} +
    + ); +} + +const formatDate = (value: string) => { + return ( + formatDateTime(value, { + day: "2-digit", + month: "long", + year: "numeric", + }) ?? "—" + ); +}; diff --git a/components/pagadores/details/pagador-history-card.tsx b/components/pagadores/details/pagador-history-card.tsx index e07a36a..1854e58 100644 --- a/components/pagadores/details/pagador-history-card.tsx +++ b/components/pagadores/details/pagador-history-card.tsx @@ -15,7 +15,7 @@ import { ChartTooltip, ChartTooltipContent, } 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 type { PagadorHistoryPoint } from "@/lib/pagadores/details"; diff --git a/components/pagadores/details/pagador-info-card.tsx b/components/pagadores/details/pagador-info-card.tsx index 602f694..fb06151 100644 --- a/components/pagadores/details/pagador-info-card.tsx +++ b/components/pagadores/details/pagador-info-card.tsx @@ -1,136 +1,26 @@ -"use client"; - -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 { RiUser3Line } from "@remixicon/react"; +import type { ReactNode } from "react"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; 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"; - -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; -}; +import type { PagadorInfo } from "./types"; type PagadorInfoCardProps = { pagador: PagadorInfo; - selectedPeriod: string; - summary: PagadorSummaryPreview; }; -export function PagadorInfoCard({ - pagador, - selectedPeriod, - summary, -}: PagadorInfoCardProps) { - const router = useRouter(); - const [isSending, startTransition] = useTransition(); - const [confirmOpen, setConfirmOpen] = useState(false); +export function PagadorInfoCard({ pagador }: PagadorInfoCardProps) { + const showSensitiveDetails = pagador.canEdit; - const avatarSrc = getAvatarSrc(pagador.avatarUrl); - 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 getStatusBadgeVariant = (status: string): "success" | "outline" => { const normalizedStatus = status.toLowerCase(); if (normalizedStatus === "ativo") { return "success"; @@ -140,84 +30,18 @@ export function PagadorInfoCard({ return ( - -
    -
    - {`Avatar -
    - -
    -
    - - {pagador.name} - - {isAdmin ? ( - - ) : null} - {pagador.isAutoSend ? ( - - ) : null} -
    - - Criado em {createdAtLabel} - -
    -
    - -
    - {pagador.canEdit ? ( - <> - - - Último envio: {lastMailLabel} - - - ) : ( - - Acesso somente leitura - - )} -
    + + + Detalhes do pagador + + + {showSensitiveDetails + ? "Informações cadastrais e preferências de envio." + : "Informações cadastrais visíveis para este compartilhamento."} + - - - {pagador.email} - - ) : ( - "Sem e-mail cadastrado" - ) - } - /> } /> - - {!pagador.email ? ( + {showSensitiveDetails ? ( + + ) : null} + {showSensitiveDetails ? ( + + ) : null} + {showSensitiveDetails && !pagador.email ? ( ) : null} - {pagador.note} - ) : ( - "Sem observações" - ) - } - className="sm:col-span-2" - /> + {showSensitiveDetails ? ( + {pagador.note} + ) : ( + "Sem observações" + ) + } + className="sm:col-span-2" + /> + ) : null} - - {pagador.canEdit ? ( - { - if (isSending) return; - setConfirmOpen(open); - }} - > - - - Confirmar envio do resumo - - Resumo de{" "} - - {summary.periodLabel} - {" "} - para{" "} - - {pagador.email} - - - - -
    - {/* Total Geral */} -
    -
    -
    -
    - -
    -
    -

    - Total de Despesas -

    -

    - {formatCurrency(summary.totalExpenses)} -

    -
    -
    -
    -

    - {summary.lancamentoCount} lançamentos -

    -
    -
    -
    - - {/* Grid de Formas de Pagamento */} -
    - {/* Cartões */} -
    -
    - - - Cartões - -
    -

    - {formatCurrency(summary.paymentSplits.card)} -

    -
    - - {/* Boletos */} -
    -
    - - - Boletos - -
    -

    - {formatCurrency(summary.paymentSplits.boleto)} -

    -
    - - {/* Instantâneo */} -
    -
    - - - Pix/Débito - -
    -

    - {formatCurrency(summary.paymentSplits.instant)} -

    -
    -
    - - {/* Detalhes Adicionais */} -
    - {/* Cartões Utilizados */} - {summary.cardUsage.length > 0 && ( -
    -
    - - - Cartões Utilizados - -
    -
    - {summary.cardUsage.map((card, index) => ( -
    - {card.name} - - {formatCurrency(card.amount)} - -
    - ))} -
    -
    - )} - - {/* Status de Boletos */} - {(summary.boletoStats.paidCount > 0 || - summary.boletoStats.pendingCount > 0) && ( -
    -
    - - - Status de Boletos - -
    -
    -
    -

    Pagos

    -

    - {formatCurrency(summary.boletoStats.paidAmount)}{" "} - - ({summary.boletoStats.paidCount}) - -

    -
    -
    -

    - Pendentes -

    -

    - {formatCurrency(summary.boletoStats.pendingAmount)}{" "} - - ({summary.boletoStats.pendingCount}) - -

    -
    -
    -
    - )} -
    -
    - - - - - -
    -
    - ) : null}
    ); } -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) => { if (role === PAGADOR_ROLE_ADMIN) return "Administrador"; return "Pagador"; }; -const formatCurrency = (value: number) => - value.toLocaleString("pt-BR", { - style: "currency", - currency: "BRL", - maximumFractionDigits: 2, - }); - type InfoItemProps = { label: string; value: ReactNode; diff --git a/components/pagadores/details/pagador-leave-share-card.tsx b/components/pagadores/details/pagador-leave-share-card.tsx index 5bf6b2a..f065d5d 100644 --- a/components/pagadores/details/pagador-leave-share-card.tsx +++ b/components/pagadores/details/pagador-leave-share-card.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { deletePagadorShareAction } from "@/app/(dashboard)/pagadores/actions"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatDateTime } from "@/lib/utils/date"; interface PagadorLeaveShareCardProps { shareId: string; @@ -37,11 +38,12 @@ export function PagadorLeaveShareCard({ }); }; - const formattedDate = new Date(createdAt).toLocaleDateString("pt-BR", { - day: "2-digit", - month: "long", - year: "numeric", - }); + const formattedDate = + formatDateTime(createdAt, { + day: "2-digit", + month: "long", + year: "numeric", + }) ?? "—"; return ( diff --git a/components/pagadores/details/pagador-monthly-summary-card.tsx b/components/pagadores/details/pagador-monthly-summary-card.tsx index 67329ce..2d3c3f1 100644 --- a/components/pagadores/details/pagador-monthly-summary-card.tsx +++ b/components/pagadores/details/pagador-monthly-summary-card.tsx @@ -1,5 +1,5 @@ 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 type { PagadorMonthlyBreakdown } from "@/lib/pagadores/details"; import { cn } from "@/lib/utils/ui"; diff --git a/components/pagadores/details/pagador-payment-method-cards.tsx b/components/pagadores/details/pagador-payment-method-cards.tsx index fbfd196..f6841be 100644 --- a/components/pagadores/details/pagador-payment-method-cards.tsx +++ b/components/pagadores/details/pagador-payment-method-cards.tsx @@ -5,40 +5,17 @@ import { RiWallet3Line, } from "@remixicon/react"; 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 { Progress } from "@/components/ui/progress"; -import { WidgetEmptyState } from "@/components/widget-empty-state"; +import { buildBillStatusLabel } from "@/lib/dashboard/bills-helpers"; import type { PagadorBoletoItem, PagadorPaymentStatusData, } from "@/lib/pagadores/details"; 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 --- type PagadorBoletoCardProps = { @@ -62,7 +39,7 @@ export function PagadorBoletoCard({ items }: PagadorBoletoCardProps) {
      {items.map((item) => { - const statusLabel = buildStatusLabel(item); + const statusLabel = buildBillStatusLabel(item); return (
    • - + Compartilhamentos

      diff --git a/components/pagadores/details/types.ts b/components/pagadores/details/types.ts new file mode 100644 index 0000000..84cb4d0 --- /dev/null +++ b/components/pagadores/details/types.ts @@ -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; +}; diff --git a/components/pagadores/pagador-card.tsx b/components/pagadores/pagador-card.tsx index 2d8524a..26c6aee 100644 --- a/components/pagadores/pagador-card.tsx +++ b/components/pagadores/pagador-card.tsx @@ -9,7 +9,6 @@ import { } from "@remixicon/react"; import Image from "next/image"; import Link from "next/link"; -import { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; @@ -23,11 +22,7 @@ interface PagadorCardProps { } export function PagadorCard({ pagador, onEdit, onRemove }: PagadorCardProps) { - const avatarSrc = useMemo( - () => getAvatarSrc(pagador.avatarUrl), - [pagador.avatarUrl], - ); - + const avatarSrc = getAvatarSrc(pagador.avatarUrl); const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN; const isReadOnly = !pagador.canEdit; diff --git a/components/pagadores/pagador-select-items.tsx b/components/pagadores/pagador-select-items.tsx index 43877cd..1595965 100644 --- a/components/pagadores/pagador-select-items.tsx +++ b/components/pagadores/pagador-select-items.tsx @@ -1,13 +1,13 @@ "use client"; -import DotIcon from "@/components/dot-icon"; +import StatusDot from "@/components/shared/status-dot"; export function StatusSelectContent({ label }: { label: string }) { const isActive = label === "Ativo"; return ( - {label} diff --git a/components/pagadores/pagadores-page.tsx b/components/pagadores/pagadores-page.tsx index 67bad5d..10e3a8c 100644 --- a/components/pagadores/pagadores-page.tsx +++ b/components/pagadores/pagadores-page.tsx @@ -2,15 +2,15 @@ import { RiAddCircleLine } from "@remixicon/react"; import { useRouter } from "next/navigation"; -import { useCallback, useMemo, useState, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { deletePagadorAction, joinPagadorByShareCodeAction, } from "@/app/(dashboard)/pagadores/actions"; -import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { PagadorCard } from "@/components/pagadores/pagador-card"; import { PagadorDialog } from "@/components/pagadores/pagador-dialog"; +import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; @@ -49,35 +49,35 @@ export function PagadoresPage({ [pagadores], ); - const handleEdit = useCallback((pagador: Pagador) => { + const handleEdit = (pagador: Pagador) => { setSelectedPagador(pagador); setEditOpen(true); - }, []); + }; - const handleEditOpenChange = useCallback((open: boolean) => { + const handleEditOpenChange = (open: boolean) => { setEditOpen(open); if (!open) { setSelectedPagador(null); } - }, []); + }; - const handleRemoveRequest = useCallback((pagador: Pagador) => { + const handleRemoveRequest = (pagador: Pagador) => { if (pagador.role === PAGADOR_ROLE_ADMIN) { toast.error("Pagadores administradores não podem ser removidos."); return; } setPagadorToRemove(pagador); setRemoveOpen(true); - }, []); + }; - const handleRemoveOpenChange = useCallback((open: boolean) => { + const handleRemoveOpenChange = (open: boolean) => { setRemoveOpen(open); if (!open) { setPagadorToRemove(null); } - }, []); + }; - const handleRemoveConfirm = useCallback(async () => { + const handleRemoveConfirm = async () => { if (!pagadorToRemove) { return; } @@ -91,37 +91,34 @@ export function PagadoresPage({ toast.error(result.error); throw new Error(result.error); - }, [pagadorToRemove]); + }; const removeTitle = pagadorToRemove ? `Remover pagador "${pagadorToRemove.name}"?` : "Remover pagador?"; - const handleJoinByCode = useCallback( - (event: React.FormEvent) => { - event.preventDefault(); - if (!shareCodeInput.trim()) { - toast.error("Informe um código válido."); + const handleJoinByCode = (event: React.FormEvent) => { + event.preventDefault(); + if (!shareCodeInput.trim()) { + 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; } - startJoin(async () => { - const result = await joinPagadorByShareCodeAction({ - code: shareCodeInput.trim(), - }); - - if (!result.success) { - toast.error(result.error); - return; - } - - toast.success(result.message); - setShareCodeInput(""); - router.refresh(); - }); - }, - [shareCodeInput, router], - ); + toast.success(result.message); + setShareCodeInput(""); + router.refresh(); + }); + }; return ( <> diff --git a/components/pre-lancamentos/inbox-card.tsx b/components/pre-lancamentos/inbox-card.tsx index b0231e8..d7d2d83 100644 --- a/components/pre-lancamentos/inbox-card.tsx +++ b/components/pre-lancamentos/inbox-card.tsx @@ -10,8 +10,7 @@ import { import { format, formatDistanceToNow } from "date-fns"; import { ptBR } from "date-fns/locale"; import Image from "next/image"; -import { useMemo } from "react"; -import MoneyValues from "@/components/money-values"; +import MoneyValues from "@/components/shared/money-values"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -28,6 +27,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { resolveLogoSrc } from "@/lib/logo"; import type { InboxItem } from "./types"; interface InboxCardProps { @@ -41,17 +41,6 @@ interface InboxCardProps { onRestoreToPending?: (item: InboxItem) => void | Promise; } -function resolveLogoPath(logo: string): string { - if ( - logo.startsWith("http") || - logo.startsWith("data:") || - logo.startsWith("/") - ) { - return logo; - } - return `/logos/${logo}`; -} - function findMatchingLogo( sourceAppName: string | null, appLogoMap: Record, @@ -61,12 +50,12 @@ function findMatchingLogo( const appName = sourceAppName.toLowerCase(); // 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 for (const [name, logo] of Object.entries(appLogoMap)) { if (name.includes(appName) || appName.includes(name)) { - return resolveLogoPath(logo); + return resolveLogoSrc(logo); } } @@ -83,11 +72,9 @@ export function InboxCard({ onDelete, onRestoreToPending, }: InboxCardProps) { - const matchedLogo = useMemo( - () => - appLogoMap ? findMatchingLogo(item.sourceAppName, appLogoMap) : null, - [item.sourceAppName, appLogoMap], - ); + const matchedLogo = appLogoMap + ? findMatchingLogo(item.sourceAppName, appLogoMap) + : null; const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null; diff --git a/components/pre-lancamentos/inbox-details-dialog.tsx b/components/pre-lancamentos/inbox-details-dialog.tsx index e55138d..3ff7bdb 100644 --- a/components/pre-lancamentos/inbox-details-dialog.tsx +++ b/components/pre-lancamentos/inbox-details-dialog.tsx @@ -2,7 +2,7 @@ import { format } from "date-fns"; 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 { Button } from "@/components/ui/button"; import { diff --git a/components/pre-lancamentos/inbox-page.tsx b/components/pre-lancamentos/inbox-page.tsx index 7c32401..4d1183b 100644 --- a/components/pre-lancamentos/inbox-page.tsx +++ b/components/pre-lancamentos/inbox-page.tsx @@ -1,7 +1,7 @@ "use client"; import { RiAtLine, RiDeleteBinLine } from "@remixicon/react"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; import { bulkDeleteInboxItemsAction, @@ -10,8 +10,8 @@ import { markInboxAsProcessedAction, restoreDiscardedInboxItemAction, } from "@/app/(dashboard)/pre-lancamentos/actions"; -import { ConfirmActionDialog } from "@/components/confirm-action-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 { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -67,66 +67,71 @@ export function InboxPage({ "processed" | "discarded" >("processed"); - const sortByTimestamp = useCallback( - (list: InboxItem[]) => - [...list].sort( + const sortedPending = useMemo( + () => + [...pendingItems].sort( (a, b) => new Date(b.notificationTimestamp).getTime() - new Date(a.notificationTimestamp).getTime(), ), - [], - ); - - const sortedPending = useMemo( - () => sortByTimestamp(pendingItems), - [pendingItems, sortByTimestamp], + [pendingItems], ); 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( - () => 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); if (!open) { setItemToProcess(null); } - }, []); + }; - const handleDetailsOpenChange = useCallback((open: boolean) => { + const handleDetailsOpenChange = (open: boolean) => { setDetailsOpen(open); if (!open) { setItemDetails(null); } - }, []); + }; - const handleDiscardOpenChange = useCallback((open: boolean) => { + const handleDiscardOpenChange = (open: boolean) => { setDiscardOpen(open); if (!open) { setItemToDiscard(null); } - }, []); + }; - const handleProcessRequest = useCallback((item: InboxItem) => { + const handleProcessRequest = (item: InboxItem) => { setItemToProcess(item); setProcessOpen(true); - }, []); + }; - const handleDetailsRequest = useCallback((item: InboxItem) => { + const handleDetailsRequest = (item: InboxItem) => { setItemDetails(item); setDetailsOpen(true); - }, []); + }; - const handleDiscardRequest = useCallback((item: InboxItem) => { + const handleDiscardRequest = (item: InboxItem) => { setItemToDiscard(item); setDiscardOpen(true); - }, []); + }; - const handleDiscardConfirm = useCallback(async () => { + const handleDiscardConfirm = async () => { if (!itemToDiscard) return; const result = await discardInboxItemAction({ @@ -140,21 +145,21 @@ export function InboxPage({ toast.error(result.error); throw new Error(result.error); - }, [itemToDiscard]); + }; - const handleDeleteOpenChange = useCallback((open: boolean) => { + const handleDeleteOpenChange = (open: boolean) => { setDeleteOpen(open); if (!open) { setItemToDelete(null); } - }, []); + }; - const handleDeleteRequest = useCallback((item: InboxItem) => { + const handleDeleteRequest = (item: InboxItem) => { setItemToDelete(item); setDeleteOpen(true); - }, []); + }; - const handleDeleteConfirm = useCallback(async () => { + const handleDeleteConfirm = async () => { if (!itemToDelete) return; const result = await deleteInboxItemAction({ @@ -168,21 +173,21 @@ export function InboxPage({ toast.error(result.error); throw new Error(result.error); - }, [itemToDelete]); + }; - const handleRestoreOpenChange = useCallback((open: boolean) => { + const handleRestoreOpenChange = (open: boolean) => { setRestoreOpen(open); if (!open) { setItemToRestore(null); } - }, []); + }; - const handleRestoreRequest = useCallback((item: InboxItem) => { + const handleRestoreRequest = (item: InboxItem) => { setItemToRestore(item); setRestoreOpen(true); - }, []); + }; - const handleRestoreToPendingConfirm = useCallback(async () => { + const handleRestoreToPendingConfirm = async () => { if (!itemToRestore) return; const result = await restoreDiscardedInboxItemAction({ @@ -196,21 +201,18 @@ export function InboxPage({ toast.error(result.error); throw new Error(result.error); - }, [itemToRestore]); + }; - const handleBulkDeleteOpenChange = useCallback((open: boolean) => { + const handleBulkDeleteOpenChange = (open: boolean) => { setBulkDeleteOpen(open); - }, []); + }; - const handleBulkDeleteRequest = useCallback( - (status: "processed" | "discarded") => { - setBulkDeleteStatus(status); - setBulkDeleteOpen(true); - }, - [], - ); + const handleBulkDeleteRequest = (status: "processed" | "discarded") => { + setBulkDeleteStatus(status); + setBulkDeleteOpen(true); + }; - const handleBulkDeleteConfirm = useCallback(async () => { + const handleBulkDeleteConfirm = async () => { const result = await bulkDeleteInboxItemsAction({ status: bulkDeleteStatus, }); @@ -222,9 +224,9 @@ export function InboxPage({ toast.error(result.error); throw new Error(result.error); - }, [bulkDeleteStatus]); + }; - const handleLancamentoSuccess = useCallback(async () => { + const handleLancamentoSuccess = async () => { if (!itemToProcess) return; const result = await markInboxAsProcessedAction({ @@ -236,7 +238,7 @@ export function InboxPage({ } else { toast.error(result.error); } - }, [itemToProcess]); + }; // Prepare default values from inbox item const getDateString = ( diff --git a/lib/faturas/index.ts b/lib/faturas/index.ts new file mode 100644 index 0000000..5a0f0a7 --- /dev/null +++ b/lib/faturas/index.ts @@ -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 = { + [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 = + { + [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}$/; diff --git a/lib/installments/utils.ts b/lib/installments/utils.ts index a8cc09d..86c87e4 100644 --- a/lib/installments/utils.ts +++ b/lib/installments/utils.ts @@ -1,3 +1,5 @@ +import { displayPeriod, periodToDate } from "@/lib/utils/period"; + /** * 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") @@ -10,18 +12,13 @@ export function calculateLastInstallmentDate( currentInstallment: number, totalInstallments: number, ): Date { - // Parse do período atual (formato: "YYYY-MM") - const [yearStr, monthStr] = currentPeriod.split("-"); - const year = Number.parseInt(yearStr ?? "", 10); - const monthIndex = Number.parseInt(monthStr ?? "", 10) - 1; // 0-indexed - - if (Number.isNaN(year) || Number.isNaN(monthIndex)) { + let currentDate: Date; + try { + currentDate = periodToDate(currentPeriod); + } catch { 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) // Ex: parcela 2 de 6 -> restam 5 parcelas (2, 3, 4, 5, 6) const remainingInstallments = totalInstallments - currentInstallment + 1; @@ -41,15 +38,9 @@ export function calculateLastInstallmentDate( * Exemplo: "Março de 2026" */ export function formatLastInstallmentDate(date: Date): string { - const formatter = new Intl.DateTimeFormat("pt-BR", { - month: "long", - year: "numeric", - timeZone: "UTC", - }); - - const formatted = formatter.format(date); - // Capitaliza a primeira letra - return formatted.charAt(0).toUpperCase() + formatted.slice(1); + return displayPeriod( + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`, + ); } /** @@ -71,6 +62,9 @@ export function formatPurchaseDate(date: Date): string { * Formata o texto da parcela atual * Exemplo: "1 de 6" */ -export function formatCurrentInstallment(current: number, total: number): string { +export function formatCurrentInstallment( + current: number, + total: number, +): string { return `${current} de ${total}`; } diff --git a/lib/lancamentos/formatting-helpers.ts b/lib/lancamentos/formatting-helpers.ts index fa2d6f1..1b36d97 100644 --- a/lib/lancamentos/formatting-helpers.ts +++ b/lib/lancamentos/formatting-helpers.ts @@ -1,6 +1,14 @@ /** * 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 @@ -11,14 +19,6 @@ function capitalize(value: string): string { : 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) */ @@ -44,9 +44,13 @@ export const monthFormatter = new Intl.DateTimeFormat("pt-BR", { */ export function formatDate(value?: string | null): string { if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "—"; - return dateFormatter.format(date); + return ( + formatDateOnly(value, { + 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 { if (!value) return "—"; - const [year, month] = value.split("-").map(Number); - if (!year || !month) return value; - const date = new Date(year, month - 1, 1); - return capitalize(monthFormatter.format(date)); + try { + return formatMonthYearLabel(value); + } catch { + return value; + } } /** @@ -97,5 +102,5 @@ export function getTransactionBadgeVariant( * @example formatCurrency(1234.56) => "R$ 1.234,56" */ export function formatCurrency(value: number): string { - return currencyFormatter.format(value); + return formatCurrencyValue(value); } diff --git a/lib/lancamentos/page-helpers.ts b/lib/lancamentos/page-helpers.ts index 665a4a1..cbfd663 100644 --- a/lib/lancamentos/page-helpers.ts +++ b/lib/lancamentos/page-helpers.ts @@ -19,6 +19,7 @@ import { PAGADOR_ROLE_ADMIN, PAGADOR_ROLE_TERCEIRO, } from "@/lib/pagadores/constants"; +import { toDateOnlyString } from "@/lib/utils/date"; type PagadorRow = typeof pagadores.$inferSelect; type ContaRow = typeof contas.$inferSelect; @@ -185,12 +186,10 @@ export const fetchLancamentoFilterSources = async (userId: string) => { where: eq(pagadores.userId, userId), }), db.query.contas.findMany({ - where: (contas, { eq, and }) => - and(eq(contas.userId, userId), eq(contas.status, "Ativa")), + where: and(eq(contas.userId, userId), eq(contas.status, "Ativa")), }), db.query.cartoes.findMany({ - where: (cartoes, { eq, and }) => - and(eq(cartoes.userId, userId), eq(cartoes.status, "Ativo")), + where: and(eq(cartoes.userId, userId), eq(cartoes.status, "Ativo")), }), db.query.categorias.findMany({ where: eq(categorias.userId, userId), @@ -405,7 +404,7 @@ export const mapLancamentosData = (rows: LancamentoRowWithRelations[]) => id: item.id, userId: item.userId, name: item.name, - purchaseDate: item.purchaseDate?.toISOString() ?? new Date().toISOString(), + purchaseDate: toDateOnlyString(item.purchaseDate) ?? "", period: item.period ?? "", transactionType: item.transactionType, amount: Number(item.amount ?? 0), diff --git a/lib/pagadores/details.ts b/lib/pagadores/details.ts index 7608403..2c617af 100644 --- a/lib/pagadores/details.ts +++ b/lib/pagadores/details.ts @@ -14,6 +14,13 @@ import { import { cartoes, lancamentos } from "@/db/schema"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants"; 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 DESPESA = "Despesa"; @@ -65,76 +72,6 @@ export type PagadorPaymentStatusData = { 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 = () => or( isNull(lancamentos.note), @@ -206,9 +143,10 @@ export async function fetchPagadorHistory({ period, months = 6, }: BaseFilters & { months?: number }): Promise { - const window = buildPeriodWindow(period, months); - const start = window[0]; - const end = window[window.length - 1]; + const startPeriod = addMonthsToPeriod(period, -(Math.max(months, 1) - 1)); + const windowPeriods = buildPeriodRange(startPeriod, period); + const start = windowPeriods[0]; + const end = windowPeriods[windowPeriods.length - 1]; const rows = await db .select({ @@ -233,7 +171,7 @@ export async function fetchPagadorHistory({ { receitas: number; despesas: number } >(); - for (const key of window) { + for (const key of windowPeriods) { 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, - label: formatPeriodLabel(key), + label: formatCompactPeriodLabel(key), receitas: totalsByPeriod.get(key)?.receitas ?? 0, despesas: totalsByPeriod.get(key)?.despesas ?? 0, })); @@ -283,20 +221,22 @@ export async function fetchPagadorCardUsage({ ) .groupBy(lancamentos.cartaoId, cartoes.name, cartoes.logo); - return rows - .filter((row) => Boolean(row.cartaoId)) - .map((row) => { - if (!row.cartaoId) { - throw new Error("cartaoId should not be null after filter"); - } - return { - id: row.cartaoId, - name: row.cardName ?? "Cartão", - logo: row.cardLogo ?? null, - amount: Math.abs(toNumber(row.totalAmount)), - }; - }) - .sort((a, b) => b.amount - a.amount); + const items: PagadorCardUsageItem[] = []; + + for (const row of rows) { + if (!row.cartaoId) { + continue; + } + + items.push({ + id: row.cartaoId, + name: row.cardName ?? "Cartão", + logo: row.cardLogo ?? null, + amount: Math.abs(toNumber(row.totalAmount)), + }); + } + + return items.sort((a, b) => b.amount - a.amount); } export async function fetchPagadorBoletoStats({ @@ -374,14 +314,20 @@ export async function fetchPagadorBoletoItems({ ) .orderBy(asc(lancamentos.dueDate)); - return rows.map((row) => ({ - id: row.id, - name: row.name, - amount: Math.abs(toNumber(row.amount)), - dueDate: toISODate(row.dueDate), - boletoPaymentDate: toISODate(row.boletoPaymentDate), - isSettled: Boolean(row.isSettled), - })); + const items: PagadorBoletoItem[] = []; + + for (const row of rows) { + items.push({ + id: row.id, + name: row.name, + amount: Math.abs(toNumber(row.amount)), + dueDate: toDateOnlyString(row.dueDate), + boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate), + isSettled: Boolean(row.isSettled), + }); + } + + return items; } export async function fetchPagadorPaymentStatus({ diff --git a/lib/pagadores/notifications.ts b/lib/pagadores/notifications.ts index 2811322..479035f 100644 --- a/lib/pagadores/notifications.ts +++ b/lib/pagadores/notifications.ts @@ -3,6 +3,8 @@ import { Resend } from "resend"; import { pagadores } from "@/db/schema"; import { db } from "@/lib/db"; import { getResendFromEmail } from "@/lib/email/resend"; +import { formatCurrency } from "@/lib/utils/currency"; +import { formatDateTime } from "@/lib/utils/date"; type ActionType = "created" | "deleted"; @@ -24,20 +26,21 @@ export type PagadorNotificationRequest = { entriesByPagador: Map; }; -const formatCurrency = (value: number) => - value.toLocaleString("pt-BR", { - style: "currency", - currency: "BRL", - maximumFractionDigits: 2, - }); +type PagadorNotificationRecipient = { + id: string; + name: string | null; + email: string | null; + isAutoSend: boolean | null; +}; const formatDate = (value: Date | null) => { - if (!value) return "—"; - return value.toLocaleDateString("pt-BR", { - day: "2-digit", - month: "short", - year: "numeric", - }); + return ( + formatDateTime(value, { + day: "2-digit", + month: "short", + year: "numeric", + }) ?? "—" + ); }; const buildHtmlBody = ({ @@ -133,9 +136,9 @@ export async function sendPagadorAutoEmails({ return; } - const pagadorRows = await db.query.pagadores.findMany({ + const pagadorRows = (await db.query.pagadores.findMany({ where: inArray(pagadores.id, pagadorIds), - }); + })) as PagadorNotificationRecipient[]; if (pagadorRows.length === 0) { return; @@ -146,7 +149,7 @@ export async function sendPagadorAutoEmails({ action === "created" ? "Novo lançamento" : "Lançamento removido"; const results = await Promise.allSettled( - pagadorRows.map(async (pagador) => { + pagadorRows.map(async (pagador: PagadorNotificationRecipient) => { if (!pagador.email || !pagador.isAutoSend) { return; } @@ -172,7 +175,7 @@ export async function sendPagadorAutoEmails({ ); // Log any failed email sends - results.forEach((result, index) => { + results.forEach((result: PromiseSettledResult, index: number) => { if (result.status === "rejected") { const pagador = pagadorRows[index]; console.error(