feat(merchants): scatter plot, merchant profiles, and per-merchant transaction history
- /merchants page with spend-vs-frequency scatter chart (click to open profile) - Merchant profile drawer: stats, monthly trend line, full transaction history - /api/analytics/merchants: split-adjusted merchant aggregates + monthly trends - /api/analytics/merchants/[merchant]: per-merchant transaction list - Add Merchants nav item to sidebar
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ merchant: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { merchant } = await params;
|
||||
const decoded = decodeURIComponent(merchant);
|
||||
|
||||
const transactions = await queryRaw<{
|
||||
id: number;
|
||||
transaction_date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
amount_aud: number | null;
|
||||
my_amount: number;
|
||||
transaction_type: string;
|
||||
category: string;
|
||||
bank_name: string;
|
||||
statement_id: number;
|
||||
}>(`
|
||||
SELECT
|
||||
t.id,
|
||||
t.transaction_date::text,
|
||||
t.description,
|
||||
t.amount,
|
||||
t.amount_aud,
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END::numeric(10,2) as my_amount,
|
||||
t.transaction_type,
|
||||
COALESCE(o.category_override, t.category) as category,
|
||||
s.bank_name,
|
||||
t.statement_id
|
||||
FROM transactions t
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
WHERE s.owner_id = $1
|
||||
AND t.transaction_type IN ('debit', 'fee')
|
||||
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
|
||||
ORDER BY t.transaction_date DESC
|
||||
LIMIT 500
|
||||
`, [user.id, decoded]);
|
||||
|
||||
return NextResponse.json({ transactions });
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const months = Math.min(24, Math.max(1, Number(searchParams.get("months") || "12")));
|
||||
|
||||
const cutoff = new Date();
|
||||
cutoff.setMonth(cutoff.getMonth() - months);
|
||||
const fromDate = cutoff.toISOString().slice(0, 10);
|
||||
|
||||
// Merchant aggregates — split-adjusted, debit transactions only
|
||||
const rows = await queryRaw<{
|
||||
merchant: string;
|
||||
category: string;
|
||||
transaction_count: number;
|
||||
total_spend: number;
|
||||
avg_transaction: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
months_active: number;
|
||||
}>(`
|
||||
SELECT
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
|
||||
MODE() WITHIN GROUP (ORDER BY COALESCE(o.category_override, t.category)) as category,
|
||||
COUNT(*)::int as transaction_count,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END
|
||||
)::numeric(12,2) as total_spend,
|
||||
AVG(
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END
|
||||
)::numeric(10,2) as avg_transaction,
|
||||
MIN(t.transaction_date)::text as first_seen,
|
||||
MAX(t.transaction_date)::text as last_seen,
|
||||
COUNT(DISTINCT TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM'))::int as months_active
|
||||
FROM transactions t
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
WHERE s.owner_id = $1
|
||||
AND t.transaction_type IN ('debit', 'fee')
|
||||
AND t.transaction_date >= $2
|
||||
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
||||
GROUP BY 1
|
||||
HAVING SUM(
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END
|
||||
) > 0
|
||||
ORDER BY total_spend DESC
|
||||
LIMIT 200
|
||||
`, [user.id, fromDate]);
|
||||
|
||||
// Monthly trend per merchant (top 50 by spend)
|
||||
const topMerchants = rows.slice(0, 50).map((r) => r.merchant);
|
||||
|
||||
interface TrendRow { merchant: string; month: string; total: number }
|
||||
let trendRows: TrendRow[] = [];
|
||||
if (topMerchants.length > 0) {
|
||||
trendRows = await queryRaw<TrendRow>(`
|
||||
SELECT
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
|
||||
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END
|
||||
)::numeric(10,2) as total
|
||||
FROM transactions t
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
WHERE s.owner_id = $1
|
||||
AND t.transaction_type IN ('debit', 'fee')
|
||||
AND t.transaction_date >= $2
|
||||
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
|
||||
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`, [user.id, fromDate, topMerchants]);
|
||||
}
|
||||
|
||||
// Group trend data by merchant
|
||||
const trendByMerchant: Record<string, Record<string, number>> = {};
|
||||
for (const tr of trendRows) {
|
||||
if (!trendByMerchant[tr.merchant]) trendByMerchant[tr.merchant] = {};
|
||||
trendByMerchant[tr.merchant][tr.month] = Number(tr.total);
|
||||
}
|
||||
|
||||
const merchants = rows.map((r) => ({
|
||||
...r,
|
||||
transaction_count: Number(r.transaction_count),
|
||||
total_spend: Number(r.total_spend),
|
||||
avg_transaction: Number(r.avg_transaction),
|
||||
months_active: Number(r.months_active),
|
||||
monthly_trend: trendByMerchant[r.merchant] || {},
|
||||
}));
|
||||
|
||||
return NextResponse.json({ merchants, months });
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
LineChart,
|
||||
Line,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import { useMerchants, useMerchantTransactions, MerchantRow } from "@/lib/hooks";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
|
||||
function fmt(n: number) {
|
||||
return new Intl.NumberFormat("en-AU", {
|
||||
style: "currency",
|
||||
currency: "AUD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
}
|
||||
function fmtExact(n: number) {
|
||||
return new Intl.NumberFormat("en-AU", {
|
||||
style: "currency",
|
||||
currency: "AUD",
|
||||
minimumFractionDigits: 2,
|
||||
}).format(n);
|
||||
}
|
||||
function fmtDate(d: string) {
|
||||
return new Date(d + "T00:00:00").toLocaleDateString("en-AU", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
groceries: "#4ade80",
|
||||
dining: "#fb923c",
|
||||
transport: "#60a5fa",
|
||||
fuel: "#facc15",
|
||||
shopping: "#f472b6",
|
||||
utilities: "#a78bfa",
|
||||
entertainment: "#34d399",
|
||||
travel: "#38bdf8",
|
||||
health: "#f87171",
|
||||
insurance: "#94a3b8",
|
||||
subscriptions: "#c084fc",
|
||||
government: "#6b7280",
|
||||
education: "#fbbf24",
|
||||
rent: "#e879f9",
|
||||
home_goods: "#67e8f9",
|
||||
home_maintenance: "#c084fc",
|
||||
personal_care: "#fb7185",
|
||||
pets: "#a3e635",
|
||||
gifts: "#f9a8d4",
|
||||
charity: "#6ee7b7",
|
||||
other: "#71717a",
|
||||
};
|
||||
|
||||
// Custom scatter tooltip
|
||||
function ScatterTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: MerchantRow }>;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-zinc-800 border border-zinc-700 rounded-lg p-3 text-sm shadow-xl max-w-48">
|
||||
<p className="font-semibold text-white truncate">{d.merchant}</p>
|
||||
<p className="text-zinc-400 text-xs mt-0.5">{formatCategory(d.category)}</p>
|
||||
<div className="mt-2 space-y-1 text-zinc-300">
|
||||
<p>{fmt(d.total_spend)} total</p>
|
||||
<p>{d.transaction_count}× transactions</p>
|
||||
<p>{fmtExact(d.avg_transaction)} avg</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Quadrant labels
|
||||
function QuadrantLabels({ medianX, medianY }: { medianX: number; medianY: number }) {
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none select-none">
|
||||
<div className="absolute top-2 right-4 text-xs text-zinc-600 text-right">
|
||||
high cost · frequent
|
||||
</div>
|
||||
<div className="absolute top-2 left-16 text-xs text-zinc-600">
|
||||
high cost · rare
|
||||
</div>
|
||||
<div className="absolute bottom-8 right-4 text-xs text-zinc-600 text-right">
|
||||
low cost · frequent
|
||||
</div>
|
||||
<div className="absolute bottom-8 left-16 text-xs text-zinc-600">
|
||||
low cost · rare
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Merchant profile drawer
|
||||
function MerchantProfile({
|
||||
merchant,
|
||||
onClose,
|
||||
}: {
|
||||
merchant: MerchantRow;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data } = useMerchantTransactions(merchant.merchant);
|
||||
const transactions = data?.transactions ?? [];
|
||||
|
||||
// Build trend chart data from monthly_trend
|
||||
const trendData = useMemo(() => {
|
||||
const months = Object.keys(merchant.monthly_trend).sort();
|
||||
return months.map((m) => ({
|
||||
month: m,
|
||||
label: new Date(m + "-01").toLocaleDateString("en-AU", { month: "short", year: "2-digit" }),
|
||||
amount: merchant.monthly_trend[m],
|
||||
}));
|
||||
}, [merchant.monthly_trend]);
|
||||
|
||||
const color = CATEGORY_COLORS[merchant.category] || "#6366f1";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
{/* backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
{/* drawer */}
|
||||
<div className="relative w-full max-w-lg bg-zinc-900 border-l border-zinc-700 h-full overflow-y-auto flex flex-col shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-5 border-b border-zinc-800">
|
||||
<div className="flex-1 min-w-0 pr-4">
|
||||
<h2 className="text-lg font-semibold text-white truncate">{merchant.merchant}</h2>
|
||||
<span
|
||||
className="inline-block mt-1 px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{ background: color + "33", color }}
|
||||
>
|
||||
{formatCategory(merchant.category)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-400 hover:text-white text-xl leading-none mt-0.5"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3 p-5 border-b border-zinc-800">
|
||||
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-zinc-400 mb-1">Total Spent</p>
|
||||
<p className="text-white font-semibold">{fmt(merchant.total_spend)}</p>
|
||||
</div>
|
||||
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-zinc-400 mb-1">Transactions</p>
|
||||
<p className="text-white font-semibold">{merchant.transaction_count}</p>
|
||||
</div>
|
||||
<div className="bg-zinc-800 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-zinc-400 mb-1">Average</p>
|
||||
<p className="text-white font-semibold">{fmtExact(merchant.avg_transaction)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend chart */}
|
||||
{trendData.length > 1 && (
|
||||
<div className="p-5 border-b border-zinc-800">
|
||||
<p className="text-sm font-medium text-zinc-300 mb-3">Monthly Spend</p>
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<LineChart data={trendData}>
|
||||
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: "#71717a", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: "#71717a", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `$${Math.round(v)}`}
|
||||
width={45}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: "8px" }}
|
||||
labelStyle={{ color: "#a1a1aa" }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="amount"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: color, r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction history */}
|
||||
<div className="p-5 flex-1">
|
||||
<p className="text-sm font-medium text-zinc-300 mb-3">
|
||||
Transactions{" "}
|
||||
<span className="text-zinc-500 font-normal">({transactions.length})</span>
|
||||
</p>
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm">Loading…</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between py-2 border-b border-zinc-800 text-sm"
|
||||
>
|
||||
<div className="flex-1 min-w-0 pr-3">
|
||||
<p className="text-zinc-300 truncate">{tx.description}</p>
|
||||
<p className="text-zinc-500 text-xs mt-0.5">
|
||||
{fmtDate(tx.transaction_date)} · {tx.bank_name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-white font-medium whitespace-nowrap">
|
||||
{fmtExact(tx.my_amount)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MerchantsPage() {
|
||||
const [months, setMonths] = useState(12);
|
||||
const [selected, setSelected] = useState<MerchantRow | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data, isLoading } = useMerchants(months);
|
||||
const merchants = data?.merchants ?? [];
|
||||
|
||||
// Stats for median lines
|
||||
const { medianX, medianY } = useMemo(() => {
|
||||
if (merchants.length === 0) return { medianX: 0, medianY: 0 };
|
||||
const counts = [...merchants].sort((a, b) => a.transaction_count - b.transaction_count);
|
||||
const spends = [...merchants].sort((a, b) => a.total_spend - b.total_spend);
|
||||
const mid = Math.floor(merchants.length / 2);
|
||||
return {
|
||||
medianX: counts[mid]?.transaction_count ?? 0,
|
||||
medianY: spends[mid]?.total_spend ?? 0,
|
||||
};
|
||||
}, [merchants]);
|
||||
|
||||
// Filtered merchants for the table
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return merchants;
|
||||
const q = search.toLowerCase();
|
||||
return merchants.filter((m) => m.merchant.toLowerCase().includes(q));
|
||||
}, [merchants, search]);
|
||||
|
||||
// Top merchants for the scatter (max 150 for performance)
|
||||
const scatterData = useMemo(() => merchants.slice(0, 150), [merchants]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-zinc-400">Loading merchant data…</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">Merchants</h1>
|
||||
<p className="text-zinc-400 text-sm mt-0.5">{merchants.length} merchants · last {months} months</p>
|
||||
</div>
|
||||
<select
|
||||
value={months}
|
||||
onChange={(e) => setMonths(Number(e.target.value))}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 px-3 py-1.5"
|
||||
>
|
||||
<option value={3}>3 months</option>
|
||||
<option value={6}>6 months</option>
|
||||
<option value={12}>12 months</option>
|
||||
<option value={24}>24 months</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Scatter plot */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<h2 className="text-sm font-medium text-zinc-300 mb-1">Spend vs Frequency</h2>
|
||||
<p className="text-xs text-zinc-500 mb-4">
|
||||
Each dot = one merchant. Click to open profile.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<QuadrantLabels medianX={medianX} medianY={medianY} />
|
||||
<ResponsiveContainer width="100%" height={360}>
|
||||
<ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
|
||||
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="transaction_count"
|
||||
name="Transactions"
|
||||
type="number"
|
||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
label={{ value: "Transaction Count", position: "insideBottom", offset: -10, fill: "#52525b", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="total_spend"
|
||||
name="Total Spend"
|
||||
type="number"
|
||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `$${Math.round(v / 1000)}k`}
|
||||
width={48}
|
||||
label={{ value: "Total Spend", angle: -90, position: "insideLeft", offset: 10, fill: "#52525b", fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: "3 3", stroke: "#52525b" }} />
|
||||
<Scatter
|
||||
data={scatterData}
|
||||
onClick={(d) => setSelected(d as unknown as MerchantRow)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{scatterData.map((entry, idx) => (
|
||||
<Cell
|
||||
key={idx}
|
||||
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
|
||||
fillOpacity={0.75}
|
||||
stroke={selected?.merchant === entry.merchant ? "#fff" : "transparent"}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{/* Category legend */}
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1">
|
||||
{Object.entries(CATEGORY_COLORS)
|
||||
.filter(([cat]) => merchants.some((m) => m.category === cat))
|
||||
.slice(0, 12)
|
||||
.map(([cat, color]) => (
|
||||
<span key={cat} className="flex items-center gap-1.5 text-xs text-zinc-400">
|
||||
<span className="w-2 h-2 rounded-full inline-block" style={{ background: color }} />
|
||||
{formatCategory(cat)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Merchant table */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
|
||||
<h2 className="text-sm font-medium text-zinc-300">All Merchants</h2>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 placeholder-zinc-500 px-3 py-1.5 w-48"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-zinc-500 text-xs border-b border-zinc-800">
|
||||
<th className="text-left px-4 py-2 font-medium">Merchant</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Category</th>
|
||||
<th className="text-right px-4 py-2 font-medium">Total</th>
|
||||
<th className="text-right px-4 py-2 font-medium">Count</th>
|
||||
<th className="text-right px-4 py-2 font-medium">Avg</th>
|
||||
<th className="text-right px-4 py-2 font-medium hidden sm:table-cell">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((m) => {
|
||||
const color = CATEGORY_COLORS[m.category] || "#6366f1";
|
||||
return (
|
||||
<tr
|
||||
key={m.merchant}
|
||||
onClick={() => setSelected(m)}
|
||||
className="border-b border-zinc-800 hover:bg-zinc-800 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2.5 text-zinc-200 max-w-[200px]">
|
||||
<span className="truncate block">{m.merchant}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-xs"
|
||||
style={{ background: color + "22", color }}
|
||||
>
|
||||
{formatCategory(m.category)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-white font-medium">
|
||||
{fmt(m.total_spend)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-zinc-400">{m.transaction_count}</td>
|
||||
<td className="px-4 py-2.5 text-right text-zinc-400">
|
||||
{fmtExact(m.avg_transaction)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-zinc-500 text-xs hidden sm:table-cell">
|
||||
{fmtDate(m.last_seen)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Merchant profile drawer */}
|
||||
{selected && (
|
||||
<MerchantProfile merchant={selected} onClose={() => setSelected(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const NAV_ITEMS = [
|
||||
{ href: "/shared", label: "Shared", icon: "users" },
|
||||
{ href: "/budget", label: "Analytics", icon: "bar-chart" },
|
||||
{ href: "/insights", label: "Insights", icon: "lightbulb" },
|
||||
{ href: "/merchants", label: "Merchants", icon: "store" },
|
||||
{ href: "/tags", label: "Tags", icon: "tag" },
|
||||
{ href: "/rules", label: "Rules", icon: "settings" },
|
||||
];
|
||||
@@ -50,6 +51,11 @@ const ICONS: Record<string, React.ReactNode> = {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m1.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
),
|
||||
store: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export function Sidebar() {
|
||||
|
||||
@@ -557,3 +557,49 @@ export function useFees() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface MerchantRow {
|
||||
merchant: string;
|
||||
category: string;
|
||||
transaction_count: number;
|
||||
total_spend: number;
|
||||
avg_transaction: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
months_active: number;
|
||||
monthly_trend: Record<string, number>;
|
||||
}
|
||||
|
||||
export function useMerchants(months = 12) {
|
||||
return useQuery<{ merchants: MerchantRow[]; months: number }>({
|
||||
queryKey: ["analytics", "merchants", months],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/analytics/merchants?months=${months}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface MerchantTxnRow {
|
||||
id: number;
|
||||
transaction_date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
amount_aud: number | null;
|
||||
my_amount: number;
|
||||
transaction_type: string;
|
||||
category: string;
|
||||
bank_name: string;
|
||||
statement_id: number;
|
||||
}
|
||||
|
||||
export function useMerchantTransactions(merchant: string | null) {
|
||||
return useQuery<{ transactions: MerchantTxnRow[] }>({
|
||||
queryKey: ["analytics", "merchant-txns", merchant],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/analytics/merchants/${encodeURIComponent(merchant!)}`);
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!merchant,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user