Major interest workflow expansion driven by the rapid-fire UX session.
EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.
Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.
Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.
Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).
Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).
Berth interest list overhaul:
- Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
- Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
- Per-letter row tinting via colored left-border accent + dot in cell
- Documents tab merged Files (single attachments section)
Topbar improvements:
- Always-visible back arrow on detail pages (path depth > 2)
- Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
push their entity hierarchy (Clients › Mary Smith › Interest › B17)
- Tighter spacing, softer separators, 160px crumb truncation
DataTable upgrades:
- Page-size selector with All option (validator cap raised to 1000)
- getRowClassName slot for per-row styling (used by berth tinting)
- Fixed Radix SelectItem crash on empty-string values via __any__
sentinel (was crashing every list page that opened a select filter)
Interest list:
- Configurable columns picker
- Stage cell clickable into detail
- TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
- Save view moved into ColumnPicker menu; Views button hidden when
no views are saved
- Pipeline kanban board endpoint at /api/v1/interests/board with
minimal projection, 5000-row cap + truncated banner, filter
pass-through
Mobile chrome + sidebar collapse removed (always-expanded design choice).
User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
217 lines
7.4 KiB
TypeScript
217 lines
7.4 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 }: { clientId: string }) {
|
|
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>
|
|
<Button variant="outline" size="sm" className="h-8">
|
|
<FileDown className="mr-1.5 h-3.5 w-3.5" />
|
|
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, reservations, 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" />
|
|
) : (
|
|
<FileDown className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
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" />
|
|
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" />
|
|
</Button>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setOpen(false)}>
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|