feat(client-archive): single-client smart-archive dialog + CSP/middleware fixups
UI side of the smart-archive backend that shipped in d07f1ed.
- SmartArchiveDialog renders the dossier as a sectioned modal:
Pipeline interests, Berths (with next-in-line listed), Yachts,
Active reservations, Outstanding invoices, In-flight Documenso
envelopes, Auto-handled summary. Each section has a per-row decision
dropdown with sensible defaults (release for available/under-offer
berths, retain for sold berths and yachts, cancel for active
reservations, leave for invoices and documents).
- High-stakes deals show an amber warning panel + require a reason in
the textarea before the Archive button enables. Signed-document
acknowledgment checkbox blocks submission until checked.
- Wires into client-detail-header in place of the previous dumb
ArchiveConfirmDialog (the simple confirm dialog is kept for the
restore case until the smart-restore wizard ships).
- Pre-flight blocker banner surfaces dossier.blockers (e.g. active
reservation on a sold berth) and disables the Archive button entirely.
Two side fixes from CSP rollout:
- next.config CSP allows unpkg.com in dev so the react-grab devtool
loads. Stripped in prod via the existing isProd flag.
- middleware whitelist now passes /manifest.json + icon-*.png +
apple-touch-icon through unauthenticated, so PWA installability
isn't blocked by the auth redirect.
Bulk variant + restore wizard + hard-delete-with-email-code land in
follow-on commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
import { SmartArchiveDialog } from '@/components/clients/smart-archive-dialog';
|
||||
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
||||
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
|
||||
@@ -41,15 +42,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
|
||||
const isArchived = !!client.archivedAt;
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
setArchiveOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
@@ -187,21 +179,27 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
</div>
|
||||
</DetailHeaderStrip>
|
||||
|
||||
<ArchiveConfirmDialog
|
||||
open={archiveOpen}
|
||||
onOpenChange={setArchiveOpen}
|
||||
entityName={client.fullName}
|
||||
entityType="Client"
|
||||
isArchived={isArchived}
|
||||
onConfirm={() => {
|
||||
if (isArchived) {
|
||||
restoreMutation.mutate();
|
||||
} else {
|
||||
archiveMutation.mutate();
|
||||
}
|
||||
}}
|
||||
isLoading={archiveMutation.isPending || restoreMutation.isPending}
|
||||
/>
|
||||
{/* Restore flow keeps the simple confirm dialog (the smart restore
|
||||
wizard ships in a follow-on commit). Archive uses the new smart
|
||||
dialog with the dossier + per-section decisions. */}
|
||||
{isArchived ? (
|
||||
<ArchiveConfirmDialog
|
||||
open={archiveOpen}
|
||||
onOpenChange={setArchiveOpen}
|
||||
entityName={client.fullName}
|
||||
entityType="Client"
|
||||
isArchived
|
||||
onConfirm={() => restoreMutation.mutate()}
|
||||
isLoading={restoreMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<SmartArchiveDialog
|
||||
open={archiveOpen}
|
||||
onOpenChange={setArchiveOpen}
|
||||
clientId={client.id}
|
||||
clientName={client.fullName}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
554
src/components/clients/smart-archive-dialog.tsx
Normal file
554
src/components/clients/smart-archive-dialog.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Anchor, FileText, Loader2, Receipt, Ship, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface DossierBerth {
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
status: string;
|
||||
otherInterests: Array<{
|
||||
interestId: string;
|
||||
clientId: string | null;
|
||||
clientName: string | null;
|
||||
pipelineStage: string;
|
||||
daysSinceUpdate: number;
|
||||
}>;
|
||||
}
|
||||
interface DossierDocument {
|
||||
documentId: string;
|
||||
templateName: string | null;
|
||||
status: string;
|
||||
documensoEnvelopeId: string | null;
|
||||
isInFlight: boolean;
|
||||
}
|
||||
interface DossierYacht {
|
||||
yachtId: string;
|
||||
name: string;
|
||||
hullNumber: string | null;
|
||||
status: string;
|
||||
}
|
||||
interface DossierReservation {
|
||||
reservationId: string;
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
status: string;
|
||||
startDate: string;
|
||||
}
|
||||
interface DossierInvoice {
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
total: string;
|
||||
currency: string;
|
||||
}
|
||||
interface DossierInterest {
|
||||
interestId: string;
|
||||
pipelineStage: string;
|
||||
primaryBerthMooring: string | null;
|
||||
hasSignedEoi: boolean;
|
||||
}
|
||||
interface ArchiveDossier {
|
||||
client: { id: string; fullName: string; portId: string; archivedAt: string | null };
|
||||
stakeLevel: 'low' | 'high';
|
||||
highStakesStage: string | null;
|
||||
interests: DossierInterest[];
|
||||
berths: DossierBerth[];
|
||||
yachts: DossierYacht[];
|
||||
companies: Array<{ companyId: string; name: string; membershipRole: string | null }>;
|
||||
reservations: DossierReservation[];
|
||||
invoices: DossierInvoice[];
|
||||
documents: DossierDocument[];
|
||||
hasPortalUser: boolean;
|
||||
blockers: string[];
|
||||
}
|
||||
|
||||
type BerthAction = 'release' | 'retain';
|
||||
type YachtAction = 'transfer' | 'mark_sold_away' | 'retain';
|
||||
type ReservationAction = 'cancel' | 'transfer';
|
||||
type InvoiceAction = 'void' | 'write_off' | 'leave';
|
||||
type DocumentAction = 'void_documenso' | 'leave';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
/** Called after successful archive. */
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, onSuccess }: Props) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const dossierQuery = useQuery({
|
||||
queryKey: ['client-archive-dossier', clientId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: ArchiveDossier }>(`/api/v1/clients/${clientId}/archive-dossier`),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const dossier = dossierQuery.data?.data;
|
||||
|
||||
// ─── Local decision state ────────────────────────────────────────────────
|
||||
const [reason, setReason] = useState('');
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const [berthDecisions, setBerthDecisions] = useState<Record<string, BerthAction>>({});
|
||||
const [yachtDecisions, setYachtDecisions] = useState<Record<string, YachtAction>>({});
|
||||
const [reservationDecisions, setReservationDecisions] = useState<
|
||||
Record<string, ReservationAction>
|
||||
>({});
|
||||
const [invoiceDecisions, setInvoiceDecisions] = useState<Record<string, InvoiceAction>>({});
|
||||
const [documentDecisions, setDocumentDecisions] = useState<Record<string, DocumentAction>>({});
|
||||
|
||||
// Reset state when the dialog opens / closes / dossier loads.
|
||||
useEffect(() => {
|
||||
if (!open || !dossier) return;
|
||||
setReason('');
|
||||
setAcknowledged(false);
|
||||
// Sensible defaults: release all berths, retain all yachts, cancel
|
||||
// active reservations, leave invoices, leave documents alone.
|
||||
const b: Record<string, BerthAction> = {};
|
||||
for (const berth of dossier.berths) {
|
||||
// Sold berths can't be released; default to retain.
|
||||
b[berth.berthId] = berth.status === 'sold' ? 'retain' : 'release';
|
||||
}
|
||||
setBerthDecisions(b);
|
||||
setYachtDecisions(Object.fromEntries(dossier.yachts.map((y) => [y.yachtId, 'retain'])));
|
||||
setReservationDecisions(
|
||||
Object.fromEntries(dossier.reservations.map((r) => [r.reservationId, 'cancel'])),
|
||||
);
|
||||
setInvoiceDecisions(Object.fromEntries(dossier.invoices.map((i) => [i.invoiceId, 'leave'])));
|
||||
setDocumentDecisions(Object.fromEntries(dossier.documents.map((d) => [d.documentId, 'leave'])));
|
||||
}, [open, dossier]);
|
||||
|
||||
const hasSignedDocs = useMemo(
|
||||
() =>
|
||||
dossier?.documents.some((d) => d.status === 'completed' || d.status === 'signed') ?? false,
|
||||
[dossier],
|
||||
);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!dossier) return false;
|
||||
if (dossier.blockers.length > 0) return false;
|
||||
if (dossier.stakeLevel === 'high' && reason.trim().length < 5) return false;
|
||||
if (hasSignedDocs && !acknowledged) return false;
|
||||
return true;
|
||||
}, [dossier, reason, hasSignedDocs, acknowledged]);
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!dossier) throw new Error('No dossier');
|
||||
const berthDec = dossier.berths.map((b) => ({
|
||||
berthId: b.berthId,
|
||||
// The interestId for this berth — use the first interest in the
|
||||
// dossier that has this berth as its primary. Fallback to the
|
||||
// first interest at all (the API only needs the link reference).
|
||||
interestId:
|
||||
dossier.interests.find((i) => i.primaryBerthMooring === b.mooringNumber)?.interestId ??
|
||||
dossier.interests[0]?.interestId ??
|
||||
'',
|
||||
action: berthDecisions[b.berthId] ?? 'retain',
|
||||
}));
|
||||
return apiFetch<{ data: { releasedBerths: Array<{ mooringNumber: string }> } }>(
|
||||
`/api/v1/clients/${clientId}/archive`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
reason,
|
||||
acknowledgedSignedDocuments: acknowledged,
|
||||
berthDecisions: berthDec,
|
||||
yachtDecisions: dossier.yachts.map((y) => ({
|
||||
yachtId: y.yachtId,
|
||||
action: yachtDecisions[y.yachtId] ?? 'retain',
|
||||
})),
|
||||
reservationDecisions: dossier.reservations.map((r) => ({
|
||||
reservationId: r.reservationId,
|
||||
action: reservationDecisions[r.reservationId] ?? 'cancel',
|
||||
})),
|
||||
invoiceDecisions: dossier.invoices.map((i) => ({
|
||||
invoiceId: i.invoiceId,
|
||||
action: invoiceDecisions[i.invoiceId] ?? 'leave',
|
||||
})),
|
||||
documentDecisions: dossier.documents.map((d) => ({
|
||||
documentId: d.documentId,
|
||||
action: documentDecisions[d.documentId] ?? 'leave',
|
||||
})),
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
const released = res.data.releasedBerths;
|
||||
toast.success(
|
||||
released.length > 0
|
||||
? `${clientName} archived. ${released.length} berth${released.length === 1 ? '' : 's'} released.`
|
||||
: `${clientName} archived.`,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: ['clients'] });
|
||||
qc.invalidateQueries({ queryKey: ['berths'] });
|
||||
qc.invalidateQueries({ queryKey: ['interests'] });
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Archive {clientName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Archive is reversible — the client can be restored from the archived list. Decide what
|
||||
should happen to the relationships below before continuing.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{dossierQuery.isLoading ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
Loading dossier…
|
||||
</div>
|
||||
) : dossierQuery.error || !dossier ? (
|
||||
<div className="py-8 text-center text-sm text-red-600">
|
||||
Failed to load dossier:{' '}
|
||||
{dossierQuery.error instanceof Error ? dossierQuery.error.message : 'unknown error'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{dossier.blockers.length > 0 && (
|
||||
<Card className="border-red-300 bg-red-50">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-red-800 flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" /> Cannot archive
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-red-700 space-y-1">
|
||||
{dossier.blockers.map((b, i) => (
|
||||
<p key={i}>{b}</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{dossier.stakeLevel === 'high' && (
|
||||
<Card className="border-amber-300 bg-amber-50">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-amber-900 flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Late-stage deal — confirmation required
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-amber-900">
|
||||
This client is at <Badge variant="secondary">{dossier.highStakesStage}</Badge>.
|
||||
Provide a reason explaining why you’re archiving them at this stage. The
|
||||
reason is recorded in the audit log.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Interests + signed-doc acknowledgment */}
|
||||
{dossier.interests.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" /> Pipeline interests ({dossier.interests.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs space-y-1">
|
||||
{dossier.interests.map((i) => (
|
||||
<div key={i.interestId} className="flex items-center justify-between">
|
||||
<span className="font-mono">{i.interestId.slice(0, 8)}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{i.pipelineStage}
|
||||
</Badge>
|
||||
{i.hasSignedEoi && <Badge className="text-xs">Signed EOI</Badge>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasSignedDocs && (
|
||||
<Card className="border-blue-300 bg-blue-50">
|
||||
<CardContent className="pt-4 text-xs text-blue-900">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
I acknowledge this client has signed legal documents. The documents will be
|
||||
retained in the system and remain binding regardless of archive status.
|
||||
</span>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Berths */}
|
||||
{dossier.berths.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Anchor className="h-4 w-4" /> Berths ({dossier.berths.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{dossier.berths.map((b) => (
|
||||
<div key={b.berthId} className="text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Berth {b.mooringNumber}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{b.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<select
|
||||
className="rounded border bg-background px-2 py-1 text-xs disabled:opacity-50"
|
||||
value={berthDecisions[b.berthId] ?? 'retain'}
|
||||
onChange={(e) =>
|
||||
setBerthDecisions((prev) => ({
|
||||
...prev,
|
||||
[b.berthId]: e.target.value as BerthAction,
|
||||
}))
|
||||
}
|
||||
disabled={b.status === 'sold'}
|
||||
>
|
||||
<option value="retain">Retain on archived client</option>
|
||||
<option value="release">Release to available</option>
|
||||
</select>
|
||||
</div>
|
||||
{b.status === 'sold' && (
|
||||
<p className="text-muted-foreground italic">
|
||||
Sold berths stay sold. Process a refund separately if needed.
|
||||
</p>
|
||||
)}
|
||||
{b.otherInterests.length > 0 && berthDecisions[b.berthId] === 'release' && (
|
||||
<p className="text-muted-foreground">
|
||||
Releasing will notify the sales rep. Other interests on this berth:{' '}
|
||||
{b.otherInterests
|
||||
.slice(0, 3)
|
||||
.map((o) => `${o.clientName ?? '?'} (${o.pipelineStage})`)
|
||||
.join(', ')}
|
||||
{b.otherInterests.length > 3 ? ` +${b.otherInterests.length - 3}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Yachts */}
|
||||
{dossier.yachts.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Ship className="h-4 w-4" /> Yachts owned ({dossier.yachts.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{dossier.yachts.map((y) => (
|
||||
<div key={y.yachtId} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{y.name}</span>
|
||||
<select
|
||||
className="rounded border bg-background px-2 py-1 text-xs"
|
||||
value={yachtDecisions[y.yachtId] ?? 'retain'}
|
||||
onChange={(e) =>
|
||||
setYachtDecisions((prev) => ({
|
||||
...prev,
|
||||
[y.yachtId]: e.target.value as YachtAction,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="retain">Leave on archived client</option>
|
||||
<option value="mark_sold_away">Mark as sold-away</option>
|
||||
{/* Transfer to another owner needs an owner picker; v1
|
||||
offers retain + sold-away inline. Use the yacht
|
||||
detail page to transfer to a specific owner. */}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reservations */}
|
||||
{dossier.reservations.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Anchor className="h-4 w-4" /> Active reservations (
|
||||
{dossier.reservations.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{dossier.reservations.map((r) => (
|
||||
<div
|
||||
key={r.reservationId}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span>Berth {r.mooringNumber}</span>
|
||||
<select
|
||||
className="rounded border bg-background px-2 py-1 text-xs"
|
||||
value={reservationDecisions[r.reservationId] ?? 'cancel'}
|
||||
onChange={(e) =>
|
||||
setReservationDecisions((prev) => ({
|
||||
...prev,
|
||||
[r.reservationId]: e.target.value as ReservationAction,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="cancel">Cancel reservation</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Invoices */}
|
||||
{dossier.invoices.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Receipt className="h-4 w-4" /> Outstanding invoices ({dossier.invoices.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{dossier.invoices.map((i) => (
|
||||
<div key={i.invoiceId} className="flex items-center justify-between text-xs">
|
||||
<span>
|
||||
{i.invoiceNumber} · {i.total} {i.currency}
|
||||
</span>
|
||||
<select
|
||||
className="rounded border bg-background px-2 py-1 text-xs"
|
||||
value={invoiceDecisions[i.invoiceId] ?? 'leave'}
|
||||
onChange={(e) =>
|
||||
setInvoiceDecisions((prev) => ({
|
||||
...prev,
|
||||
[i.invoiceId]: e.target.value as InvoiceAction,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="leave">Leave open</option>
|
||||
<option value="void">Void</option>
|
||||
<option value="write_off">Write off</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* In-flight Documenso envelopes */}
|
||||
{dossier.documents.filter((d) => d.isInFlight).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" /> In-flight Documenso envelopes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{dossier.documents
|
||||
.filter((d) => d.isInFlight)
|
||||
.map((d) => (
|
||||
<div key={d.documentId} className="flex items-center justify-between text-xs">
|
||||
<span>{d.templateName ?? d.documentId.slice(0, 8)}</span>
|
||||
<select
|
||||
className="rounded border bg-background px-2 py-1 text-xs"
|
||||
value={documentDecisions[d.documentId] ?? 'leave'}
|
||||
onChange={(e) =>
|
||||
setDocumentDecisions((prev) => ({
|
||||
...prev,
|
||||
[d.documentId]: e.target.value as DocumentAction,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="leave">Leave envelope pending</option>
|
||||
<option value="void_documenso">Void in Documenso</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Auto-handled summary */}
|
||||
<Card className="border-muted">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Users className="h-4 w-4" /> Automatically handled
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground space-y-1">
|
||||
<p>EOI documents — retained for audit (always)</p>
|
||||
{dossier.hasPortalUser && <p>Portal user — deactivated (login revoked)</p>}
|
||||
{dossier.companies.length > 0 && (
|
||||
<p>Company memberships — end-dated to today (history preserved)</p>
|
||||
)}
|
||||
<p>Notes, contacts, tags, addresses — survive on the archived client</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Reason field */}
|
||||
<div>
|
||||
<label className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Reason {dossier.stakeLevel === 'high' && <span className="text-red-600">*</span>}
|
||||
</label>
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder={
|
||||
dossier.stakeLevel === 'high'
|
||||
? `Why are you archiving this client at ${dossier.highStakesStage}?`
|
||||
: 'Optional: why is this client being archived?'
|
||||
}
|
||||
className="mt-1 min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canSubmit || archiveMutation.isPending}
|
||||
onClick={() => archiveMutation.mutate()}
|
||||
>
|
||||
{archiveMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||
) : null}
|
||||
Archive
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -110,11 +110,14 @@ export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (Next.js image optimisation)
|
||||
* - favicon.ico
|
||||
* - /images/ (public image assets)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (Next.js image optimisation)
|
||||
* - favicon.ico (browser tab icon)
|
||||
* - /images/ (public image assets)
|
||||
* - manifest.json (PWA manifest — must be unauthed for installability)
|
||||
* - icon-*.png (PWA + apple-touch icons referenced by manifest)
|
||||
* - apple-touch-icon (iOS home-screen icon)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon\\.ico|images/).*)',
|
||||
'/((?!_next/static|_next/image|favicon\\.ico|images/|manifest\\.json|icon-|apple-touch-icon).*)',
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user