Files
pn-new-crm/src/components/expenses/expense-detail.tsx

287 lines
9.8 KiB
TypeScript
Raw Normal View History

'use client';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
import { Archive, Download, Edit, FileText, Loader2, Receipt } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
import { PermissionGate } from '@/components/shared/permission-gate';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api/client';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import type { ExpenseRow } from './expense-columns';
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema + service skeletons committed in PRs 1-3. PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source), date-range picker (today/7d/30d/90d), CSV+PNG export per card. PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard right-rail, three-tab page (active/dismissed/resolved), socket-driven invalidation. Bell lazy-loads list on popover open to keep cold pages fast in non-dashboard routes. PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count surfaces in tab label. PR7 Interests-by-berth tab on berth detail — replaces the stub. PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow banner on detail w/ Merge / Not-a-duplicate, transactional merge consolidates receipts and archives the source. PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in its own (scanner) group with no dashboard chrome, dynamic per-port manifest, OpenAI + Claude provider abstraction, admin OCR settings page (port-level + super-admin global default w/ opt-in fallback), test-connection endpoint, manual-entry fallback when no key is configured. Verify form always shown before save — no ghost rows. PR10 Audit log read view — swap to tsvector full-text search on the existing GIN index, cursor pagination, filters for entity/action/user /date range, batched actor-email resolution. PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip cleanly without their gate envs so CI stays green. Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
import { ExpenseDuplicateBanner } from './expense-duplicate-banner';
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
/**
* Renders an image thumbnail for previewable receipts (jpeg/png/webp/heic
* via the existing /files/[id]/preview presign), falling back to a "Download"
* link for PDFs and other non-previewable types. Replaces the prior
* impossible-to-use UUID-badge list reps can finally see the receipt
* they uploaded against the expense.
*/
function ReceiptThumbnail({ fileId }: { fileId: string }) {
const { data, isLoading, isError } = useQuery<{
data: { url: string; mimeType: string } | null;
error?: string;
}>({
queryKey: ['file-preview', fileId],
queryFn: async () => {
try {
const res = await apiFetch<{ data: { url: string; mimeType: string } }>(
`/api/v1/files/${fileId}/preview`,
);
return res;
} catch (e) {
// Non-image files raise ValidationError ("This file type cannot be
// previewed") — fall through to the Download link.
return { data: null, error: e instanceof Error ? e.message : 'preview unavailable' };
}
},
staleTime: 5 * 60 * 1000,
});
if (isLoading) {
return (
<div className="flex h-32 items-center justify-center rounded border bg-muted/40 text-xs text-muted-foreground">
<Loader2 className="mr-2 h-3 w-3 animate-spin" /> Loading preview
</div>
);
}
const url = data?.data?.url;
const mime = data?.data?.mimeType ?? '';
const isImage = mime.startsWith('image/');
return (
<div className="rounded border bg-muted/40 p-2">
{url && isImage ? (
<a href={url} target="_blank" rel="noopener noreferrer">
<img
src={url}
alt="Receipt"
className="h-32 w-full rounded object-cover hover:opacity-90"
/>
</a>
) : (
<div className="flex h-32 items-center justify-center text-muted-foreground">
<FileText className="h-8 w-8" />
</div>
)}
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span className="truncate">{mime || (isError ? 'Receipt' : 'File')}</span>
<a
href={`/api/v1/files/${fileId}/download`}
className="inline-flex items-center gap-1 text-primary hover:underline"
>
<Download className="h-3 w-3" /> Download
</a>
</div>
</div>
);
}
const PAYMENT_STATUS_COLORS: Record<string, string> = {
unpaid: 'bg-red-100 text-red-700 border-red-200',
paid: 'bg-green-100 text-green-700 border-green-200',
partial: 'bg-yellow-100 text-yellow-700 border-yellow-200',
};
interface ExpenseDetailProps {
expenseId: string;
onEdit?: () => void;
onArchived?: () => void;
}
export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailProps) {
const queryClient = useQueryClient();
const [archiveOpen, setArchiveOpen] = useState(false);
const { data, isLoading, error } = useQuery<{ data: ExpenseRow }>({
queryKey: ['expenses', expenseId],
queryFn: () => apiFetch(`/api/v1/expenses/${expenseId}`),
});
const { setChrome } = useMobileChrome();
const titleForChrome: string | null =
data?.data?.establishmentName ?? data?.data?.description?.slice(0, 40) ?? null;
useEffect(() => {
setChrome({ title: titleForChrome ?? 'Expense', showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
const archiveMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/expenses/${expenseId}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
setArchiveOpen(false);
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
toast.success('Expense archived');
onArchived?.();
},
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
onError: (e) => {
toast.error(e instanceof Error ? e.message : 'Archive failed');
setArchiveOpen(false);
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !data?.data) {
return (
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema + service skeletons committed in PRs 1-3. PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source), date-range picker (today/7d/30d/90d), CSV+PNG export per card. PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard right-rail, three-tab page (active/dismissed/resolved), socket-driven invalidation. Bell lazy-loads list on popover open to keep cold pages fast in non-dashboard routes. PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count surfaces in tab label. PR7 Interests-by-berth tab on berth detail — replaces the stub. PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow banner on detail w/ Merge / Not-a-duplicate, transactional merge consolidates receipts and archives the source. PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in its own (scanner) group with no dashboard chrome, dynamic per-port manifest, OpenAI + Claude provider abstraction, admin OCR settings page (port-level + super-admin global default w/ opt-in fallback), test-connection endpoint, manual-entry fallback when no key is configured. Verify form always shown before save — no ghost rows. PR10 Audit log read view — swap to tsvector full-text search on the existing GIN index, cursor pagination, filters for entity/action/user /date range, batched actor-email resolution. PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip cleanly without their gate envs so CI stays green. Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
<div className="p-6 text-center text-muted-foreground">Failed to load expense details.</div>
);
}
const expense = data.data;
const status = expense.paymentStatus ?? 'unpaid';
const statusColor = PAYMENT_STATUS_COLORS[status] ?? '';
return (
<div className="space-y-6">
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema + service skeletons committed in PRs 1-3. PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source), date-range picker (today/7d/30d/90d), CSV+PNG export per card. PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard right-rail, three-tab page (active/dismissed/resolved), socket-driven invalidation. Bell lazy-loads list on popover open to keep cold pages fast in non-dashboard routes. PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count surfaces in tab label. PR7 Interests-by-berth tab on berth detail — replaces the stub. PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow banner on detail w/ Merge / Not-a-duplicate, transactional merge consolidates receipts and archives the source. PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in its own (scanner) group with no dashboard chrome, dynamic per-port manifest, OpenAI + Claude provider abstraction, admin OCR settings page (port-level + super-admin global default w/ opt-in fallback), test-connection endpoint, manual-entry fallback when no key is configured. Verify form always shown before save — no ghost rows. PR10 Audit log read view — swap to tsvector full-text search on the existing GIN index, cursor pagination, filters for entity/action/user /date range, batched actor-email resolution. PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip cleanly without their gate envs so CI stays green. Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
<ExpenseDuplicateBanner expense={expense} />
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">
{expense.establishmentName ?? 'Unnamed Expense'}
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{format(new Date(expense.expenseDate), 'MMMM d, yyyy')}
</p>
</div>
<div className="flex items-center gap-2">
{onEdit && (
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
<PermissionGate resource="expenses" action="edit">
<Button variant="outline" size="sm" onClick={onEdit}>
<Edit className="mr-1.5 h-4 w-4" />
Edit
</Button>
</PermissionGate>
)}
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
<PermissionGate resource="expenses" action="delete">
<Button
variant="outline"
size="sm"
className="text-destructive"
onClick={() => setArchiveOpen(true)}
>
<Archive className="mr-1.5 h-4 w-4" />
Archive
</Button>
</PermissionGate>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Amount</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold tabular-nums">
{Number(expense.amount).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
{expense.currency}
</p>
{expense.amountUsd && expense.currency !== 'USD' && (
<p className="text-sm text-muted-foreground mt-1">
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema + service skeletons committed in PRs 1-3. PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source), date-range picker (today/7d/30d/90d), CSV+PNG export per card. PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard right-rail, three-tab page (active/dismissed/resolved), socket-driven invalidation. Bell lazy-loads list on popover open to keep cold pages fast in non-dashboard routes. PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count surfaces in tab label. PR7 Interests-by-berth tab on berth detail — replaces the stub. PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow banner on detail w/ Merge / Not-a-duplicate, transactional merge consolidates receipts and archives the source. PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in its own (scanner) group with no dashboard chrome, dynamic per-port manifest, OpenAI + Claude provider abstraction, admin OCR settings page (port-level + super-admin global default w/ opt-in fallback), test-connection endpoint, manual-entry fallback when no key is configured. Verify form always shown before save — no ghost rows. PR10 Audit log read view — swap to tsvector full-text search on the existing GIN index, cursor pagination, filters for entity/action/user /date range, batched actor-email resolution. PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip cleanly without their gate envs so CI stays green. Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
$
{Number(expense.amountUsd).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema + service skeletons committed in PRs 1-3. PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source), date-range picker (today/7d/30d/90d), CSV+PNG export per card. PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard right-rail, three-tab page (active/dismissed/resolved), socket-driven invalidation. Bell lazy-loads list on popover open to keep cold pages fast in non-dashboard routes. PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count surfaces in tab label. PR7 Interests-by-berth tab on berth detail — replaces the stub. PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow banner on detail w/ Merge / Not-a-duplicate, transactional merge consolidates receipts and archives the source. PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in its own (scanner) group with no dashboard chrome, dynamic per-port manifest, OpenAI + Claude provider abstraction, admin OCR settings page (port-level + super-admin global default w/ opt-in fallback), test-connection endpoint, manual-entry fallback when no key is configured. Verify form always shown before save — no ghost rows. PR10 Audit log read view — swap to tsvector full-text search on the existing GIN index, cursor pagination, filters for entity/action/user /date range, batched actor-email resolution. PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip cleanly without their gate envs so CI stays green. Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
})}{' '}
USD
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Payment Status</CardTitle>
</CardHeader>
<CardContent>
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema + service skeletons committed in PRs 1-3. PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source), date-range picker (today/7d/30d/90d), CSV+PNG export per card. PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard right-rail, three-tab page (active/dismissed/resolved), socket-driven invalidation. Bell lazy-loads list on popover open to keep cold pages fast in non-dashboard routes. PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count surfaces in tab label. PR7 Interests-by-berth tab on berth detail — replaces the stub. PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow banner on detail w/ Merge / Not-a-duplicate, transactional merge consolidates receipts and archives the source. PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in its own (scanner) group with no dashboard chrome, dynamic per-port manifest, OpenAI + Claude provider abstraction, admin OCR settings page (port-level + super-admin global default w/ opt-in fallback), test-connection endpoint, manual-entry fallback when no key is configured. Verify form always shown before save — no ghost rows. PR10 Audit log read view — swap to tsvector full-text search on the existing GIN index, cursor pagination, filters for entity/action/user /date range, batched actor-email resolution. PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip cleanly without their gate envs so CI stays green. Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
<Badge variant="outline" className={`capitalize text-sm border ${statusColor}`}>
{status}
</Badge>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Details</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Category</span>
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Payment Method</span>
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Payer</span>
<p className="mt-0.5">{expense.payer ?? '-'}</p>
</div>
feat(expenses+interests): trip/event grouping (lightweight) Per the trips/events design discussion: instead of building a full events domain (table + CRUD UI + calendar) for the 6–12 yacht shows a year, ship the cheap version that covers the actual asks. Expenses — `tripLabel` free-text: - New `expenses.trip_label` text column (migration 0039) + index for filter / autocomplete lookup. - Validator: createExpenseShape + listExpensesSchema + exportExpensePdfSchema.filter all accept tripLabel. - Service: createExpense + updateExpense persist; listExpenses filters; new `listTripLabels(portId, search?)` returns distinct values ordered by most-recent expenseDate so the autocomplete surfaces recently-used labels first. - New `GET /api/v1/expenses/trip-labels` endpoint (gated by expenses.view) backs the autocomplete. - Form dialog: native `<datalist>` powered by the autocomplete query so reps don't end up with "Palm Beach 2026" / "palm-beach 2026" fragmented across two PDF sections. - Expense list: new "Trip" column (badge) + free-text filter. - Detail page: trip label rendered alongside Category / Payer. - PDF export: GroupBy gains 'trip'; filter.tripLabel narrows the export. Untagged rows fall under "(no trip)". - Trim/normalize on write so " Palm Beach 2026 " === "Palm Beach 2026". Interests — event tagging via existing tag system: - Reps can tag interests with an event tag (e.g. "Palm Beach 2026") via the existing InlineTagEditor on the detail page; tags are port-scoped and reusable. - Interest list now has a TagPicker filter rendered next to the FilterBar so reps can sort prospects by event attended ("show me every lead from Palm Beach"). Hidden 'relation'-typed FilterDefinition for tagIds wires URL round-trip + saved-views capture without rendering inside the FilterBar. - FilterBar deserializer now handles `relation` types as comma-joined arrays on URL load. Why a free-text trip label and not a trips table: - 6–12 events/year doesn't justify a domain. The CRUD UI cost would be most of the engineering, and reps already have the events on their personal calendars. - If usage proves demand for per-event ROI dashboards or richer attribution, promote to a real `trips` table later. Migration path: trip_label → tripId is a backfill+swap. Test status: 1168/1168 vitest. tsc clean. Migration 0039 applied in dev (also caught + fixed an unrelated audit-v3 follow-up: 0037 had `idx_br_interest` colliding with the existing `berth_recommendations.idx_br_interest`; renamed to `idx_brr_interest` / `idx_brr_contract_file`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:46:54 +02:00
<div>
<span className="text-muted-foreground">Trip / event</span>
<p className="mt-0.5">
{expense.tripLabel ? (
<Badge variant="secondary" className="text-xs font-normal">
{expense.tripLabel}
</Badge>
) : (
'-'
)}
</p>
</div>
<div>
<span className="text-muted-foreground">Description</span>
<p className="mt-0.5">{expense.description ?? '-'}</p>
</div>
</CardContent>
</Card>
{expense.receiptFileIds && expense.receiptFileIds.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Receipt className="h-4 w-4" />
Receipts ({expense.receiptFileIds.length})
</CardTitle>
</CardHeader>
<CardContent>
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{(expense.receiptFileIds as string[]).map((fileId) => (
<ReceiptThumbnail key={fileId} fileId={fileId} />
))}
</div>
</CardContent>
</Card>
)}
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
entityName={expense.establishmentName ?? 'this expense'}
entityType="Expense"
isArchived={false}
onConfirm={() => archiveMutation.mutate()}
isLoading={archiveMutation.isPending}
/>
</div>
);
}