- Install @biomejs/biome as devDependency at root - Configure biome.json with 2-space indent, double quotes, Tailwind CSS support - Add lint/lint:fix/format/format:fix scripts to root and app package.json - Fix noNonNullAssertion: env vars extracted to variables with suppression, <div role=button> replaced with <button> - Fix noUnusedVariables: remove unused destructured vars - Fix useIterableCallbackReturn: arrow functions with block body - Fix noExplicitAny: recharts Tooltip formatters - Fix noLabelWithoutControl: add htmlFor+id or use <span> for non-input labels - Fix noStaticElementInteractions/useKeyWithClickEvents: role+keyboard events for overlays - Fix noArrayIndexKey: use error string as key - Fix CSS parse: enable tailwindDirectives parser - Normalize formatting across 80 files with biome check --write
231 lines
8.0 KiB
TypeScript
231 lines
8.0 KiB
TypeScript
import { useNavigate } from "@tanstack/react-router";
|
|
import { CalendarClock, ExternalLink } from "lucide-react";
|
|
import { useMemo } from "react";
|
|
import type { PendingUpcomingExpense } from "@/lib/api";
|
|
import { usePendingUpcomingExpenses } from "@/lib/queries";
|
|
|
|
type GroupedExpense = {
|
|
periodicExpenseId: number;
|
|
description: string;
|
|
totalAmount: number;
|
|
earliestDueDate: string;
|
|
dueType: "overdue" | "upcoming";
|
|
count: number;
|
|
};
|
|
|
|
function groupExpenses(expenses: PendingUpcomingExpense[]): GroupedExpense[] {
|
|
const map = new Map<number, GroupedExpense>();
|
|
|
|
for (const e of expenses) {
|
|
if (e.periodicExpenseId === null) continue;
|
|
|
|
const key = e.periodicExpenseId;
|
|
const existing = map.get(key);
|
|
|
|
if (existing) {
|
|
existing.totalAmount += Number(e.amount);
|
|
existing.count += 1;
|
|
if (e.dueDate < existing.earliestDueDate) {
|
|
existing.earliestDueDate = e.dueDate;
|
|
}
|
|
if (e.dueType === "overdue") {
|
|
existing.dueType = "overdue";
|
|
}
|
|
} else {
|
|
map.set(key, {
|
|
periodicExpenseId: key,
|
|
description: e.periodicExpense?.description ?? e.description,
|
|
totalAmount: Number(e.amount),
|
|
earliestDueDate: e.dueDate,
|
|
dueType: e.dueType,
|
|
count: 1,
|
|
});
|
|
}
|
|
}
|
|
|
|
return Array.from(map.values()).sort((a, b) => {
|
|
if (a.dueType !== b.dueType) return a.dueType === "overdue" ? -1 : 1;
|
|
return a.earliestDueDate.localeCompare(b.earliestDueDate);
|
|
});
|
|
}
|
|
|
|
function formatDate(value: string) {
|
|
return new Date(value).toLocaleDateString("es-AR", {
|
|
timeZone: "UTC",
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
function formatAmount(value: number) {
|
|
return `$${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
function DueTypeBadge({ dueType }: { dueType: "overdue" | "upcoming" }) {
|
|
return (
|
|
<span
|
|
className={
|
|
dueType === "overdue"
|
|
? "inline-flex h-5 shrink-0 items-center rounded-md bg-red-100 px-1.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
|
: "inline-flex h-5 shrink-0 items-center rounded-md bg-amber-100 px-1.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
|
}
|
|
>
|
|
{dueType === "overdue" ? "Vencido" : "Próximo"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function DashboardExpensesTable() {
|
|
const { data: expenses, isLoading } = usePendingUpcomingExpenses();
|
|
const navigate = useNavigate();
|
|
|
|
const groups = useMemo(() => {
|
|
if (!expenses) return [];
|
|
return groupExpenses(expenses);
|
|
}, [expenses]);
|
|
|
|
function goToExpenses(periodicExpenseId: number) {
|
|
navigate({ to: "/expenses", search: { periodicExpenseId } });
|
|
}
|
|
|
|
const overdueCount = groups.filter((g) => g.dueType === "overdue").length;
|
|
const upcomingCount = groups.filter((g) => g.dueType === "upcoming").length;
|
|
const grandTotal = groups.reduce((sum, g) => sum + g.totalAmount, 0);
|
|
const hasData = groups.length > 0;
|
|
|
|
return (
|
|
<div className="rounded-lg border">
|
|
<div className="flex items-center gap-2 border-b px-4 py-3">
|
|
<CalendarClock className="size-4 text-muted-foreground" />
|
|
<h2 className="text-sm font-medium">Gastos por vencer</h2>
|
|
{isLoading && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
Cargando...
|
|
</span>
|
|
)}
|
|
{!isLoading && hasData && (
|
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
|
{overdueCount} vencido{overdueCount !== 1 ? "s" : ""}
|
|
{upcomingCount > 0 &&
|
|
` · ${upcomingCount} próximo${upcomingCount !== 1 ? "s" : ""}`}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{!isLoading && !hasData && (
|
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
|
No hay gastos pendientes.
|
|
</div>
|
|
)}
|
|
|
|
{isLoading && (
|
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
|
Cargando...
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1 p-1 sm:hidden">
|
|
{groups.map((group) => (
|
|
<div
|
|
key={group.periodicExpenseId}
|
|
className="rounded-lg border p-3 text-sm"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => goToExpenses(group.periodicExpenseId)}
|
|
className="truncate font-medium text-left hover:underline cursor-pointer inline-flex items-center gap-1"
|
|
>
|
|
{group.description}
|
|
<ExternalLink className="size-3 shrink-0 text-muted-foreground" />
|
|
</button>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Vence {formatDate(group.earliestDueDate)}
|
|
{group.count > 1 && ` (${group.count} gastos)`}
|
|
</p>
|
|
</div>
|
|
<DueTypeBadge dueType={group.dueType} />
|
|
</div>
|
|
|
|
<p className="mt-3 text-lg font-semibold tabular-nums">
|
|
{formatAmount(group.totalAmount)}
|
|
</p>
|
|
</div>
|
|
))}
|
|
{hasData && (
|
|
<div className="flex items-center justify-between rounded-lg border bg-muted/50 px-3 py-2.5 text-sm font-medium">
|
|
<span>Total</span>
|
|
<span className="tabular-nums">{formatAmount(grandTotal)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="hidden overflow-x-auto sm:block">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b bg-muted/50">
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
|
Descripción
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
|
Monto
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
|
Vencimiento
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
|
Cantidad
|
|
</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
|
Estado
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{groups.map((group) => (
|
|
<tr
|
|
key={group.periodicExpenseId}
|
|
className="border-b last:border-0 hover:bg-muted/30"
|
|
>
|
|
<td className="px-3 py-2.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => goToExpenses(group.periodicExpenseId)}
|
|
className="font-medium text-left hover:underline cursor-pointer inline-flex items-center gap-1"
|
|
>
|
|
{group.description}
|
|
<ExternalLink className="size-3 shrink-0 text-muted-foreground" />
|
|
</button>
|
|
</td>
|
|
<td className="px-3 py-2.5 tabular-nums">
|
|
{formatAmount(group.totalAmount)}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-muted-foreground">
|
|
{formatDate(group.earliestDueDate)}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-muted-foreground tabular-nums">
|
|
{group.count > 1 ? `${group.count} gastos` : "1 gasto"}
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<DueTypeBadge dueType={group.dueType} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{hasData && (
|
|
<tr className="border-t bg-muted/50 font-medium">
|
|
<td className="px-3 py-2.5">Total</td>
|
|
<td className="px-3 py-2.5 tabular-nums">
|
|
{formatAmount(grandTotal)}
|
|
</td>
|
|
<td colSpan={3} />
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|