Files
pn-new-crm/src/components/yachts/yacht-detail-header.tsx

255 lines
8.4 KiB
TypeScript
Raw Normal View History

'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>
fix(ux): pass-3 — yacht/company headers, reminder filters wrap, client tab counts Five small fixes from the third audit pass on previously-unchecked surfaces: Yacht detail header (mobile): - Stack the action cluster (Edit / Transfer / Archive) below the title block on phone widths. Previously the three buttons crowded the right side enough to truncate the status pill to "A..." and force the owner name to wrap to two lines. Same fix that landed for berth / client / company headers. Company detail header (mobile): - Same mobile stacking fix; legal-name + Tax-ID metadata no longer wraps awkwardly. Company detail Incorporation Date (all viewports): - Strip the time portion of the ISO timestamp before passing to the inline editor. Previously rendered the raw "2019-03-14T00:00:00.000Z" Postgres-serialized form. Now reads "2019-03-14" and round-trips through the YYYY-MM-DD inline editor cleanly. Reminders list filter row: - Allow flex-wrap on the My/All tabs + status filter + priority filter cluster. At 390px, the priority filter dropdown was being pushed off the right edge of the screen. Client detail tab counts: - Add interestCount + noteCount to getClientById response, surface as badges on the Interests + Notes tabs. Brings them into parity with Yachts/Companies/Reservations/Addresses which already showed counts; Files + Activity are still stubs and don't get a count yet. Verification: 0 tsc errors, 926/926 vitest passing, lint clean. Out of scope (deferred): - Residential clients / interests pages still render plain HTML tables on phone widths (header columns clip at the right edge). Needs the DataView card-on-mobile treatment that the main /clients and /interests pages already have. Substantial separate work. - Phone contacts in the legacy seed have value set but valueE164 NULL, so InlinePhoneField shows "—" even though metadata is technically populated. Fix is a one-time backfill via libphonenumber-js, not a UI change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:09:27 +02:00
{/* Stacks vertically on phone widths so the action cluster doesn't
crush the status pill / owner row. From sm up, title block sits
beside actions in the original layout. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-3 sm:flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="hidden sm:block 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 }}
/>
</>
);
}