Action buttons in entity detail headers (Invite/GDPR/Archive on clients, similar sets elsewhere) overflowed off-screen at 393px because the actions row was flex without flex-wrap. Adds flex-wrap so buttons drop to a second/third row instead of clipping. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Archive, RotateCcw, Mail, Phone } from 'lucide-react';
|
|
|
|
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 { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
|
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
|
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
|
|
interface ClientDetailHeaderProps {
|
|
client: {
|
|
id: string;
|
|
fullName: string;
|
|
nationality?: string | null;
|
|
preferredContactMethod?: string | null;
|
|
preferredLanguage?: string | null;
|
|
timezone?: string | null;
|
|
source?: string | null;
|
|
sourceDetails?: string | null;
|
|
archivedAt?: string | null;
|
|
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
|
|
tags?: Array<{ id: string; name: string; color: string }>;
|
|
clientPortalEnabled?: boolean;
|
|
};
|
|
}
|
|
|
|
const SOURCE_LABELS: Record<string, string> = {
|
|
website: 'Website',
|
|
manual: 'Manual',
|
|
referral: 'Referral',
|
|
broker: 'Broker',
|
|
};
|
|
|
|
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|
const queryClient = useQueryClient();
|
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
|
|
|
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: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
|
|
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
|
setArchiveOpen(false);
|
|
},
|
|
});
|
|
|
|
const primaryEmail =
|
|
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
|
|
client.contacts?.find((c) => c.channel === 'email');
|
|
const primaryPhone =
|
|
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
|
|
client.contacts?.find((c) => c.channel === 'phone');
|
|
|
|
return (
|
|
<>
|
|
<DetailHeaderStrip>
|
|
<div className="flex items-start gap-3 flex-wrap">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h1 className="text-2xl font-bold text-foreground truncate">{client.fullName}</h1>
|
|
{isArchived && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Archived
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
|
|
{client.source && (
|
|
<span>
|
|
Source:{' '}
|
|
<span className="text-foreground">
|
|
{SOURCE_LABELS[client.source] ?? client.source}
|
|
</span>
|
|
</span>
|
|
)}
|
|
{primaryEmail && (
|
|
<span className="flex items-center gap-1">
|
|
<Mail className="h-3.5 w-3.5" />
|
|
{primaryEmail.value}
|
|
</span>
|
|
)}
|
|
{primaryPhone && (
|
|
<span className="flex items-center gap-1">
|
|
<Phone className="h-3.5 w-3.5" />
|
|
{primaryPhone.value}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{client.tags && client.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{client.tags.map((tag) => (
|
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{!isArchived && client.clientPortalEnabled !== false && (
|
|
<PortalInviteButton
|
|
clientId={client.id}
|
|
clientName={client.fullName}
|
|
defaultEmail={primaryEmail?.value}
|
|
/>
|
|
)}
|
|
<GdprExportButton clientId={client.id} />
|
|
<Button
|
|
variant={isArchived ? 'outline' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setArchiveOpen(true)}
|
|
>
|
|
{isArchived ? (
|
|
<>
|
|
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
|
Restore
|
|
</>
|
|
) : (
|
|
<>
|
|
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
|
Archive
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|