feat: implement category history widget and loading state for category history page
This commit is contained in:
@@ -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));
|
||||
});
|
||||
|
||||
33
app/(dashboard)/categorias/historico/loading.tsx
Normal file
33
app/(dashboard)/categorias/historico/loading.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6 px-6">
|
||||
<Card className="h-auto">
|
||||
<CardContent className="space-y-2.5">
|
||||
<div className="space-y-2">
|
||||
{/* Selected categories and counter */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-8 w-32 rounded-md" />
|
||||
<Skeleton className="h-8 w-40 rounded-md" />
|
||||
<Skeleton className="h-8 w-36 rounded-md" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-1.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-6 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category selector button */}
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<Skeleton className="h-[450px] w-full rounded-lg" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
21
app/(dashboard)/categorias/historico/page.tsx
Normal file
21
app/(dashboard)/categorias/historico/page.tsx
Normal file
@@ -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 (
|
||||
<main className="flex flex-col gap-6">
|
||||
<p className="text-muted-foreground">
|
||||
Acompanhe o histórico de desempenho das suas categorias ao longo de 9
|
||||
meses.
|
||||
</p>
|
||||
<CategoryHistoryWidget data={data} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
469
components/dashboard/category-history-widget.tsx
Normal file
469
components/dashboard/category-history-widget.tsx
Normal file
@@ -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<string[]>([]);
|
||||
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<string, number>;
|
||||
}>;
|
||||
}, [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<string, number | string> = { 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 (
|
||||
<Card className="h-auto">
|
||||
<CardContent className="space-y-2.5">
|
||||
<div className="space-y-2">
|
||||
{selectedCategoryDetails.length > 0 && (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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 (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
style={{ borderColor: color }}
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent className="size-4" style={{ color }} />
|
||||
) : (
|
||||
<div
|
||||
className="size-3 rounded-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-foreground">{category.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||
onClick={() => handleRemoveCategory(category.id)}
|
||||
>
|
||||
<RiCloseLine className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-1.5">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{selectedCategories.length}/5 selecionadas
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCategories.length < 5 && availableCategories.length > 0 && (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between hover:scale-none"
|
||||
>
|
||||
Selecionar categorias
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-(--radix-popover-trigger-width) p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Pesquisar categoria..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
|
||||
|
||||
{despesaCategories.length > 0 && (
|
||||
<CommandGroup heading="Despesas">
|
||||
{despesaCategories.map((category) => {
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.name}
|
||||
onSelect={() => handleAddCategory(category.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent className="size-4 text-red-600" />
|
||||
) : (
|
||||
<div className="size-3 rounded-sm bg-red-600" />
|
||||
)}
|
||||
<span>{category.name}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{receitaCategories.length > 0 && (
|
||||
<CommandGroup heading="Receitas">
|
||||
{receitaCategories.map((category) => {
|
||||
const IconComponent = category.icon
|
||||
? getIconComponent(category.icon)
|
||||
: null;
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.name}
|
||||
onSelect={() => handleAddCategory(category.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
{IconComponent ? (
|
||||
<IconComponent className="size-4 text-green-600" />
|
||||
) : (
|
||||
<div className="size-3 rounded-sm bg-green-600" />
|
||||
)}
|
||||
<span>{category.name}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="h-[450px] flex items-center justify-center">
|
||||
<WidgetEmptyState
|
||||
icon={
|
||||
<RiBarChartBoxLine className="size-6 text-muted-foreground" />
|
||||
}
|
||||
title="Selecione categorias para visualizar"
|
||||
description="Escolha até 5 categorias para acompanhar o histórico nos últimos 6 meses."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="h-[450px] w-full">
|
||||
<AreaChart
|
||||
data={filteredChartData}
|
||||
margin={{ top: 10, right: 20, left: 10, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
{filteredCategories.map((category) => (
|
||||
<linearGradient
|
||||
key={`gradient-${category.id}`}
|
||||
id={`gradient-${category.id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={category.color}
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor={category.color}
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
className="text-xs"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
className="text-xs"
|
||||
tickFormatter={formatCurrencyCompact}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={({ active, payload }) => {
|
||||
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 (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
{payload[0].payload.month}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{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 (
|
||||
<div
|
||||
key={entry.dataKey}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: config?.color }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
{config?.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium tabular-nums">
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
cursor={{
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
/>
|
||||
{filteredCategories.map((category) => (
|
||||
<Area
|
||||
key={category.id}
|
||||
type="monotone"
|
||||
dataKey={category.name}
|
||||
stroke={category.color}
|
||||
strokeWidth={1}
|
||||
fill={`url(#gradient-${category.id})`}
|
||||
fillOpacity={1}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
fill: category.color,
|
||||
stroke: "hsl(var(--background))",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<li className="mt-4">
|
||||
<InstallmentTimeline
|
||||
purchaseDate={new Date(lancamento.purchaseDate)}
|
||||
purchaseDate={parseLocalDateString(lancamento.purchaseDate)}
|
||||
currentInstallment={parcelaAtual}
|
||||
totalInstallments={totalParcelas}
|
||||
period={lancamento.period}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RiDashboardLine,
|
||||
RiFundsLine,
|
||||
RiGroupLine,
|
||||
RiLineChartLine,
|
||||
RiPriceTag3Line,
|
||||
RiSettingsLine,
|
||||
RiSparklingLine,
|
||||
@@ -19,6 +20,7 @@ export type SidebarSubItem = {
|
||||
avatarUrl?: string | null;
|
||||
isShared?: boolean;
|
||||
key?: string;
|
||||
icon?: RemixiconComponentType;
|
||||
};
|
||||
|
||||
export type SidebarItem = {
|
||||
@@ -65,6 +67,8 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
|
||||
a.title.localeCompare(b.title, "pt-BR", { sensitivity: "base" })
|
||||
);
|
||||
|
||||
const pagadorItemsWithHistory: SidebarSubItem[] = pagadorItems;
|
||||
|
||||
return {
|
||||
navMain: [
|
||||
{
|
||||
@@ -114,12 +118,20 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
|
||||
title: "Pagadores",
|
||||
url: "/pagadores",
|
||||
icon: RiGroupLine,
|
||||
items: pagadorItems,
|
||||
items: pagadorItemsWithHistory,
|
||||
},
|
||||
{
|
||||
title: "Categorias",
|
||||
url: "/categorias",
|
||||
icon: RiPriceTag3Line,
|
||||
items: [
|
||||
{
|
||||
title: "Histórico",
|
||||
url: "/categorias/historico",
|
||||
key: "historico-categorias",
|
||||
icon: RiLineChartLine,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@ type NavItem = {
|
||||
avatarUrl?: string | null;
|
||||
isShared?: boolean;
|
||||
key?: string;
|
||||
icon?: RemixiconComponentType;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -158,7 +159,9 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
|
||||
href={buildHrefWithPeriod(subItem.url)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{subItem.avatarUrl !== undefined ? (
|
||||
{subItem.icon ? (
|
||||
<subItem.icon className="size-4" />
|
||||
) : subItem.avatarUrl !== undefined ? (
|
||||
<Avatar className="size-5 border border-border/60 bg-background">
|
||||
<AvatarImage
|
||||
src={avatarSrc}
|
||||
|
||||
@@ -47,7 +47,9 @@ function parseYYYYMMDD(dateString: string): Date | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Tenta formato YYYY-MM-DD primeiro
|
||||
// Parse YYYY-MM-DD format as 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!
|
||||
const ymdMatch = dateString.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (ymdMatch) {
|
||||
const [, year, month, day] = ymdMatch;
|
||||
@@ -55,9 +57,9 @@ function parseYYYYMMDD(dateString: string): Date | undefined {
|
||||
return isValidDate(date) ? date : undefined;
|
||||
}
|
||||
|
||||
// Fallback para Date parser nativo
|
||||
const date = new Date(dateString);
|
||||
return isValidDate(date) ? date : undefined;
|
||||
// For other formats, return undefined instead of using native parser
|
||||
// to avoid timezone issues
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface DatePickerProps {
|
||||
|
||||
201
lib/dashboard/categories/category-history.ts
Normal file
201
lib/dashboard/categories/category-history.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { categorias, lancamentos, pagadores } from "@/db/schema";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { toNumber } from "@/lib/dashboard/common";
|
||||
import { addMonths, format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
|
||||
export type CategoryOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: "receita" | "despesa";
|
||||
};
|
||||
|
||||
export type CategoryHistoryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string;
|
||||
data: Record<string, number>;
|
||||
};
|
||||
|
||||
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<CategoryOption[]> {
|
||||
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<CategoryHistoryData> {
|
||||
// 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<string>`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<string, number>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Initialize ALL categories with transactions with all months set to 0
|
||||
uniqueCategories.forEach((cat, index) => {
|
||||
const monthData: Record<string, number> = {};
|
||||
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<string, number | string> = { month };
|
||||
|
||||
categoriesMap.forEach((category) => {
|
||||
dataPoint[category.name] = category.data[month];
|
||||
});
|
||||
|
||||
return dataPoint;
|
||||
});
|
||||
|
||||
return {
|
||||
months: monthLabels,
|
||||
categories: Array.from(categoriesMap.values()),
|
||||
chartData,
|
||||
allCategories,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user