Files
pn-new-crm/src/components/berths/berth-card.tsx
Matt 39c19b2340
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m11s
Build & Push Docker Images / build-and-push (push) Successful in 13m12s
feat(berths): click-to-change status from the list (chip → reason modal)
Adds BerthStatusQuickEdit — wraps the status chip on the berths list (card +
table) in a click target that opens a compact change-status dialog: status
dropdown + required reason (quick-pick chips) + optional interest link when
moving to under_offer/sold. Reuses the existing PATCH /api/v1/berths/[id]/status
endpoint + validator + audit (same capability the detail page already had).
Gated by berths.edit (non-editors see a plain chip); stops click propagation
so it doesn't also navigate into the berth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:01:40 +02:00

188 lines
7.2 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 { Activity, MoreHorizontal, Pencil } from 'lucide-react';
import { useRouter, useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
import { formatCurrency } from '@/lib/utils/currency';
import type { BerthRow } from './berth-columns';
import { mooringLetterDot } from './mooring-letter-tone';
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
const BERTH_STATUS_PILL: Record<string, StatusPillStatus> = {
available: 'available',
under_offer: 'under_offer',
sold: 'sold',
};
interface BerthCardProps {
berth: BerthRow;
}
export function BerthCard({ berth }: BerthCardProps) {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const statusLabel = STATUS_LABELS[berth.status] ?? berth.status;
const statusPill = BERTH_STATUS_PILL[berth.status] ?? 'pending';
// Accent stripe groups visually by dock (A-row, B-row, ...). Status is
// already conveyed by the pill below, so the stripe is dock-keyed.
const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300';
// Dimensions string - Length × Width × Draft (each segment is optional).
// The avatar already conveys the mooring number, so this becomes the
// primary "what is this berth" line.
const dimParts: string[] = [];
if (berth.lengthM) dimParts.push(`${berth.lengthM}m`);
if (berth.widthM) dimParts.push(`${berth.widthM}m`);
if (berth.draftM) dimParts.push(`${berth.draftM}m draft`);
const dimText = dimParts.length > 0 ? dimParts.join(' × ') : null;
// Recommended boat size - the most rep-actionable signal in a glance
// ("can my client's yacht park here?"). Tenure was previously here but
// dropped: tenure is set per EOI/contract, not per berth, so showing
// it as a berth property was misleading.
let boatCapacityText: string | null = null;
if (berth.nominalBoatSizeM) {
boatCapacityText = `Fits up to ${berth.nominalBoatSizeM}m`;
} else if (berth.nominalBoatSize) {
boatCapacityText = `Fits up to ${berth.nominalBoatSize}ft`;
}
// Water depth - operational; matters for deep-keel yachts.
let waterDepthText: string | null = null;
if (berth.waterDepthM) {
const prefix = berth.waterDepthIsMinimum ? '≥ ' : '';
waterDepthText = `${prefix}${berth.waterDepthM}m deep`;
}
// Power label: combine capacity + voltage when both present.
let powerText: string | null = null;
if (berth.powerCapacity && berth.voltage) {
powerText = `${berth.powerCapacity}A / ${berth.voltage}V`;
} else if (berth.powerCapacity) {
powerText = `${berth.powerCapacity}A`;
} else if (berth.voltage) {
powerText = `${berth.voltage}V`;
}
// Secondary meta: boat-capacity · water-depth · price · power. All
// optional; order favours the highest-utility scan signals first.
const metaParts: string[] = [];
if (boatCapacityText) metaParts.push(boatCapacityText);
if (waterDepthText) metaParts.push(waterDepthText);
if (berth.price)
metaParts.push(formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 }));
if (powerText) metaParts.push(powerText);
const tags = berth.tags ?? [];
return (
<ListCard
href={`/${portSlug}/berths/${berth.id}`}
ariaLabel={`Berth ${berth.mooringNumber}`}
accentClassName={accentClass}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={(e) => e.stopPropagation()}
aria-label={`Actions for berth ${berth.mooringNumber}`}
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${portSlug}/berths/${berth.id}`);
}}
>
<Activity className="mr-2 h-3.5 w-3.5" aria-hidden />
View details
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${portSlug}/berths/${berth.id}?edit=true`);
}}
>
<Pencil className="mr-2 h-3.5 w-3.5" aria-hidden />
Edit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
>
<div className="flex items-center gap-3">
{/* The mooring number IS the avatar - recognisable at a glance
(A1, B12, …) and eliminates the duplicate berth-number heading
that previously sat to the right of an anchor icon. */}
<ListCardAvatar
initials={berth.mooringNumber}
className="text-base font-bold tracking-tight"
/>
<div className="min-w-0 flex-1">
{/* Primary line: dimensions (L × W × Draft). The avatar
already carries the area letter, so this slot becomes the
"what fits here" answer. Falls back gracefully when
dimensions aren't recorded yet. */}
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 truncate text-sm font-semibold text-foreground">
{dimText ?? <span className="font-normal text-muted-foreground">No dimensions</span>}
</p>
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Meta line: tenure · price · power. All optional. */}
{metaParts.length > 0 ? (
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
{metaParts.map((part, i) => (
<span key={part} className="inline-flex items-center gap-1">
{i > 0 ? <span aria-hidden>·</span> : null}
<ListCardMeta>{part}</ListCardMeta>
</span>
))}
</div>
) : null}
{/* Status pill + tags */}
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<BerthStatusQuickEdit berthId={berth.id} currentStatus={berth.status}>
<StatusPill status={statusPill}>{statusLabel}</StatusPill>
</BerthStatusQuickEdit>
{tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{tags.length > 2 ? (
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
+{tags.length - 2}
</span>
) : null}
</div>
</div>
</div>
</ListCard>
);
}