Files
pn-new-crm/src/components/yachts/yacht-detail-header.tsx
Matt Ciaccio a653c8e039 fix(mobile): wrap detail-header actions on narrow viewports
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>
2026-05-01 15:48:51 +02:00

250 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive, ArrowRightLeft } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
import { apiFetch } from '@/lib/api/client';
interface YachtDetailHeaderYacht {
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
flag: string | null;
yearBuilt: number | null;
builder: string | null;
model: string | null;
hullMaterial: string | null;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
draftM: string | null;
currentOwnerType: 'client' | 'company';
currentOwnerId: string;
status: string;
notes: string | null;
archivedAt: string | null;
}
interface YachtDetailHeaderProps {
yacht: YachtDetailHeaderYacht;
}
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800 border-green-300',
retired: 'bg-gray-100 text-gray-800 border-gray-300',
sold_away: 'bg-amber-100 text-amber-800 border-amber-300',
};
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
retired: 'Retired',
sold_away: 'Sold Away',
};
export function OwnerLink({
portSlug,
type,
id,
preloadedName,
}: {
portSlug: string;
type: 'client' | 'company';
id: string;
/** Optional name supplied by the parent list/detail endpoint to skip the
* per-row fetch (avoids an N+1 round-trip on lists). */
preloadedName?: string | null;
}) {
// Only fetch when the parent didn't already supply a name — list endpoints
// batch-resolve owners server-side via a join.
const { data } = useQuery<{ fullName?: string; name?: string }>({
queryKey: [type === 'client' ? 'clients' : 'companies', id, 'name-only'],
queryFn: () =>
apiFetch<{ data: { fullName?: string; name?: string } }>(
type === 'client' ? `/api/v1/clients/${id}` : `/api/v1/companies/${id}`,
).then((r) => r.data),
enabled: preloadedName === undefined || preloadedName === null,
});
const label = preloadedName ?? (type === 'client' ? data?.fullName : data?.name);
const href = type === 'client' ? `/${portSlug}/clients/${id}` : `/${portSlug}/companies/${id}`;
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
className="text-primary hover:underline"
>
{label ?? `${type === 'client' ? 'Client' : 'Company'} ${id.slice(0, 8)}`}
</Link>
);
}
function formatDimensions(yacht: YachtDetailHeaderYacht): string | null {
const parts: string[] = [];
if (yacht.lengthFt) parts.push(`${yacht.lengthFt} ft`);
if (yacht.widthFt) parts.push(`${yacht.widthFt} ft`);
let summary: string | null = null;
if (parts.length > 0) {
summary = parts.join(' × ');
}
if (yacht.draftFt) {
summary = summary ? `${summary} (draft ${yacht.draftFt} ft)` : `draft ${yacht.draftFt} ft`;
}
return summary;
}
export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
const queryClient = useQueryClient();
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const isArchived = !!yacht.archivedAt;
const archiveMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/yachts/${yacht.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['yachts', yacht.id] });
queryClient.invalidateQueries({ queryKey: ['yachts'] });
toast.success('Yacht archived');
setArchiveOpen(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(`/${portSlug}/yachts` as any);
},
onError: (err: Error) => {
toast.error(err.message || 'Failed to archive yacht');
},
});
const dimensions = formatDimensions(yacht);
const statusLabel = STATUS_LABELS[yacht.status] ?? yacht.status;
const statusColor = STATUS_COLORS[yacht.status] ?? 'bg-muted text-muted-foreground border-muted';
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">{yacht.name}</h1>
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
>
{statusLabel}
</span>
{isArchived && (
<Badge variant="secondary" className="text-xs">
Archived
</Badge>
)}
</div>
{dimensions && <p className="text-muted-foreground mt-0.5 text-sm">{dimensions}</p>}
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<span>Owner:</span>
<OwnerLink
portSlug={portSlug}
type={yacht.currentOwnerType}
id={yacht.currentOwnerId}
/>
</div>
</div>
{/* Actions */}
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<PermissionGate resource="yachts" action="transfer">
<Button
variant="outline"
size="sm"
onClick={() => setTransferOpen(true)}
disabled={isArchived}
>
<ArrowRightLeft className="mr-1.5 h-3.5 w-3.5" />
Transfer
</Button>
</PermissionGate>
<Button
variant="outline"
size="sm"
onClick={() => setArchiveOpen(true)}
disabled={isArchived}
>
<Archive className="mr-1.5 h-3.5 w-3.5" />
Archive
</Button>
</div>
</div>
</DetailHeaderStrip>
<YachtForm
open={editOpen}
onOpenChange={setEditOpen}
yacht={{
id: yacht.id,
name: yacht.name,
hullNumber: yacht.hullNumber,
registration: yacht.registration,
flag: yacht.flag,
yearBuilt: yacht.yearBuilt,
builder: yacht.builder,
model: yacht.model,
hullMaterial: yacht.hullMaterial,
lengthFt: yacht.lengthFt,
widthFt: yacht.widthFt,
draftFt: yacht.draftFt,
lengthM: yacht.lengthM,
widthM: yacht.widthM,
draftM: yacht.draftM,
currentOwnerType: yacht.currentOwnerType,
currentOwnerId: yacht.currentOwnerId,
status: yacht.status,
notes: yacht.notes,
}}
/>
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
entityName={yacht.name}
entityType="Yacht"
isArchived={isArchived}
onConfirm={() => {
archiveMutation.mutate();
}}
isLoading={archiveMutation.isPending}
/>
<YachtTransferDialog
open={transferOpen}
onOpenChange={setTransferOpen}
yachtId={yacht.id}
currentOwner={{ type: yacht.currentOwnerType, id: yacht.currentOwnerId }}
/>
</>
);
}