- Add FinancialAuditLog model to track all financial changes - Log action (CREATE, UPDATE, DELETE) with before/after values - Capture who made changes (IP address) - Record timestamp and reason for change - Create /admin/financial-audit page to view audit trail - Add filtering by entity type, action, date range - Show summary stats (total changes, deletions, etc) - Integrate with purchases: log create and delete operations - Red-flag deletions for security awareness - Add "Financial audit trail" link to admin menu This provides foundation for Phase 2 (reporting UI) and Phase 3 (bank reconciliation). All financial changes are now traceable and auditable for compliance. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
218 lines
8.0 KiB
TypeScript
218 lines
8.0 KiB
TypeScript
import { prisma } from '@/lib/prisma';
|
|
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
|
import AdminNav from '@/components/AdminNav';
|
|
import Link from 'next/link';
|
|
|
|
export default async function FinancialAuditPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: { type?: string; action?: string; from?: string; to?: string };
|
|
}) {
|
|
const isAdmin = await isAdminAuthenticated();
|
|
if (!isAdmin) {
|
|
return <div className="px-6 py-12 text-center">Unauthorized</div>;
|
|
}
|
|
|
|
const entityType = searchParams.type as any;
|
|
const action = searchParams.action as any;
|
|
const fromDate = searchParams.from ? new Date(searchParams.from) : null;
|
|
const toDate = searchParams.to ? new Date(`${searchParams.to}T23:59:59`) : null;
|
|
|
|
const logs = await prisma.financialAuditLog.findMany({
|
|
where: {
|
|
...(entityType && { entityType }),
|
|
...(action && { action }),
|
|
...(fromDate || toDate
|
|
? {
|
|
changedAt: {
|
|
...(fromDate && { gte: fromDate }),
|
|
...(toDate && { lte: toDate }),
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
orderBy: { changedAt: 'desc' },
|
|
take: 200,
|
|
});
|
|
|
|
// Summary stats
|
|
const allLogs = await prisma.financialAuditLog.findMany({
|
|
orderBy: { changedAt: 'desc' },
|
|
});
|
|
const last30Days = allLogs.filter(
|
|
(l) => l.changedAt > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
|
);
|
|
const deletions = logs.filter((l) => l.action === 'DELETE');
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl px-6 py-12">
|
|
<AdminNav />
|
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
|
<h1 className="font-display text-3xl">Financial Audit Trail</h1>
|
|
<Link href="/admin" className="text-sm text-clay hover:text-clay-dark">
|
|
Back to admin
|
|
</Link>
|
|
</div>
|
|
|
|
<p className="mt-4 text-sm text-muted">
|
|
Complete record of all changes to financial data (orders, sales, purchases, expenses). Use this
|
|
to track who changed what and when.
|
|
</p>
|
|
|
|
{/* Stats */}
|
|
<div className="mt-6 grid gap-3 sm:grid-cols-4">
|
|
<div className="border border-line bg-surface p-4">
|
|
<p className="tag-label">Total Changes</p>
|
|
<p className="mt-1 font-mono text-xl">{allLogs.length}</p>
|
|
</div>
|
|
<div className="border border-line bg-surface p-4">
|
|
<p className="tag-label">Last 30 Days</p>
|
|
<p className="mt-1 font-mono text-xl">{last30Days.length}</p>
|
|
</div>
|
|
<div className="border border-line bg-surface p-4">
|
|
<p className="tag-label">Deletions</p>
|
|
<p className={`mt-1 font-mono text-xl ${deletions.length > 0 ? 'text-splash-pink' : ''}`}>
|
|
{deletions.length}
|
|
</p>
|
|
</div>
|
|
<div className="border border-line bg-surface p-4">
|
|
<p className="tag-label">Creates/Updates</p>
|
|
<p className="mt-1 font-mono text-xl">{allLogs.length - deletions.length}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="mt-6 space-y-4">
|
|
<form method="get" className="flex flex-wrap gap-3">
|
|
<select
|
|
name="type"
|
|
defaultValue={entityType ?? ''}
|
|
className="border border-line bg-paper px-3 py-2 text-sm"
|
|
>
|
|
<option value="">All entity types</option>
|
|
<option value="ORDER">Orders</option>
|
|
<option value="MANUAL_SALE">Manual Sales</option>
|
|
<option value="PURCHASE">Purchases</option>
|
|
<option value="EXPENSE">Expenses</option>
|
|
</select>
|
|
|
|
<select
|
|
name="action"
|
|
defaultValue={action ?? ''}
|
|
className="border border-line bg-paper px-3 py-2 text-sm"
|
|
>
|
|
<option value="">All actions</option>
|
|
<option value="CREATE">Created</option>
|
|
<option value="UPDATE">Updated</option>
|
|
<option value="DELETE">Deleted</option>
|
|
</select>
|
|
|
|
<input
|
|
type="date"
|
|
name="from"
|
|
defaultValue={searchParams.from ?? ''}
|
|
className="border border-line bg-paper px-3 py-2 text-sm"
|
|
placeholder="From"
|
|
/>
|
|
|
|
<input
|
|
type="date"
|
|
name="to"
|
|
defaultValue={searchParams.to ?? ''}
|
|
className="border border-line bg-paper px-3 py-2 text-sm"
|
|
placeholder="To"
|
|
/>
|
|
|
|
<button
|
|
type="submit"
|
|
className="border border-line px-4 py-2 text-sm hover:border-clay hover:text-clay"
|
|
>
|
|
Filter
|
|
</button>
|
|
|
|
{(entityType || action || searchParams.from || searchParams.to) && (
|
|
<Link href="/admin/financial-audit" className="text-sm text-muted hover:text-ink">
|
|
Clear filters
|
|
</Link>
|
|
)}
|
|
</form>
|
|
</div>
|
|
|
|
{/* Logs Table */}
|
|
{logs.length === 0 ? (
|
|
<p className="mt-8 text-sm text-muted">No audit logs found.</p>
|
|
) : (
|
|
<div className="mt-8 overflow-x-auto">
|
|
<table className="w-full border-collapse text-sm">
|
|
<thead>
|
|
<tr className="border-b border-line">
|
|
<th className="text-left py-2 px-3 font-medium">Date & Time</th>
|
|
<th className="text-left py-2 px-3 font-medium">Action</th>
|
|
<th className="text-left py-2 px-3 font-medium">Entity Type</th>
|
|
<th className="text-left py-2 px-3 font-medium">Entity ID</th>
|
|
<th className="text-left py-2 px-3 font-medium">Changed By (IP)</th>
|
|
<th className="text-left py-2 px-3 font-medium">Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.map((log) => {
|
|
const changes = JSON.parse(log.changes || '{}');
|
|
const changeList = Object.entries(changes)
|
|
.map(([field, change]: [string, any]) => `${field}: ${change.old} → ${change.new}`)
|
|
.slice(0, 2)
|
|
.join('; ');
|
|
|
|
return (
|
|
<tr
|
|
key={log.id}
|
|
className={`border-b border-line hover:bg-surface ${
|
|
log.action === 'DELETE' ? 'bg-splash-pink/5' : ''
|
|
}`}
|
|
>
|
|
<td className="py-2 px-3 font-mono text-xs">
|
|
{log.changedAt.toLocaleDateString('en-GB', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})}{' '}
|
|
{log.changedAt.toLocaleTimeString('en-GB', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
})}
|
|
</td>
|
|
<td className="py-2 px-3">
|
|
<span
|
|
className={`tag-label rounded-full px-2 py-1 text-xs ${
|
|
log.action === 'CREATE'
|
|
? 'bg-green-100 text-green-700'
|
|
: log.action === 'UPDATE'
|
|
? 'bg-blue-100 text-blue-700'
|
|
: 'bg-splash-pink/10 text-splash-pink'
|
|
}`}
|
|
>
|
|
{log.action}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 px-3 text-xs">{log.entityType}</td>
|
|
<td className="py-2 px-3 font-mono text-xs">{log.entityId}</td>
|
|
<td className="py-2 px-3 font-mono text-xs">{log.changedBy || '—'}</td>
|
|
<td className="py-2 px-3 text-xs text-muted truncate" title={changeList}>
|
|
{changeList || log.reason || '—'}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<p className="mt-8 text-xs text-muted">
|
|
Showing last 200 changes. Audit logs help you track financial data integrity and detect
|
|
unauthorized changes.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|