CM-4: remove Email/Call/WhatsApp deep-link pills from the client + interest detail headers; relocate GDPR export into the client-header action cluster as a compact icon. Keeps the interest "Log contact" quick action. CM-5: gate the interest assignment feature behind a per-port `assignment_enabled` setting (default OFF for single-rep ports). Hides the AssignedToChip + residential assigned-to row and skips tier-2/3 auto-assign on create; the column + data are preserved and reversible. Tests cover the auto-assign guard. CM-6: add a per-port `manualEntry` receipt mode (skip all parsing → empty form). Threaded through ocr-config.service, the admin OCR form, the scan-receipt route, and the scanner shell (skips Tesseract + the server call). Tests cover the save/resolve round-trip. Verified: tsc clean, lint 0 errors, 1631 vitest pass, prod build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
236 lines
8.1 KiB
TypeScript
236 lines
8.1 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { format } from 'date-fns';
|
|
import { Download, FileDown, Loader2, Mail } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { usePermissions } from '@/hooks/use-permissions';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
|
|
interface ExportRow {
|
|
id: string;
|
|
status: 'pending' | 'building' | 'ready' | 'sent' | 'failed';
|
|
storageKey: string | null;
|
|
sizeBytes: number | null;
|
|
createdAt: string;
|
|
readyAt: string | null;
|
|
sentAt: string | null;
|
|
sentTo: string | null;
|
|
error: string | null;
|
|
}
|
|
|
|
interface ListResp {
|
|
data: ExportRow[];
|
|
}
|
|
|
|
const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'destructive'> = {
|
|
pending: 'outline',
|
|
building: 'outline',
|
|
ready: 'secondary',
|
|
sent: 'secondary',
|
|
failed: 'destructive',
|
|
};
|
|
|
|
export function GdprExportButton({
|
|
clientId,
|
|
variant = 'button',
|
|
}: {
|
|
clientId: string;
|
|
/** `button` = standalone outline button (default). `icon` = compact icon-only
|
|
* trigger for the detail-header top-right action cluster (CM-4). */
|
|
variant?: 'button' | 'icon';
|
|
}) {
|
|
const { can, isSuperAdmin } = usePermissions();
|
|
const qc = useQueryClient();
|
|
const [open, setOpen] = useState(false);
|
|
const [emailToClient, setEmailToClient] = useState(false);
|
|
const [emailOverride, setEmailOverride] = useState('');
|
|
|
|
const allowed = isSuperAdmin || can('admin', 'manage_settings');
|
|
|
|
const queryKey = ['gdpr-exports', clientId];
|
|
const { data, isLoading } = useQuery<ListResp>({
|
|
queryKey,
|
|
queryFn: () => apiFetch<ListResp>(`/api/v1/clients/${clientId}/gdpr-export`),
|
|
enabled: open && allowed,
|
|
// Poll only when the user is watching AND a job is in flight. GDPR
|
|
// exports take ~30s; 15s is the rule-of-thumb minimum that doesn't
|
|
// burn CPU. When everything's already settled, stop polling.
|
|
refetchInterval: (q) => {
|
|
if (!open || !allowed) return false;
|
|
const rows = q.state.data?.data ?? [];
|
|
const hasInFlight = rows.some((r) => r.status === 'pending' || r.status === 'building');
|
|
return hasInFlight ? 15_000 : false;
|
|
},
|
|
});
|
|
|
|
const request = useMutation({
|
|
mutationFn: () =>
|
|
apiFetch(`/api/v1/clients/${clientId}/gdpr-export`, {
|
|
method: 'POST',
|
|
body: {
|
|
emailToClient,
|
|
emailOverride: emailOverride.trim() || null,
|
|
},
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('Export queued - refresh in ~30 seconds');
|
|
qc.invalidateQueries({ queryKey });
|
|
setEmailOverride('');
|
|
},
|
|
onError: (err: unknown) => {
|
|
toastError(err);
|
|
},
|
|
});
|
|
|
|
if (!allowed) return null;
|
|
|
|
async function downloadById(exportId: string) {
|
|
try {
|
|
const res = await apiFetch<{ data: { url: string } }>(
|
|
`/api/v1/clients/${clientId}/gdpr-export/${exportId}`,
|
|
);
|
|
window.open(res.data.url, '_blank', 'noopener');
|
|
} catch (err) {
|
|
toastError(err);
|
|
}
|
|
}
|
|
|
|
const rows = data?.data ?? [];
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
{variant === 'icon' ? (
|
|
<button
|
|
type="button"
|
|
aria-label="GDPR export"
|
|
title="GDPR export"
|
|
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
|
>
|
|
<FileDown className="size-4" aria-hidden />
|
|
</button>
|
|
) : (
|
|
<Button variant="outline" size="sm" className="h-8">
|
|
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
GDPR export
|
|
</Button>
|
|
)}
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Personal data export</DialogTitle>
|
|
<DialogDescription>
|
|
Bundles every record we hold about this client (profile, contacts, addresses, yachts,
|
|
companies, interests, tenancies, invoices, documents, audit log) into a ZIP with JSON
|
|
and HTML copies. Used to satisfy GDPR Article 15 access requests.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
|
<Checkbox
|
|
id="email-to-client"
|
|
checked={emailToClient}
|
|
onCheckedChange={(v) => setEmailToClient(v === true)}
|
|
/>
|
|
<div className="space-y-2 flex-1 min-w-0">
|
|
<Label htmlFor="email-to-client" className="text-sm font-medium">
|
|
Email the bundle when ready
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Sends a 7-day signed download link to the client's primary email - or to the
|
|
override below.
|
|
</p>
|
|
{emailToClient ? (
|
|
<Input
|
|
type="email"
|
|
placeholder="optional override (defaults to primary contact)"
|
|
value={emailOverride}
|
|
onChange={(e) => setEmailOverride(e.target.value)}
|
|
className="h-8 text-sm"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<Button onClick={() => request.mutate()} disabled={request.isPending}>
|
|
{request.isPending ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
) : (
|
|
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
)}
|
|
Queue export
|
|
</Button>
|
|
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-2">Recent exports</h4>
|
|
{isLoading ? (
|
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
) : rows.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No exports yet.</p>
|
|
) : (
|
|
<ul className="text-sm divide-y border rounded-lg">
|
|
{rows.map((r) => (
|
|
<li key={r.id} className="flex items-center gap-2 py-2 px-3 hover:bg-muted/50">
|
|
<Badge variant={STATUS_VARIANT[r.status]} className="capitalize text-xs">
|
|
{r.status}
|
|
</Badge>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs">
|
|
Requested {format(new Date(r.createdAt), 'MMM d, yyyy HH:mm')}
|
|
</div>
|
|
{r.sentTo ? (
|
|
<div className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
|
<Mail className="h-3 w-3" aria-hidden />
|
|
Sent to {r.sentTo}
|
|
</div>
|
|
) : null}
|
|
{r.error ? (
|
|
<div className="text-xs text-destructive truncate">{r.error}</div>
|
|
) : null}
|
|
</div>
|
|
{(r.status === 'ready' || r.status === 'sent') && r.storageKey ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => downloadById(r.id)}
|
|
>
|
|
<Download className="h-3.5 w-3.5" aria-hidden />
|
|
</Button>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setOpen(false)}>
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|