diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts b/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts index eb4e70e..1471403 100644 --- a/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts @@ -17,6 +17,7 @@ import { type InvoicePaymentStatus, } from "@/lib/faturas"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { parseLocalDateString } from "@/lib/utils/date"; import { and, eq, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; @@ -157,7 +158,7 @@ export async function updateInvoicePaymentStatusAction( if (adminPagador) { // Usar a data customizada ou a data atual como data de pagamento const invoiceDate = data.paymentDate - ? new Date(data.paymentDate) + ? parseLocalDateString(data.paymentDate) : new Date(); const amount = `-${formatDecimal(adminShare)}`; @@ -273,7 +274,7 @@ export async function updatePaymentDateAction( await tx .update(lancamentos) .set({ - purchaseDate: new Date(data.paymentDate), + purchaseDate: parseLocalDateString(data.paymentDate), }) .where(eq(lancamentos.id, existingPayment.id)); }); diff --git a/app/(dashboard)/categorias/historico/loading.tsx b/app/(dashboard)/categorias/historico/loading.tsx new file mode 100644 index 0000000..fc2b027 --- /dev/null +++ b/app/(dashboard)/categorias/historico/loading.tsx @@ -0,0 +1,33 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent } from "@/components/ui/card"; + +export default function Loading() { + return ( +
+ + +
+ {/* Selected categories and counter */} +
+
+ + + +
+
+ + +
+
+ + {/* Category selector button */} + +
+ + {/* Chart */} + +
+
+
+ ); +} diff --git a/app/(dashboard)/categorias/historico/page.tsx b/app/(dashboard)/categorias/historico/page.tsx new file mode 100644 index 0000000..6df506b --- /dev/null +++ b/app/(dashboard)/categorias/historico/page.tsx @@ -0,0 +1,21 @@ +import { CategoryHistoryWidget } from "@/components/dashboard/category-history-widget"; +import { getUser } from "@/lib/auth/server"; +import { fetchCategoryHistory } from "@/lib/dashboard/categories/category-history"; +import { getCurrentPeriod } from "@/lib/utils/period"; + +export default async function HistoricoCategoriasPage() { + const user = await getUser(); + const currentPeriod = getCurrentPeriod(); + + const data = await fetchCategoryHistory(user.id, currentPeriod); + + return ( +
+

+ Acompanhe o histórico de desempenho das suas categorias ao longo de 9 + meses. +

+ +
+ ); +} diff --git a/app/(dashboard)/lancamentos/actions.ts b/app/(dashboard)/lancamentos/actions.ts index d5f52f5..b511b33 100644 --- a/app/(dashboard)/lancamentos/actions.ts +++ b/app/(dashboard)/lancamentos/actions.ts @@ -22,7 +22,11 @@ import { } from "@/lib/pagadores/notifications"; import { noteSchema, uuidSchema } from "@/lib/schemas/common"; import { formatDecimalForDbRequired } from "@/lib/utils/currency"; -import { getTodayDateString } from "@/lib/utils/date"; +import { + getTodayDate, + getTodayDateString, + parseLocalDateString, +} from "@/lib/utils/date"; import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import { z } from "zod"; @@ -32,7 +36,7 @@ const resolvePeriod = (purchaseDate: string, period?: string | null) => { return period; } - const date = new Date(purchaseDate); + const date = parseLocalDateString(purchaseDate); if (Number.isNaN(date.getTime())) { throw new Error("Data da transação inválida."); } @@ -42,8 +46,6 @@ const resolvePeriod = (purchaseDate: string, period?: string | null) => { return `${year}-${month}`; }; -const getTodayDate = () => new Date(getTodayDateString()); - const baseFields = z.object({ purchaseDate: z .string({ message: "Informe a data da transação." }) @@ -471,13 +473,13 @@ export async function createLancamentoAction( const data = createSchema.parse(input); const period = resolvePeriod(data.purchaseDate, data.period); - const purchaseDate = new Date(data.purchaseDate); - const dueDate = data.dueDate ? new Date(data.dueDate) : null; + const purchaseDate = parseLocalDateString(data.purchaseDate); + const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null; const shouldSetBoletoPaymentDate = data.paymentMethod === "Boleto" && (data.isSettled ?? false); const boletoPaymentDate = shouldSetBoletoPaymentDate ? data.boletoPaymentDate - ? new Date(data.boletoPaymentDate) + ? parseLocalDateString(data.boletoPaymentDate) : getTodayDate() : null; @@ -603,7 +605,7 @@ export async function updateLancamentoAction( data.paymentMethod === "Boleto" && Boolean(normalizedSettled); const boletoPaymentDateValue = shouldSetBoletoPaymentDate ? data.boletoPaymentDate - ? new Date(data.boletoPaymentDate) + ? parseLocalDateString(data.boletoPaymentDate) : getTodayDate() : null; @@ -611,7 +613,7 @@ export async function updateLancamentoAction( .update(lancamentos) .set({ name: data.name, - purchaseDate: new Date(data.purchaseDate), + purchaseDate: parseLocalDateString(data.purchaseDate), transactionType: data.transactionType, amount: normalizedAmount, condition: data.condition, @@ -624,7 +626,7 @@ export async function updateLancamentoAction( isSettled: normalizedSettled, installmentCount: data.installmentCount ?? null, recurrenceCount: data.recurrenceCount ?? null, - dueDate: data.dueDate ? new Date(data.dueDate) : null, + dueDate: data.dueDate ? parseLocalDateString(data.dueDate) : null, boletoPaymentDate: boletoPaymentDateValue, period, }) @@ -963,14 +965,14 @@ export async function updateLancamentoBulkAction( const baseDueDate = hasDueDateUpdate && data.dueDate - ? new Date(data.dueDate) + ? parseLocalDateString(data.dueDate) : hasDueDateUpdate ? null : undefined; const baseBoletoPaymentDate = hasBoletoPaymentDateUpdate && data.boletoPaymentDate - ? new Date(data.boletoPaymentDate) + ? parseLocalDateString(data.boletoPaymentDate) : hasBoletoPaymentDateUpdate ? null : undefined; @@ -1192,7 +1194,7 @@ export async function createMassLancamentosAction( const period = data.fixedFields.period ?? resolvePeriod(transaction.purchaseDate); - const purchaseDate = new Date(transaction.purchaseDate); + const purchaseDate = parseLocalDateString(transaction.purchaseDate); const amountSign: 1 | -1 = transactionType === "Despesa" ? -1 : 1; const totalCents = Math.round(Math.abs(transaction.amount) * 100); const amount = centsToDecimalString(totalCents * amountSign); diff --git a/components/dashboard/category-history-widget.tsx b/components/dashboard/category-history-widget.tsx new file mode 100644 index 0000000..9a33844 --- /dev/null +++ b/components/dashboard/category-history-widget.tsx @@ -0,0 +1,469 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + ChartContainer, + ChartTooltip, + type ChartConfig, +} from "@/components/ui/chart"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { WidgetEmptyState } from "@/components/widget-empty-state"; +import type { CategoryHistoryData } from "@/lib/dashboard/categories/category-history"; +import { getIconComponent } from "@/lib/utils/icons"; +import { RiBarChartBoxLine, RiCloseLine } from "@remixicon/react"; +import { ChevronDownIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; + +type CategoryHistoryWidgetProps = { + data: CategoryHistoryData; +}; + +const STORAGE_KEY_SELECTED = "dashboard-category-history-selected"; + +// Vibrant colors for categories +const CHART_COLORS = [ + "#ef4444", // red-500 + "#3b82f6", // blue-500 + "#10b981", // emerald-500 + "#f59e0b", // amber-500 + "#8b5cf6", // violet-500 +]; + +export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) { + const [selectedCategories, setSelectedCategories] = useState([]); + const [isClient, setIsClient] = useState(false); + const [open, setOpen] = useState(false); + + // Load selected categories from sessionStorage on mount + useEffect(() => { + setIsClient(true); + + const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED); + if (stored) { + try { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + const validCategories = parsed.filter((id) => + data.allCategories.some((cat) => cat.id === id) + ); + setSelectedCategories(validCategories.slice(0, 5)); + } + } catch (e) { + // Invalid JSON, ignore + } + } + }, [data.allCategories]); + + // Save to sessionStorage when selection changes + useEffect(() => { + if (isClient) { + sessionStorage.setItem( + STORAGE_KEY_SELECTED, + JSON.stringify(selectedCategories) + ); + } + }, [selectedCategories, isClient]); + + // Filter data to show only selected categories with vibrant colors + const filteredCategories = useMemo(() => { + return selectedCategories + .map((id, index) => { + const cat = data.categories.find((c) => c.id === id); + if (!cat) return null; + return { + ...cat, + color: CHART_COLORS[index % CHART_COLORS.length], + }; + }) + .filter(Boolean) as Array<{ + id: string; + name: string; + icon: string | null; + color: string; + data: Record; + }>; + }, [data.categories, selectedCategories]); + + // Filter chart data to include only selected categories + const filteredChartData = useMemo(() => { + if (filteredCategories.length === 0) { + return data.chartData.map((item) => ({ month: item.month })); + } + + return data.chartData.map((item) => { + const filtered: Record = { month: item.month }; + filteredCategories.forEach((category) => { + filtered[category.name] = item[category.name] || 0; + }); + return filtered; + }); + }, [data.chartData, filteredCategories]); + + // Build chart config dynamically from filtered categories + const chartConfig = useMemo(() => { + const config: ChartConfig = {}; + + filteredCategories.forEach((category) => { + config[category.name] = { + label: category.name, + color: category.color, + }; + }); + + return config; + }, [filteredCategories]); + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); + }; + + const formatCurrencyCompact = (value: number) => { + if (value >= 1000) { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + notation: "compact", + }).format(value); + } + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); + }; + + const handleAddCategory = (categoryId: string) => { + if ( + categoryId && + !selectedCategories.includes(categoryId) && + selectedCategories.length < 5 + ) { + setSelectedCategories([...selectedCategories, categoryId]); + setOpen(false); + } + }; + + const handleRemoveCategory = (categoryId: string) => { + setSelectedCategories(selectedCategories.filter((id) => id !== categoryId)); + }; + + const handleClearAll = () => { + setSelectedCategories([]); + }; + + const availableCategories = useMemo(() => { + return data.allCategories.filter( + (cat) => !selectedCategories.includes(cat.id) + ); + }, [data.allCategories, selectedCategories]); + + const selectedCategoryDetails = useMemo(() => { + return selectedCategories + .map((id) => data.allCategories.find((cat) => cat.id === id)) + .filter(Boolean); + }, [selectedCategories, data.allCategories]); + + const isEmpty = filteredCategories.length === 0; + + // Group available categories by type + const { despesaCategories, receitaCategories } = useMemo(() => { + const despesa = availableCategories.filter((cat) => cat.type === "despesa"); + const receita = availableCategories.filter((cat) => cat.type === "receita"); + return { despesaCategories: despesa, receitaCategories: receita }; + }, [availableCategories]); + + if (!isClient) { + return null; + } + + return ( + + +
+ {selectedCategoryDetails.length > 0 && ( +
+
+ {selectedCategoryDetails.map((category) => { + if (!category) return null; + const IconComponent = category.icon + ? getIconComponent(category.icon) + : null; + const colorIndex = selectedCategories.indexOf(category.id); + const color = CHART_COLORS[colorIndex % CHART_COLORS.length]; + + return ( +
+ {IconComponent ? ( + + ) : ( +
+ )} + {category.name} + +
+ ); + })} +
+
+ + {selectedCategories.length}/5 selecionadas + + +
+
+ )} + + {selectedCategories.length < 5 && availableCategories.length > 0 && ( + + + + + + + + + Nenhuma categoria encontrada. + + {despesaCategories.length > 0 && ( + + {despesaCategories.map((category) => { + const IconComponent = category.icon + ? getIconComponent(category.icon) + : null; + return ( + handleAddCategory(category.id)} + className="gap-2" + > + {IconComponent ? ( + + ) : ( +
+ )} + {category.name} + + ); + })} + + )} + + {receitaCategories.length > 0 && ( + + {receitaCategories.map((category) => { + const IconComponent = category.icon + ? getIconComponent(category.icon) + : null; + return ( + handleAddCategory(category.id)} + className="gap-2" + > + {IconComponent ? ( + + ) : ( +
+ )} + {category.name} + + ); + })} + + )} + + + + + )} +
+ + {isEmpty ? ( +
+ + } + title="Selecione categorias para visualizar" + description="Escolha até 5 categorias para acompanhar o histórico nos últimos 6 meses." + /> +
+ ) : ( + + + + {filteredCategories.map((category) => ( + + + + + ))} + + + + + { + if (!active || !payload || payload.length === 0) { + return null; + } + + // Sort payload by value (descending) + const sortedPayload = [...payload].sort( + (a, b) => (b.value as number) - (a.value as number) + ); + + return ( +
+
+ {payload[0].payload.month} +
+
+ {sortedPayload + .filter((entry) => (entry.value as number) > 0) + .map((entry) => { + const config = + chartConfig[ + entry.dataKey as keyof typeof chartConfig + ]; + const value = entry.value as number; + + return ( +
+
+
+ + {config?.label} + +
+ + {formatCurrency(value)} + +
+ ); + })} +
+
+ ); + }} + cursor={{ + stroke: "hsl(var(--muted-foreground))", + strokeWidth: 1, + }} + /> + {filteredCategories.map((category) => ( + + ))} + + + )} + + + ); +} diff --git a/components/lancamentos/dialogs/lancamento-details-dialog.tsx b/components/lancamentos/dialogs/lancamento-details-dialog.tsx index 19a5b6d..3f20c70 100644 --- a/components/lancamentos/dialogs/lancamento-details-dialog.tsx +++ b/components/lancamentos/dialogs/lancamento-details-dialog.tsx @@ -17,6 +17,7 @@ import { } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { getPaymentMethodIcon } from "@/lib/utils/icons"; +import { parseLocalDateString } from "@/lib/utils/date"; import { currencyFormatter, formatCondition, @@ -143,7 +144,7 @@ export function LancamentoDetailsDialog({ {isInstallment && (
  • - {subItem.avatarUrl !== undefined ? ( + {subItem.icon ? ( + + ) : subItem.avatarUrl !== undefined ? ( ; +}; + +export type CategoryHistoryData = { + months: string[]; // ["NOV", "DEZ", "JAN", ...] + categories: CategoryHistoryItem[]; + chartData: Array<{ + month: string; + [categoryName: string]: number | string; + }>; + allCategories: CategoryOption[]; +}; + +const CHART_COLORS = [ + "#ef4444", // red-500 + "#3b82f6", // blue-500 + "#10b981", // emerald-500 + "#f59e0b", // amber-500 + "#8b5cf6", // violet-500 +]; + +export async function fetchAllCategories( + userId: string +): Promise { + const result = await db + .select({ + id: categorias.id, + name: categorias.name, + icon: categorias.icon, + type: categorias.type, + }) + .from(categorias) + .where(eq(categorias.userId, userId)) + .orderBy(categorias.type, categorias.name); + + return result as CategoryOption[]; +} + +/** + * Fetches category expense/income history for all categories with transactions + * Widget will allow user to select up to 5 to display + */ +export async function fetchCategoryHistory( + userId: string, + currentPeriod: string +): Promise { + // Generate last 6 months including current + const periods: string[] = []; + const monthLabels: string[] = []; + + const [year, month] = currentPeriod.split("-").map(Number); + const currentDate = new Date(year, month - 1, 1); + + for (let i = 8; i >= 0; i--) { + const date = addMonths(currentDate, -i); + const period = format(date, "yyyy-MM"); + const label = format(date, "MMM", { locale: ptBR }).toUpperCase(); + periods.push(period); + monthLabels.push(label); + } + + // Fetch all categories for the selector + const allCategories = await fetchAllCategories(userId); + + // Fetch monthly data for ALL categories with transactions + const monthlyDataQuery = await db + .select({ + categoryId: categorias.id, + categoryName: categorias.name, + categoryIcon: categorias.icon, + period: lancamentos.period, + totalAmount: sql`SUM(ABS(${lancamentos.amount}))`.as( + "total_amount" + ), + }) + .from(lancamentos) + .innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(categorias.userId, userId), + inArray(lancamentos.period, periods), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + .groupBy( + categorias.id, + categorias.name, + categorias.icon, + lancamentos.period + ); + + if (monthlyDataQuery.length === 0) { + return { + months: monthLabels, + categories: [], + chartData: monthLabels.map((month) => ({ month })), + allCategories, + }; + } + + // Get unique categories from query results + const uniqueCategories = Array.from( + new Map( + monthlyDataQuery.map((row) => [ + row.categoryId, + { + id: row.categoryId, + name: row.categoryName, + icon: row.categoryIcon, + }, + ]) + ).values() + ); + + // Transform data into chart-ready format + const categoriesMap = new Map< + string, + { + id: string; + name: string; + icon: string | null; + color: string; + data: Record; + } + >(); + + // Initialize ALL categories with transactions with all months set to 0 + uniqueCategories.forEach((cat, index) => { + const monthData: Record = {}; + periods.forEach((period, periodIndex) => { + monthData[monthLabels[periodIndex]] = 0; + }); + + categoriesMap.set(cat.id, { + id: cat.id, + name: cat.name, + icon: cat.icon, + color: CHART_COLORS[index % CHART_COLORS.length], + data: monthData, + }); + }); + + // Fill in actual values from monthly data + monthlyDataQuery.forEach((row) => { + const category = categoriesMap.get(row.categoryId); + if (category) { + const periodIndex = periods.indexOf(row.period); + if (periodIndex !== -1) { + const monthLabel = monthLabels[periodIndex]; + category.data[monthLabel] = toNumber(row.totalAmount); + } + } + }); + + // Convert to chart data format + const chartData = monthLabels.map((month) => { + const dataPoint: Record = { month }; + + categoriesMap.forEach((category) => { + dataPoint[category.name] = category.data[month]; + }); + + return dataPoint; + }); + + return { + months: monthLabels, + categories: Array.from(categoriesMap.values()), + chartData, + allCategories, + }; +} diff --git a/lib/utils/date.ts b/lib/utils/date.ts index bb8a937..62af015 100644 --- a/lib/utils/date.ts +++ b/lib/utils/date.ts @@ -41,6 +41,26 @@ const MONTH_NAMES = [ // DATE CREATION & MANIPULATION // ============================================================================ +/** + * Safely parses a date string (YYYY-MM-DD) as a local date + * + * IMPORTANT: new Date("2025-11-25") treats the date as UTC midnight, + * which in Brazil (UTC-3) becomes 2025-11-26 03:00 local time! + * + * This function always interprets the date string in the local timezone. + * + * @param dateString - Date string in YYYY-MM-DD format + * @returns Date object in local timezone + */ +export function parseLocalDateString(dateString: string): Date { + const [year, month, day] = dateString.split("-"); + return new Date( + Number.parseInt(year ?? "0", 10), + Number.parseInt(month ?? "1", 10) - 1, + Number.parseInt(day ?? "1", 10) + ); +} + /** * Gets today's date in UTC * @returns Date object set to today at midnight UTC @@ -110,7 +130,7 @@ export function getTodayDateString(): string { * @returns Date object for today */ export function getTodayDate(): Date { - return new Date(getTodayDateString()); + return parseLocalDateString(getTodayDateString()); } /** @@ -119,12 +139,12 @@ export function getTodayDate(): Date { */ export function getTodayInfo(): { date: Date; period: string } { const now = new Date(); - const year = now.getUTCFullYear(); - const month = now.getUTCMonth(); - const day = now.getUTCDate(); + const year = now.getFullYear(); + const month = now.getMonth(); + const day = now.getDate(); return { - date: new Date(Date.UTC(year, month, day)), + date: new Date(year, month, day), period: `${year}-${String(month + 1).padStart(2, "0")}`, }; } @@ -162,12 +182,7 @@ export function addMonthsToDate(value: Date, offset: number): Date { * formatDate("2024-11-14") // "qui 14 nov" */ export function formatDate(value: string): string { - const [year, month, day] = value.split("-"); - const parsed = new Date( - Number.parseInt(year ?? "0", 10), - Number.parseInt(month ?? "1", 10) - 1, - Number.parseInt(day ?? "1", 10) - ); + const parsed = parseLocalDateString(value); return new Intl.DateTimeFormat("pt-BR", { weekday: "short", diff --git a/lib/utils/period/index.ts b/lib/utils/period/index.ts index 4cf9782..795b87c 100644 --- a/lib/utils/period/index.ts +++ b/lib/utils/period/index.ts @@ -334,10 +334,24 @@ export function formatMonthLabel(period: string): string { * derivePeriodFromDate() // current period */ export function derivePeriodFromDate(value?: string | null): string { - const date = value ? new Date(value) : new Date(); + if (!value) { + return getCurrentPeriod(); + } + + // Parse date string as local date to avoid timezone issues + // IMPORTANT: new Date("2025-11-25") treats the date as UTC midnight, + // which in Brazil (UTC-3) becomes 2025-11-26 03:00 local time! + const [year, month, day] = value.split("-"); + const date = new Date( + Number.parseInt(year ?? "0", 10), + Number.parseInt(month ?? "1", 10) - 1, + Number.parseInt(day ?? "1", 10) + ); + if (Number.isNaN(date.getTime())) { return getCurrentPeriod(); } + return formatPeriod(date.getFullYear(), date.getMonth() + 1); }