Files
craft2prints/src/app/admin/accounts/reconciliation/page.tsx
T
AndymickandClaude Sonnet 5 396347b982 Move uploaded images to disk storage, fix Next 16 async params/searchParams
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>
2026-07-22 20:56:40 +01:00

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>
);
}