Files
pn-new-crm/src/components/berths/berth-occupancy-chip.tsx

107 lines
3.7 KiB
TypeScript
Raw Normal View History

'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
import { cn } from '@/lib/utils';
interface ActiveInterestRow {
interestId: string;
clientName: string;
pipelineStage: string;
isPrimary: boolean;
isInEoiBundle: boolean;
}
interface BerthOccupancyChipProps {
/** Berth to query. */
berthId: string;
/** Port slug for the competing-interest link. */
portSlug: string;
/** Optional: hide rows from this interest (so a "competing" chip on
* a row inside Interest A doesn't surface A itself). */
excludeInterestId?: string | null;
/** Hide the chip entirely when the berth has zero active interests
* (default: true). Set false when the parent wants a render even
* for available berths useful for the linked-berth row where the
* rep wants explicit "no competing interest" feedback. */
hideWhenEmpty?: boolean;
/** Compact variant single-line chip with truncation. Default
* shows on multiple lines when the client name overflows. */
compact?: boolean;
}
/**
* Surfaces the competing interest(s) that own a non-available berth.
* Reuses /api/v1/berths/[id]/active-interests (shipped for the columns
* popover) so the data path is consistent across:
* - LinkedBerthRowItem (per linked berth on the interest detail)
* - BerthRecommenderPanel recommendation card body
* - InterestBerthStatusBanner (deal-level banner)
*
* Renders the highest-priority competing interest (in-EOI-bundle first,
* then primary, then most-recently-updated). Clicking the chip
* navigates to the competing interest's detail page.
*/
export function BerthOccupancyChip({
berthId,
portSlug,
excludeInterestId,
hideWhenEmpty = true,
compact = false,
}: BerthOccupancyChipProps) {
const { data, isLoading } = useQuery<{ data: ActiveInterestRow[] }>({
queryKey: ['berth', berthId, 'active-interests'],
queryFn: () =>
apiFetch<{ data: ActiveInterestRow[] }>(`/api/v1/berths/${berthId}/active-interests`),
staleTime: 30_000,
});
const rows = data?.data ?? [];
const competing = rows.filter((r) =>
excludeInterestId ? r.interestId !== excludeInterestId : true,
);
if (isLoading) return null;
if (competing.length === 0 && hideWhenEmpty) return null;
if (competing.length === 0) {
return (
<span className="inline-flex items-center text-xs text-muted-foreground">
No competing interest
</span>
);
}
// Priority: in-EOI-bundle (committed) > primary (flagged primary) >
// first by API order (already most-recently-updated server-side).
const primary =
competing.find((r) => r.isInEoiBundle) ?? competing.find((r) => r.isPrimary) ?? competing[0]!;
const extras = competing.length - 1;
return (
<Link
href={`/${portSlug}/interests/${primary.interestId}` as never}
onClick={(e) => e.stopPropagation()}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs text-amber-900 hover:bg-amber-100 transition-colors',
compact && 'max-w-[200px]',
)}
title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`}
>
<span className="font-medium">Under offer to:</span>
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
<span
className={cn(
'shrink-0 rounded-full px-1.5 text-xs',
stageBadgeClass(primary.pipelineStage),
)}
>
{stageLabel(primary.pipelineStage)}
</span>
{extras > 0 ? <span className="shrink-0 text-amber-700">+{extras} more</span> : null}
</Link>
);
}