Sanitized mirror from private repository - 2026-04-20 01:24:42 UTC
This commit is contained in:
162
dashboard/ui/app/expenses/page.tsx
Normal file
162
dashboard/ui/app/expenses/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { usePoll } from "@/lib/use-poll";
|
||||
import type { ExpenseSummary } from "@/lib/types";
|
||||
import { StatCard } from "@/components/stat-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DataTable, Column } from "@/components/data-table";
|
||||
import { CardSkeleton } from "@/components/skeleton";
|
||||
|
||||
interface Transaction {
|
||||
date: string;
|
||||
vendor: string;
|
||||
amount: string | number;
|
||||
currency?: string;
|
||||
order_number?: string;
|
||||
email_account?: string;
|
||||
message_id?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function getExpenseAccountColor(name: string): string {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.includes("gmail") || lower.includes("lzbellina")) return "text-blue-400";
|
||||
if (lower.includes("dvish")) return "text-amber-400";
|
||||
if (lower.includes("proton") || lower.includes("admin")) return "text-violet-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const { data: summary } = usePoll<ExpenseSummary>(
|
||||
"/api/expenses/summary",
|
||||
120000
|
||||
);
|
||||
const { data: expenseData } = usePoll<Transaction[] | { count: number; expenses: Transaction[] }>(
|
||||
"/api/expenses",
|
||||
120000
|
||||
);
|
||||
const transactions = Array.isArray(expenseData) ? expenseData : (expenseData?.expenses ?? []);
|
||||
|
||||
const maxVendor =
|
||||
summary?.top_vendors.reduce(
|
||||
(max, v) => Math.max(max, v.amount),
|
||||
0
|
||||
) ?? 1;
|
||||
|
||||
const txColumns: Column<Transaction>[] = [
|
||||
{ key: "date", label: "Date" },
|
||||
{
|
||||
key: "vendor",
|
||||
label: "Vendor",
|
||||
render: (row) => (
|
||||
<span className="font-medium text-foreground">{row.vendor}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "Amount",
|
||||
render: (row) => {
|
||||
const amt = Number(row.amount || 0);
|
||||
return (
|
||||
<span className={amt >= 0 ? "text-green-400" : "text-red-400"}>
|
||||
${amt.toFixed(2)} {row.currency ?? ""}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: "order_number", label: "Order #" },
|
||||
{
|
||||
key: "email_account",
|
||||
label: "Account",
|
||||
render: (row) => (
|
||||
<span className={`truncate max-w-[120px] block text-xs ${getExpenseAccountColor(String(row.email_account ?? ""))}`}>
|
||||
{String(row.email_account ?? "")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const accounts = transactions
|
||||
? [...new Set(transactions.map((t) => String(t.email_account ?? "")).filter(Boolean))]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-lg font-semibold">Expenses</h1>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<StatCard
|
||||
label="Total Spend"
|
||||
value={summary ? `$${summary.total.toFixed(2)}` : "--"}
|
||||
sub={summary?.month}
|
||||
/>
|
||||
<StatCard
|
||||
label="Transactions"
|
||||
value={summary?.count ?? "--"}
|
||||
sub="this month"
|
||||
/>
|
||||
<StatCard
|
||||
label="Top Vendor"
|
||||
value={
|
||||
summary?.top_vendors?.[0]?.vendor ?? "--"
|
||||
}
|
||||
sub={
|
||||
summary?.top_vendors?.[0]
|
||||
? `$${summary.top_vendors[0].amount.toFixed(2)}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top Vendors Bar Chart */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">Top Vendors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!summary ? (
|
||||
<CardSkeleton lines={4} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{summary.top_vendors.map((v) => (
|
||||
<div key={v.vendor} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground">{v.vendor}</span>
|
||||
<span className="text-green-400">
|
||||
${v.amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="glass-bar-track h-2">
|
||||
<div
|
||||
className="h-full glass-bar-fill bg-gradient-to-r from-blue-500 to-violet-500 transition-all duration-700"
|
||||
style={{
|
||||
width: `${(v.amount / maxVendor) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transactions Table */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable<Transaction>
|
||||
data={transactions ?? []}
|
||||
columns={txColumns}
|
||||
searchKey="vendor"
|
||||
filterKey="email_account"
|
||||
filterOptions={accounts}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user