2026-04-28 02:39:46 +02:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useState } from 'react';
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
import type { Route } from 'next';
|
|
|
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
import { ArrowLeft, Bell, Download, Mail, Trash2, X } from 'lucide-react';
|
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { PageHeader } from '@/components/shared/page-header';
|
|
|
|
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
|
|
|
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:
* 38 client components / 56 toast.error sites converted to
toastError(err) so the new admin error inspector becomes usable from
user-reported issues — every failed inline-edit, save, send, archive,
upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
the existing AppError subclasses. Adds new error codes:
DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
(client-merge "already been merged", expense/interest "couldn't find
that …", documenso "signing service didn't respond").
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:18:05 +02:00
|
|
|
import { toastError } from '@/lib/api/toast-error';
|
2026-04-28 02:39:46 +02:00
|
|
|
|
|
|
|
|
interface DetailDoc {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
status: string;
|
|
|
|
|
documentType: string;
|
|
|
|
|
documensoId: string | null;
|
|
|
|
|
signedFileId: string | null;
|
|
|
|
|
reservationId: string | null;
|
|
|
|
|
interestId: string | null;
|
|
|
|
|
clientId: string | null;
|
|
|
|
|
yachtId: string | null;
|
|
|
|
|
companyId: string | null;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
createdBy: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DetailSigner {
|
|
|
|
|
id: string;
|
|
|
|
|
signerName: string;
|
|
|
|
|
signerEmail: string;
|
|
|
|
|
signerRole: string;
|
|
|
|
|
signingOrder: number;
|
|
|
|
|
status: string;
|
|
|
|
|
signedAt: string | null;
|
|
|
|
|
signingUrl: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DetailEvent {
|
|
|
|
|
id: string;
|
|
|
|
|
eventType: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
signerId: string | null;
|
|
|
|
|
eventData: Record<string, unknown> | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DetailWatcher {
|
|
|
|
|
userId: string;
|
|
|
|
|
addedBy: string;
|
|
|
|
|
addedAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DetailResponse {
|
|
|
|
|
data: {
|
|
|
|
|
document: DetailDoc;
|
|
|
|
|
signers: DetailSigner[];
|
|
|
|
|
events: DetailEvent[];
|
|
|
|
|
watchers: DetailWatcher[];
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const STATUS_PILL_MAP: Record<string, StatusPillStatus> = {
|
|
|
|
|
draft: 'draft',
|
|
|
|
|
sent: 'sent',
|
|
|
|
|
partially_signed: 'partial',
|
|
|
|
|
completed: 'completed',
|
|
|
|
|
signed: 'signed',
|
|
|
|
|
expired: 'expired',
|
|
|
|
|
cancelled: 'cancelled',
|
|
|
|
|
rejected: 'rejected',
|
|
|
|
|
pending: 'pending',
|
|
|
|
|
declined: 'declined',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const SIGNER_PILL_MAP: Record<string, StatusPillStatus> = {
|
|
|
|
|
pending: 'pending',
|
|
|
|
|
signed: 'signed',
|
|
|
|
|
declined: 'declined',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface DocumentDetailProps {
|
|
|
|
|
documentId: string;
|
|
|
|
|
portSlug: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
|
|
|
|
|
|
|
|
const { data, isLoading, error } = useQuery<DetailResponse>({
|
|
|
|
|
queryKey: ['document-detail', documentId],
|
|
|
|
|
queryFn: () => apiFetch<DetailResponse>(`/api/v1/documents/${documentId}?detail=true`),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useRealtimeInvalidation({
|
|
|
|
|
'document:updated': [['document-detail', documentId]],
|
|
|
|
|
'document:sent': [['document-detail', documentId]],
|
|
|
|
|
'document:completed': [['document-detail', documentId]],
|
|
|
|
|
'document:cancelled': [['document-detail', documentId]],
|
|
|
|
|
'document:rejected': [['document-detail', documentId]],
|
|
|
|
|
'document:expired': [['document-detail', documentId]],
|
|
|
|
|
'document:signer:signed': [['document-detail', documentId]],
|
|
|
|
|
'document:signer:opened': [['document-detail', documentId]],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
<div className="h-24 animate-pulse rounded-xl bg-muted/40" />
|
|
|
|
|
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
|
|
|
|
<div className="h-64 animate-pulse rounded-md bg-muted/40" />
|
|
|
|
|
<div className="h-64 animate-pulse rounded-md bg-muted/40" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error || !data) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
<PageHeader
|
|
|
|
|
title="Document not found"
|
|
|
|
|
description="This document was deleted or you don't have access to it."
|
|
|
|
|
actions={
|
|
|
|
|
<Button asChild variant="outline">
|
|
|
|
|
<Link href={`/${portSlug}/documents`}>
|
|
|
|
|
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
|
|
|
|
Back to documents
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { document: doc, signers, events, watchers } = data.data;
|
|
|
|
|
|
|
|
|
|
const handleRemind = async (signerId: string) => {
|
|
|
|
|
try {
|
|
|
|
|
await apiFetch(`/api/v1/documents/${documentId}/remind`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: { signerId },
|
|
|
|
|
});
|
|
|
|
|
toast.success('Reminder sent');
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] });
|
|
|
|
|
} catch (err) {
|
fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:
* 38 client components / 56 toast.error sites converted to
toastError(err) so the new admin error inspector becomes usable from
user-reported issues — every failed inline-edit, save, send, archive,
upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
the existing AppError subclasses. Adds new error codes:
DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
(client-merge "already been merged", expense/interest "couldn't find
that …", documenso "signing service didn't respond").
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:18:05 +02:00
|
|
|
toastError(err);
|
2026-04-28 02:39:46 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCancel = async () => {
|
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units
Berth surfaces
- New compact mooring-chip header (colored plate + status pill, dock-label
in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack
- Berth list gains a "Latest deal stage" column showing the most-advanced
pipeline stage of any active linked interest (server-aggregated, ranks by
PIPELINE_STAGES index)
- "Linked prospect" Select on the status-change dialog rebuilt as a Command
combobox: search, recent-first sort, stage-coloured pills
Pipeline UX
- Reverting an interest to Open with linked berths now prompts: keep the
links, unlink and reset, or cancel. Silent when no berths are linked
- Activity feed + entity-activity feed normalise enum field values via
STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as
"10% Deposit → Contract Sent"
EOI generate dialog
- Inline-editable rows for client name, nationality (country combobox), and
yacht name — pencil affordance saves directly via clients/yachts PATCH
- Replaces the single "Edit on client's page" link with two contextual links
framed by short copy explaining what's inline vs what needs the canonical
page
- Backend EoiContext now includes client.id + yacht.id so the dialog can
PATCH without an extra round-trip
Company form
- New "Connections" section lets the rep attach members (clients) and yachts
during create. Yacht attach uses the existing transfer endpoint so audit
log + ownership history capture the change
- Inline "+ New client" / "+ New yacht" buttons open the canonical forms
stacked over the company sheet
- After save, the form chains to a yacht pull-in prompt (if any attached
client owns yachts not yet linked) and an optional "Create interest" step
pre-filled with the first attached client
Admin
- /admin landing gains a searchable index — typed query flattens groups into
a result list matching label + description + group title
- "Documenso & EOI" card relabelled to "EOI signing service" (consistent
with the user-facing language rename from round 1)
Measurement units (migration 0053)
- interests gains desired_*_m columns + desired_*_unit discriminators so
the rep's literal entry (ft OR m) is preserved verbatim instead of being
reconstructed from a single canonical column on every render
- yachts + berths gain matching *_unit columns alongside their existing
ft + m pairs; defaults to 'ft' so legacy rows still render normally
- Interest form POST/PATCH now sends both ft + m + unit; computed m is
derived from the ft canonical to keep the recommender SQL unchanged
Misc
- Active-deals tile + topbar type their Link href as `Route` instead of `any`
- Unused REPORT_TYPE_LABELS const dropped from generate-report-form
- Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated
to include the new id + unit fields on the EoiContext / Berth shapes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
|
|
|
if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.'))
|
|
|
|
|
return;
|
2026-04-28 02:39:46 +02:00
|
|
|
setIsCancelling(true);
|
|
|
|
|
try {
|
|
|
|
|
await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' });
|
|
|
|
|
toast.success('Document cancelled');
|
|
|
|
|
router.push(`/${portSlug}/documents`);
|
|
|
|
|
} catch (err) {
|
fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:
* 38 client components / 56 toast.error sites converted to
toastError(err) so the new admin error inspector becomes usable from
user-reported issues — every failed inline-edit, save, send, archive,
upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
the existing AppError subclasses. Adds new error codes:
DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
(client-merge "already been merged", expense/interest "couldn't find
that …", documenso "signing service didn't respond").
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:18:05 +02:00
|
|
|
toastError(err);
|
2026-04-28 02:39:46 +02:00
|
|
|
setIsCancelling(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleEmailSignedPdf = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const draft = await apiFetch<{
|
|
|
|
|
data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> };
|
|
|
|
|
}>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' });
|
|
|
|
|
toast.info(
|
2026-05-04 22:57:01 +02:00
|
|
|
`Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} - opens in PR8 wizard`,
|
2026-04-28 02:39:46 +02:00
|
|
|
);
|
|
|
|
|
} catch (err) {
|
fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:
* 38 client components / 56 toast.error sites converted to
toastError(err) so the new admin error inspector becomes usable from
user-reported issues — every failed inline-edit, save, send, archive,
upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
the existing AppError subclasses. Adds new error codes:
DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
(client-merge "already been merged", expense/interest "couldn't find
that …", documenso "signing service didn't respond").
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:18:05 +02:00
|
|
|
toastError(err);
|
2026-04-28 02:39:46 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isInFlight = ['sent', 'partially_signed'].includes(doc.status);
|
|
|
|
|
const isComplete = ['completed', 'signed'].includes(doc.status);
|
|
|
|
|
|
|
|
|
|
const subjectLink = doc.reservationId
|
|
|
|
|
? { href: `/${portSlug}/berth-reservations/${doc.reservationId}`, label: 'Reservation' }
|
|
|
|
|
: doc.interestId
|
|
|
|
|
? { href: `/${portSlug}/interests/${doc.interestId}`, label: 'Interest' }
|
|
|
|
|
: doc.clientId
|
|
|
|
|
? { href: `/${portSlug}/clients/${doc.clientId}`, label: 'Client' }
|
|
|
|
|
: doc.yachtId
|
|
|
|
|
? { href: `/${portSlug}/yachts/${doc.yachtId}`, label: 'Yacht' }
|
|
|
|
|
: doc.companyId
|
|
|
|
|
? { href: `/${portSlug}/companies/${doc.companyId}`, label: 'Company' }
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
<PageHeader
|
|
|
|
|
eyebrow={doc.documentType.replace(/_/g, ' ')}
|
|
|
|
|
title={doc.title}
|
|
|
|
|
description={`Created ${new Date(doc.createdAt).toLocaleDateString('en-GB')}`}
|
|
|
|
|
kpiLine={
|
|
|
|
|
<>
|
|
|
|
|
<StatusPill status={STATUS_PILL_MAP[doc.status] ?? 'pending'} withDot>
|
|
|
|
|
{doc.status.replace(/_/g, ' ')}
|
|
|
|
|
</StatusPill>
|
|
|
|
|
<span>
|
|
|
|
|
{signers.filter((s) => s.status === 'signed').length}/{signers.length} signed
|
|
|
|
|
</span>
|
|
|
|
|
{watchers.length > 0 ? <span>{watchers.length} watching</span> : null}
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
actions={
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button asChild variant="outline" size="sm">
|
|
|
|
|
<Link href={`/${portSlug}/documents`}>
|
|
|
|
|
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
{isComplete && doc.signedFileId ? (
|
|
|
|
|
<>
|
|
|
|
|
<Button asChild size="sm">
|
|
|
|
|
<Link href={`/api/v1/files/${doc.signedFileId}/download`}>
|
|
|
|
|
<Download className="mr-1.5 h-4 w-4" /> Download signed PDF
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="sm" variant="outline" onClick={handleEmailSignedPdf}>
|
|
|
|
|
<Mail className="mr-1.5 h-4 w-4" /> Email signatories
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
{isInFlight ? (
|
|
|
|
|
<Button size="sm" variant="outline" onClick={handleCancel} disabled={isCancelling}>
|
|
|
|
|
<X className="mr-1.5 h-4 w-4" /> Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
variant="gradient"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
|
|
|
|
{/* Left column */}
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
<section className="rounded-md border bg-white p-4">
|
|
|
|
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Signers
|
|
|
|
|
</h2>
|
|
|
|
|
{signers.length === 0 ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground">No signers attached.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="space-y-3">
|
|
|
|
|
{signers.map((signer, idx) => (
|
|
|
|
|
<li
|
|
|
|
|
key={signer.id}
|
|
|
|
|
className="flex items-start gap-3 rounded-md border bg-white p-3 shadow-xs transition-colors hover:bg-muted/30"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${
|
|
|
|
|
signer.status === 'signed'
|
|
|
|
|
? 'bg-success-bg text-success'
|
|
|
|
|
: signer.status === 'declined'
|
|
|
|
|
? 'bg-error-bg text-error'
|
|
|
|
|
: 'bg-slate-100 text-slate-600'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{idx + 1}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<div className="font-medium text-foreground">{signer.signerName}</div>
|
|
|
|
|
<StatusPill status={SIGNER_PILL_MAP[signer.status] ?? 'pending'}>
|
|
|
|
|
{signer.status}
|
|
|
|
|
</StatusPill>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
{signer.signerEmail} · {signer.signerRole}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
{signer.signedAt
|
|
|
|
|
? `Signed ${new Date(signer.signedAt).toLocaleDateString('en-GB')}`
|
|
|
|
|
: 'Pending'}
|
|
|
|
|
</div>
|
|
|
|
|
{signer.status === 'pending' && doc.documensoId && isInFlight ? (
|
|
|
|
|
<div className="mt-2 flex items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => handleRemind(signer.id)}
|
|
|
|
|
>
|
|
|
|
|
<Bell className="mr-1.5 h-3 w-3" /> Remind
|
|
|
|
|
</Button>
|
|
|
|
|
{signer.signingUrl ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
navigator.clipboard.writeText(signer.signingUrl!);
|
|
|
|
|
toast.success('Signing link copied');
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs text-brand hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Copy signing link
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{subjectLink ? (
|
|
|
|
|
<section className="rounded-md border bg-white p-4">
|
|
|
|
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Linked entity
|
|
|
|
|
</h2>
|
|
|
|
|
<Link
|
|
|
|
|
href={subjectLink.href as Route}
|
|
|
|
|
className="text-sm font-medium text-brand hover:underline"
|
|
|
|
|
>
|
|
|
|
|
{subjectLink.label} →
|
|
|
|
|
</Link>
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Right column */}
|
|
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
<section className="rounded-md border bg-white p-4">
|
|
|
|
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Watchers
|
|
|
|
|
</h2>
|
|
|
|
|
{watchers.length === 0 ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground">No one is watching this document yet.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="space-y-1">
|
|
|
|
|
{watchers.map((w) => (
|
|
|
|
|
<li key={w.userId} className="flex items-center justify-between text-sm">
|
|
|
|
|
<span className="truncate font-mono text-xs text-muted-foreground">
|
|
|
|
|
{w.userId.slice(0, 8)}…
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label="Remove watcher"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
try {
|
|
|
|
|
await apiFetch(`/api/v1/documents/${documentId}/watchers/${w.userId}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
});
|
|
|
|
|
toast.success('Watcher removed');
|
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
queryKey: ['document-detail', documentId],
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:
* 38 client components / 56 toast.error sites converted to
toastError(err) so the new admin error inspector becomes usable from
user-reported issues — every failed inline-edit, save, send, archive,
upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
the existing AppError subclasses. Adds new error codes:
DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
(client-merge "already been merged", expense/interest "couldn't find
that …", documenso "signing service didn't respond").
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:18:05 +02:00
|
|
|
toastError(err);
|
2026-04-28 02:39:46 +02:00
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="text-muted-foreground hover:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section className="rounded-md border bg-white p-4">
|
|
|
|
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Activity
|
|
|
|
|
</h2>
|
|
|
|
|
{events.length === 0 ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground">No events yet.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="space-y-2">
|
|
|
|
|
{events.slice(0, 12).map((e) => (
|
|
|
|
|
<li key={e.id} className="text-xs">
|
|
|
|
|
<div className="font-medium text-foreground">
|
|
|
|
|
{e.eventType.replace(/_/g, ' ')}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-muted-foreground">
|
|
|
|
|
{new Date(e.createdAt).toLocaleString('en-GB')}
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|