Homepage was rendering 71MB of HTML because every uploaded image (gallery, products, proofs, custom requests, design previews, invoices, receipts) was stored as base64 in Postgres and double-embedded via RSC hydration payloads. Adds src/lib/storage.ts (sharp-based compression, disk storage under public/uploads/) and a new /api/design-uploads endpoint for images added inside the design tool, migrates existing rows, and drops the now-unused base64 columns and the dead ProductImage table. Also fixes a systemic bug from the Next.js 16 upgrade where dynamic pages across the app still destructured params/searchParams synchronously instead of awaiting them as Promises, which was silently breaking filters/params and crashing /products/[slug] outright. Updates README.md and SETUP_AND_BUILD.md to reflect Postgres + Next.js 16 and document the new image storage architecture and backup requirements. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
185 lines
7.0 KiB
TypeScript
185 lines
7.0 KiB
TypeScript
import { prisma } from '@/lib/prisma';
|
|
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
|
import AdminNav from '@/components/AdminNav';
|
|
import Link from 'next/link';
|
|
import Price from '@/components/Price';
|
|
import { getReconciliationSummary } from '@/lib/bankReconciliation';
|
|
import ReconciliationClient from './client';
|
|
|
|
export default async function ReconciliationPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ id?: string }>;
|
|
}) {
|
|
const isAdmin = await isAdminAuthenticated();
|
|
if (!isAdmin) {
|
|
return <div className="px-6 py-12 text-center">Unauthorized</div>;
|
|
}
|
|
|
|
const { id: statementId } = await searchParams;
|
|
|
|
if (!statementId) {
|
|
// Show list of statements
|
|
const statements = await prisma.bankStatement.findMany({
|
|
orderBy: { uploadedAt: 'desc' },
|
|
include: {
|
|
lines: true,
|
|
reconciliations: true,
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl px-6 py-12">
|
|
<AdminNav />
|
|
<div className="flex flex-wrap items-baseline justify-between gap-3 mb-8">
|
|
<h1 className="font-display text-3xl">Bank Reconciliation</h1>
|
|
<Link href="/admin/accounts/reconciliation/upload" className="border border-ink px-4 py-1.5 text-sm hover:border-clay hover:bg-clay hover:text-paper">
|
|
⬆ Upload Statement
|
|
</Link>
|
|
</div>
|
|
|
|
<p className="text-sm text-muted mb-6">
|
|
Upload your bank statements to match against system transactions and verify account accuracy.
|
|
</p>
|
|
|
|
{statements.length === 0 ? (
|
|
<div className="border border-line p-8 text-center">
|
|
<p className="text-muted mb-4">No bank statements uploaded yet.</p>
|
|
<Link href="/admin/accounts/reconciliation/upload" className="text-clay hover:text-clay-dark">
|
|
Upload your first statement →
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{statements.map((stmt) => {
|
|
const matched = stmt.reconciliations.length;
|
|
const total = stmt.lines.length;
|
|
const reconciled = stmt.status === 'RECONCILED';
|
|
|
|
return (
|
|
<Link
|
|
key={stmt.id}
|
|
href={`/admin/accounts/reconciliation?id=${stmt.id}`}
|
|
className="block border border-line p-4 hover:bg-surface"
|
|
>
|
|
<div className="flex items-baseline justify-between mb-2">
|
|
<p className="font-medium">{stmt.filename}</p>
|
|
<span className={`text-xs px-2 py-1 rounded-full ${
|
|
reconciled ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
|
|
}`}>
|
|
{stmt.status}
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-4 text-sm">
|
|
<div>
|
|
<p className="tag-label">Period</p>
|
|
<p className="font-mono text-xs">
|
|
{stmt.startDate.toLocaleDateString('en-GB')} to {stmt.endDate.toLocaleDateString('en-GB')}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="tag-label">Transactions</p>
|
|
<p className="font-mono">{total}</p>
|
|
</div>
|
|
<div>
|
|
<p className="tag-label">Matched</p>
|
|
<p className={`font-mono ${matched === total ? 'text-green-600' : 'text-orange-600'}`}>
|
|
{matched}/{total}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="tag-label">Total</p>
|
|
<p className="font-mono">
|
|
<Price cents={stmt.totalAmount || 0} />
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{stmt.reconciliedAt && (
|
|
<p className="text-xs text-muted mt-2">
|
|
Reconciled: {stmt.reconciliedAt.toLocaleDateString('en-GB')}
|
|
</p>
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<p className="mt-8 text-xs text-muted">
|
|
💡 Tip: Each uploaded statement is automatically scanned to match transactions. Review unmatched items and correct any discrepancies before archiving.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show statement detail
|
|
const statement = await prisma.bankStatement.findUnique({
|
|
where: { id: statementId },
|
|
include: {
|
|
lines: { orderBy: { date: 'desc' } },
|
|
reconciliations: true,
|
|
},
|
|
});
|
|
|
|
if (!statement) {
|
|
return <div className="px-6 py-12 text-center">Statement not found</div>;
|
|
}
|
|
|
|
const summary = await getReconciliationSummary(statementId);
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl px-6 py-12">
|
|
<AdminNav />
|
|
<div className="flex flex-wrap items-baseline justify-between gap-3 mb-8">
|
|
<div>
|
|
<Link href="/admin/accounts/reconciliation" className="text-sm text-muted hover:text-ink mb-2">
|
|
← Back to statements
|
|
</Link>
|
|
<h1 className="font-display text-3xl">{statement.filename}</h1>
|
|
</div>
|
|
<span className={`text-sm px-3 py-1 rounded-full font-medium ${
|
|
statement.status === 'RECONCILED' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
|
|
}`}>
|
|
{statement.status}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Summary Stats */}
|
|
<div className="grid gap-3 sm:grid-cols-5 mb-8">
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Period</p>
|
|
<p className="mt-2 text-sm font-mono">
|
|
{statement.startDate.toLocaleDateString('en-GB')} to {statement.endDate.toLocaleDateString('en-GB')}
|
|
</p>
|
|
</div>
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Bank Total</p>
|
|
<p className="mt-2 font-mono text-lg">
|
|
<Price cents={summary?.totalBankAmount || 0} />
|
|
</p>
|
|
</div>
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Matched</p>
|
|
<p className={`mt-2 font-mono text-lg ${summary?.matched === statement.lines.length ? 'text-green-600' : 'text-orange-600'}`}>
|
|
{summary?.matched || 0}/{statement.lines.length}
|
|
</p>
|
|
</div>
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Unmatched</p>
|
|
<p className={`mt-2 font-mono text-lg ${summary?.unmatched === 0 ? 'text-green-600' : 'text-splash-pink'}`}>
|
|
{summary?.unmatched || 0}
|
|
</p>
|
|
</div>
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Discrepancies</p>
|
|
<p className={`mt-2 font-mono text-lg ${summary?.discrepancies === 0 ? 'text-green-600' : 'text-splash-pink'}`}>
|
|
{summary?.discrepancies || 0}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<ReconciliationClient statement={statement} />
|
|
</div>
|
|
);
|
|
}
|