Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
129 lines
4.4 KiB
TypeScript
129 lines
4.4 KiB
TypeScript
'use client';
|
|
|
|
import { useQueries, useQuery } from '@tanstack/react-query';
|
|
import { AlertTriangle } from 'lucide-react';
|
|
import Link from 'next/link';
|
|
import { useParams } from 'next/navigation';
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
|
|
|
interface BerthRow {
|
|
id: string;
|
|
mooringNumber: string;
|
|
status: string;
|
|
isPrimary: boolean;
|
|
}
|
|
|
|
interface BerthsResponse {
|
|
data: BerthRow[];
|
|
}
|
|
|
|
interface CompetingInterest {
|
|
interestId: string;
|
|
clientName: string;
|
|
pipelineStage: string;
|
|
isPrimary: boolean;
|
|
}
|
|
|
|
/**
|
|
* Surfaces when one of the interest's linked berths is sold or under offer
|
|
* to a different deal. We don't block the rep from proceeding (the user
|
|
* explicitly wanted v1 to still let the deal advance - the assumption is
|
|
* that the rep is aware and treating the current deal as a fallback if
|
|
* the other one falls through), but the banner makes the conflict visible
|
|
* so they aren't surprised when the rules engine flags it.
|
|
*
|
|
* Fires only for active (non-archived, non-closed) interests - banners on
|
|
* lost deals are noise.
|
|
*/
|
|
export function InterestBerthStatusBanner({
|
|
interestId,
|
|
interestPipelineStage,
|
|
interestOutcome,
|
|
archivedAt,
|
|
}: {
|
|
interestId: string;
|
|
interestPipelineStage: string;
|
|
interestOutcome?: string | null;
|
|
archivedAt?: string | null;
|
|
}) {
|
|
const params = useParams<{ portSlug: string }>();
|
|
const portSlug = params?.portSlug ?? '';
|
|
const { data } = useQuery<BerthsResponse>({
|
|
queryKey: ['interest-berths', interestId],
|
|
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`),
|
|
});
|
|
|
|
const berths = data?.data ?? [];
|
|
const conflicts = berths.filter((b) => b.status === 'sold' || b.status === 'under_offer');
|
|
|
|
// Resolve the competing deal per conflicting berth via the
|
|
// `/active-interests` endpoint shipped in 292a8b5. Filtered client-side
|
|
// to interests OTHER THAN this one so a deal looking at its own berth
|
|
// doesn't see itself in the banner.
|
|
const competingQueries = useQueries({
|
|
queries: conflicts.map((b) => ({
|
|
queryKey: ['berth-competing', b.id, interestId] as const,
|
|
queryFn: () =>
|
|
apiFetch<{ data: CompetingInterest[] }>(`/api/v1/berths/${b.id}/active-interests`).then(
|
|
(r) => r.data.filter((row) => row.interestId !== interestId),
|
|
),
|
|
enabled: conflicts.length > 0,
|
|
staleTime: 30_000,
|
|
})),
|
|
});
|
|
|
|
if (archivedAt || interestOutcome) return null;
|
|
// The banner is most useful before the rep is committed to the deal -
|
|
// once contract is in motion, the conflict is moot.
|
|
if (interestPipelineStage === 'contract') return null;
|
|
|
|
if (conflicts.length === 0) return null;
|
|
|
|
const lines = conflicts.map((b, idx) => {
|
|
const q = competingQueries[idx];
|
|
const competing = (q?.data ?? []).find((c) => c.isPrimary) ?? (q?.data ?? [])[0] ?? null;
|
|
return { berth: b, competing };
|
|
});
|
|
|
|
return (
|
|
<div
|
|
role="status"
|
|
className="flex items-start gap-2 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-900"
|
|
>
|
|
<AlertTriangle className="size-3.5 mt-0.5 shrink-0" aria-hidden />
|
|
<div className="min-w-0">
|
|
<p className="font-medium">
|
|
{lines.length === 1
|
|
? `Berth ${lines[0]!.berth.mooringNumber} is ${
|
|
lines[0]!.berth.status === 'sold' ? 'Sold' : 'Under Offer'
|
|
} to another deal.`
|
|
: `${lines.length} linked berths are no longer freely available.`}
|
|
</p>
|
|
{lines.some((l) => l.competing) ? (
|
|
<ul className="mt-1 space-y-0.5">
|
|
{lines.map(({ berth, competing }) =>
|
|
competing ? (
|
|
<li key={berth.id} className="text-rose-900">
|
|
<span className="font-medium">{berth.mooringNumber}:</span>{' '}
|
|
<Link
|
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
href={`/${portSlug}/interests/${competing.interestId}` as any}
|
|
className="underline-offset-2 hover:underline"
|
|
>
|
|
{competing.clientName}
|
|
</Link>
|
|
</li>
|
|
) : null,
|
|
)}
|
|
</ul>
|
|
) : null}
|
|
<p className="mt-0.5 text-rose-800">
|
|
You can still progress this interest as a backup, but the rep on the other deal owns the
|
|
primary path. If their deal falls through, this one can step in.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|