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: "/shared", label: "Shared", icon: "users" },
|
||||||
{ href: "/budget", label: "Analytics", icon: "bar-chart" },
|
{ href: "/budget", label: "Analytics", icon: "bar-chart" },
|
||||||
{ href: "/insights", label: "Insights", icon: "lightbulb" },
|
{ href: "/insights", label: "Insights", icon: "lightbulb" },
|
||||||
|
{ href: "/merchants", label: "Merchants", icon: "store" },
|
||||||
{ href: "/tags", label: "Tags", icon: "tag" },
|
{ href: "/tags", label: "Tags", icon: "tag" },
|
||||||
{ href: "/rules", label: "Rules", icon: "settings" },
|
{ 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" />
|
<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>
|
</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() {
|
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