feat: implement category history widget and loading state for category history page
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
|||||||
type InvoicePaymentStatus,
|
type InvoicePaymentStatus,
|
||||||
} from "@/lib/faturas";
|
} from "@/lib/faturas";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
|
import { parseLocalDateString } from "@/lib/utils/date";
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -157,7 +158,7 @@ export async function updateInvoicePaymentStatusAction(
|
|||||||
if (adminPagador) {
|
if (adminPagador) {
|
||||||
// Usar a data customizada ou a data atual como data de pagamento
|
// Usar a data customizada ou a data atual como data de pagamento
|
||||||
const invoiceDate = data.paymentDate
|
const invoiceDate = data.paymentDate
|
||||||
? new Date(data.paymentDate)
|
? parseLocalDateString(data.paymentDate)
|
||||||
: new Date();
|
: new Date();
|
||||||
|
|
||||||
const amount = `-${formatDecimal(adminShare)}`;
|
const amount = `-${formatDecimal(adminShare)}`;
|
||||||
@@ -273,7 +274,7 @@ export async function updatePaymentDateAction(
|
|||||||
await tx
|
await tx
|
||||||
.update(lancamentos)
|
.update(lancamentos)
|
||||||
.set({
|
.set({
|
||||||
purchaseDate: new Date(data.paymentDate),
|
purchaseDate: parseLocalDateString(data.paymentDate),
|
||||||
})
|
})
|
||||||
.where(eq(lancamentos.id, existingPayment.id));
|
.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";
|
} from "@/lib/pagadores/notifications";
|
||||||
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
||||||
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
|
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 { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -32,7 +36,7 @@ const resolvePeriod = (purchaseDate: string, period?: string | null) => {
|
|||||||
return period;
|
return period;
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(purchaseDate);
|
const date = parseLocalDateString(purchaseDate);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
throw new Error("Data da transação inválida.");
|
throw new Error("Data da transação inválida.");
|
||||||
}
|
}
|
||||||
@@ -42,8 +46,6 @@ const resolvePeriod = (purchaseDate: string, period?: string | null) => {
|
|||||||
return `${year}-${month}`;
|
return `${year}-${month}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTodayDate = () => new Date(getTodayDateString());
|
|
||||||
|
|
||||||
const baseFields = z.object({
|
const baseFields = z.object({
|
||||||
purchaseDate: z
|
purchaseDate: z
|
||||||
.string({ message: "Informe a data da transação." })
|
.string({ message: "Informe a data da transação." })
|
||||||
@@ -471,13 +473,13 @@ export async function createLancamentoAction(
|
|||||||
const data = createSchema.parse(input);
|
const data = createSchema.parse(input);
|
||||||
|
|
||||||
const period = resolvePeriod(data.purchaseDate, data.period);
|
const period = resolvePeriod(data.purchaseDate, data.period);
|
||||||
const purchaseDate = new Date(data.purchaseDate);
|
const purchaseDate = parseLocalDateString(data.purchaseDate);
|
||||||
const dueDate = data.dueDate ? new Date(data.dueDate) : null;
|
const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null;
|
||||||
const shouldSetBoletoPaymentDate =
|
const shouldSetBoletoPaymentDate =
|
||||||
data.paymentMethod === "Boleto" && (data.isSettled ?? false);
|
data.paymentMethod === "Boleto" && (data.isSettled ?? false);
|
||||||
const boletoPaymentDate = shouldSetBoletoPaymentDate
|
const boletoPaymentDate = shouldSetBoletoPaymentDate
|
||||||
? data.boletoPaymentDate
|
? data.boletoPaymentDate
|
||||||
? new Date(data.boletoPaymentDate)
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
: getTodayDate()
|
: getTodayDate()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -603,7 +605,7 @@ export async function updateLancamentoAction(
|
|||||||
data.paymentMethod === "Boleto" && Boolean(normalizedSettled);
|
data.paymentMethod === "Boleto" && Boolean(normalizedSettled);
|
||||||
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
|
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
|
||||||
? data.boletoPaymentDate
|
? data.boletoPaymentDate
|
||||||
? new Date(data.boletoPaymentDate)
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
: getTodayDate()
|
: getTodayDate()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -611,7 +613,7 @@ export async function updateLancamentoAction(
|
|||||||
.update(lancamentos)
|
.update(lancamentos)
|
||||||
.set({
|
.set({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
purchaseDate: new Date(data.purchaseDate),
|
purchaseDate: parseLocalDateString(data.purchaseDate),
|
||||||
transactionType: data.transactionType,
|
transactionType: data.transactionType,
|
||||||
amount: normalizedAmount,
|
amount: normalizedAmount,
|
||||||
condition: data.condition,
|
condition: data.condition,
|
||||||
@@ -624,7 +626,7 @@ export async function updateLancamentoAction(
|
|||||||
isSettled: normalizedSettled,
|
isSettled: normalizedSettled,
|
||||||
installmentCount: data.installmentCount ?? null,
|
installmentCount: data.installmentCount ?? null,
|
||||||
recurrenceCount: data.recurrenceCount ?? null,
|
recurrenceCount: data.recurrenceCount ?? null,
|
||||||
dueDate: data.dueDate ? new Date(data.dueDate) : null,
|
dueDate: data.dueDate ? parseLocalDateString(data.dueDate) : null,
|
||||||
boletoPaymentDate: boletoPaymentDateValue,
|
boletoPaymentDate: boletoPaymentDateValue,
|
||||||
period,
|
period,
|
||||||
})
|
})
|
||||||
@@ -963,14 +965,14 @@ export async function updateLancamentoBulkAction(
|
|||||||
|
|
||||||
const baseDueDate =
|
const baseDueDate =
|
||||||
hasDueDateUpdate && data.dueDate
|
hasDueDateUpdate && data.dueDate
|
||||||
? new Date(data.dueDate)
|
? parseLocalDateString(data.dueDate)
|
||||||
: hasDueDateUpdate
|
: hasDueDateUpdate
|
||||||
? null
|
? null
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const baseBoletoPaymentDate =
|
const baseBoletoPaymentDate =
|
||||||
hasBoletoPaymentDateUpdate && data.boletoPaymentDate
|
hasBoletoPaymentDateUpdate && data.boletoPaymentDate
|
||||||
? new Date(data.boletoPaymentDate)
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
: hasBoletoPaymentDateUpdate
|
: hasBoletoPaymentDateUpdate
|
||||||
? null
|
? null
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1192,7 +1194,7 @@ export async function createMassLancamentosAction(
|
|||||||
|
|
||||||
const period =
|
const period =
|
||||||
data.fixedFields.period ?? resolvePeriod(transaction.purchaseDate);
|
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 amountSign: 1 | -1 = transactionType === "Despesa" ? -1 : 1;
|
||||||
const totalCents = Math.round(Math.abs(transaction.amount) * 100);
|
const totalCents = Math.round(Math.abs(transaction.amount) * 100);
|
||||||
const amount = centsToDecimalString(totalCents * amountSign);
|
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";
|
} from "@/components/ui/dialog";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { getPaymentMethodIcon } from "@/lib/utils/icons";
|
import { getPaymentMethodIcon } from "@/lib/utils/icons";
|
||||||
|
import { parseLocalDateString } from "@/lib/utils/date";
|
||||||
import {
|
import {
|
||||||
currencyFormatter,
|
currencyFormatter,
|
||||||
formatCondition,
|
formatCondition,
|
||||||
@@ -143,7 +144,7 @@ export function LancamentoDetailsDialog({
|
|||||||
{isInstallment && (
|
{isInstallment && (
|
||||||
<li className="mt-4">
|
<li className="mt-4">
|
||||||
<InstallmentTimeline
|
<InstallmentTimeline
|
||||||
purchaseDate={new Date(lancamento.purchaseDate)}
|
purchaseDate={parseLocalDateString(lancamento.purchaseDate)}
|
||||||
currentInstallment={parcelaAtual}
|
currentInstallment={parcelaAtual}
|
||||||
totalInstallments={totalParcelas}
|
totalInstallments={totalParcelas}
|
||||||
period={lancamento.period}
|
period={lancamento.period}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
RiDashboardLine,
|
RiDashboardLine,
|
||||||
RiFundsLine,
|
RiFundsLine,
|
||||||
RiGroupLine,
|
RiGroupLine,
|
||||||
|
RiLineChartLine,
|
||||||
RiPriceTag3Line,
|
RiPriceTag3Line,
|
||||||
RiSettingsLine,
|
RiSettingsLine,
|
||||||
RiSparklingLine,
|
RiSparklingLine,
|
||||||
@@ -19,6 +20,7 @@ export type SidebarSubItem = {
|
|||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
key?: string;
|
key?: string;
|
||||||
|
icon?: RemixiconComponentType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SidebarItem = {
|
export type SidebarItem = {
|
||||||
@@ -65,6 +67,8 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
|
|||||||
a.title.localeCompare(b.title, "pt-BR", { sensitivity: "base" })
|
a.title.localeCompare(b.title, "pt-BR", { sensitivity: "base" })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pagadorItemsWithHistory: SidebarSubItem[] = pagadorItems;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
@@ -114,12 +118,20 @@ export function createSidebarNavData(pagadores: PagadorLike[]): SidebarNavData {
|
|||||||
title: "Pagadores",
|
title: "Pagadores",
|
||||||
url: "/pagadores",
|
url: "/pagadores",
|
||||||
icon: RiGroupLine,
|
icon: RiGroupLine,
|
||||||
items: pagadorItems,
|
items: pagadorItemsWithHistory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Categorias",
|
title: "Categorias",
|
||||||
url: "/categorias",
|
url: "/categorias",
|
||||||
icon: RiPriceTag3Line,
|
icon: RiPriceTag3Line,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Histórico",
|
||||||
|
url: "/categorias/historico",
|
||||||
|
key: "historico-categorias",
|
||||||
|
icon: RiLineChartLine,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type NavItem = {
|
|||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
key?: string;
|
key?: string;
|
||||||
|
icon?: RemixiconComponentType;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -158,7 +159,9 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
|
|||||||
href={buildHrefWithPeriod(subItem.url)}
|
href={buildHrefWithPeriod(subItem.url)}
|
||||||
className="flex items-center gap-2"
|
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">
|
<Avatar className="size-5 border border-border/60 bg-background">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={avatarSrc}
|
src={avatarSrc}
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ function parseYYYYMMDD(dateString: string): Date | undefined {
|
|||||||
return 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})$/);
|
const ymdMatch = dateString.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
if (ymdMatch) {
|
if (ymdMatch) {
|
||||||
const [, year, month, day] = ymdMatch;
|
const [, year, month, day] = ymdMatch;
|
||||||
@@ -55,9 +57,9 @@ function parseYYYYMMDD(dateString: string): Date | undefined {
|
|||||||
return isValidDate(date) ? date : undefined;
|
return isValidDate(date) ? date : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback para Date parser nativo
|
// For other formats, return undefined instead of using native parser
|
||||||
const date = new Date(dateString);
|
// to avoid timezone issues
|
||||||
return isValidDate(date) ? date : undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatePickerProps {
|
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
|
// 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
|
* Gets today's date in UTC
|
||||||
* @returns Date object set to today at midnight UTC
|
* @returns Date object set to today at midnight UTC
|
||||||
@@ -110,7 +130,7 @@ export function getTodayDateString(): string {
|
|||||||
* @returns Date object for today
|
* @returns Date object for today
|
||||||
*/
|
*/
|
||||||
export function getTodayDate(): Date {
|
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 } {
|
export function getTodayInfo(): { date: Date; period: string } {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getUTCFullYear();
|
const year = now.getFullYear();
|
||||||
const month = now.getUTCMonth();
|
const month = now.getMonth();
|
||||||
const day = now.getUTCDate();
|
const day = now.getDate();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: new Date(Date.UTC(year, month, day)),
|
date: new Date(year, month, day),
|
||||||
period: `${year}-${String(month + 1).padStart(2, "0")}`,
|
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"
|
* formatDate("2024-11-14") // "qui 14 nov"
|
||||||
*/
|
*/
|
||||||
export function formatDate(value: string): string {
|
export function formatDate(value: string): string {
|
||||||
const [year, month, day] = value.split("-");
|
const parsed = parseLocalDateString(value);
|
||||||
const parsed = new Date(
|
|
||||||
Number.parseInt(year ?? "0", 10),
|
|
||||||
Number.parseInt(month ?? "1", 10) - 1,
|
|
||||||
Number.parseInt(day ?? "1", 10)
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat("pt-BR", {
|
return new Intl.DateTimeFormat("pt-BR", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
|
|||||||
@@ -334,10 +334,24 @@ export function formatMonthLabel(period: string): string {
|
|||||||
* derivePeriodFromDate() // current period
|
* derivePeriodFromDate() // current period
|
||||||
*/
|
*/
|
||||||
export function derivePeriodFromDate(value?: string | null): string {
|
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())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
return getCurrentPeriod();
|
return getCurrentPeriod();
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user