Files
pn-new-crm/src/components/berths/berth-detail-header.tsx

303 lines
10 KiB
TypeScript
Raw Normal View History

'use client';
import { useState } from 'react';
import { Pencil, RefreshCw } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { BerthForm } from './berth-form';
feat(berths): NocoDB-aligned dropdown enums + dual-unit auto-fill Pull verbatim SingleSelect choices from NocoDB Berths via MCP and lock them into BERTH_*_OPTIONS / _TYPES in lib/constants.ts: Side Pontoon (10 values), Mooring Type (5), Cleat Type (2), Cleat Capacity (2), Bollard Type (2), Bollard Capacity (2), Access (5), Area (A–E), Bow Facing (4-value UX-only constraint over a SingleLineText). Power Capacity / Voltage stay numeric inputs (NocoDB stores Number). Add `toSelectOptions()` mapper for shadcn `<Select>` `{value,label}` pairs. Wire every berth dropdown — both the modal form and the inline-edit detail tabs — to `<Select>`. Inline `EditableSpec` gains `selectOptions` for the variant and `linkedUnit { field, multiplier }` to auto-patch the metric column on save (× 0.3048 for ft→m on length, width, draft, nominal boat size, water depth). Promote nominal boat size + tenure type from read-only `<SpecRow>` to `<EditableSpec>` so reps can edit them. Tenure type currently uses the validator's `'permanent' | 'fixed_term'` set; will swap to per-port configurable list once Vocabularies admin lands (Wave 5). Mobile berth cards: replace status-coloured stripe with `mooringLetterDot()` so it groups by dock letter; status conveyed by the existing pill below. Berth detail header: "{Letter} Dock" chip instead of bare "A" / "B" text. Berth area filter: `<Select>` over A/B/C/D/E (renamed to "Dock"). Documents tab gets a one-paragraph explainer disambiguating the spec PDF from deal documents (Interests tab). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:10:24 +02:00
import { mooringLetterDot } from './mooring-letter-tone';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useVocabulary } from '@/hooks/use-vocabulary';
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
import { BERTH_STATUSES } from '@/lib/constants';
type BerthDetailData = {
id: string;
mooringNumber: string;
area: string | null;
status: string;
portId: string;
lengthFt: string | null;
lengthM: string | null;
widthFt: string | null;
widthM: string | null;
draftFt: string | null;
draftM: string | null;
widthIsMinimum: boolean | null;
feat(berths): full NocoDB field parity, numeric types, sales edit access Aligns the berths schema with the 117 production rows in NocoDB and exposes every field for editing via the BerthForm sheet. Schema (migration 0020): - power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric (NocoDB stores plain numbers; text was wrong shape and broke filter/sort) - ADD status_override_mode text (1/117 legacy rows have a value; carried forward for parity but not yet wired into the UI) - USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty strings convert cleanly Validator + service: - updateBerthSchema / createBerthSchema use z.coerce.number() for the four numeric fields - berths.service stringifies numeric values for Drizzle's numeric type Form (src/components/berths/berth-form.tsx): - adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag, side pontoon, cleat type/capacity, bollard type/capacity, bow facing - converts to typed selects (with NocoDB option lists in src/lib/constants): area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity, access - power capacity / voltage become numeric inputs (with kW / V hints) Permissions (seed.ts + dev DB): - sales_manager and sales_agent: berths.edit false -> true ("sales will sometimes have to update these and I cannot be the only one") - super_admin / director already had it; viewer stays read-only - dev DB updated in-place via UPDATE roles ... jsonb_set Verification: - pnpm exec vitest run: 858/858 passing - pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing on feat/mobile-foundation, none introduced) - lint clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
nominalBoatSize: string | null;
nominalBoatSizeM: string | null;
waterDepth: string | null;
waterDepthM: string | null;
waterDepthIsMinimum: boolean | null;
sidePontoon: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
bowFacing: string | null;
price: string | null;
priceCurrency: string;
tenureType: string;
tenureYears: number | null;
tenureStartDate: string | null;
tenureEndDate: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
access: string | null;
berthApproved: boolean | null;
tags: Array<{ id: string; name: string; color: string }>;
};
interface BerthDetailHeaderProps {
berth: BerthDetailData;
}
const STATUS_COLORS: Record<string, string> = {
available: 'bg-green-100 text-green-800 border-green-300',
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-300',
sold: 'bg-red-100 text-red-800 border-red-300',
};
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
interface InterestOption {
id: string;
clientName: string;
pipelineStage: string;
}
function StatusChangeDialog({
berthId,
currentStatus,
open,
onOpenChange,
}: {
berthId: string;
currentStatus: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const reasonChips = useVocabulary('berth_status_change_reasons');
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { isSubmitting },
} = useForm<UpdateBerthStatusInput>({
resolver: zodResolver(updateBerthStatusSchema),
defaultValues: { status: currentStatus as (typeof BERTH_STATUSES)[number], reason: '' },
});
const status = watch('status');
const interestId = watch('interestId');
const showInterestPicker = status === 'under_offer' || status === 'sold';
// Active interests for this port — used to populate the prospect
// selector when status moves to under_offer / sold. Only fetched when
// the picker is actually visible to avoid an unnecessary round-trip
// for available-status changes.
const interestsQuery = useQuery<{
data: Array<{ id: string; clientName: string; pipelineStage: string }>;
}>({
queryKey: ['interests', 'status-link-picker'],
queryFn: () => apiFetch('/api/v1/interests?pageSize=200'),
enabled: open && showInterestPicker,
staleTime: 60_000,
});
const interestOptions: InterestOption[] = interestsQuery.data?.data ?? [];
async function onSubmit(data: UpdateBerthStatusInput) {
try {
await apiFetch(`/api/v1/berths/${berthId}/status`, {
method: 'PATCH',
body: data,
});
queryClient.invalidateQueries({ queryKey: ['berths'] });
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
toast.success('Status updated');
reset();
onOpenChange(false);
} catch (err: unknown) {
toastError(err);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change Status</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label>New Status</Label>
<Select
value={status}
onValueChange={(v) => {
setValue('status', v as (typeof BERTH_STATUSES)[number]);
// Clear the interest pick when moving back to available so
// a stale value doesn't sneak through on submit.
if (v === 'available') setValue('interestId', undefined);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BERTH_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{STATUS_LABELS[s]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason *</Label>
{reasonChips.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{reasonChips.map((chip) => (
<button
type="button"
key={chip}
onClick={() => setValue('reason', chip, { shouldDirty: true })}
className="rounded-full border border-muted-foreground/20 bg-muted px-2.5 py-0.5 text-xs hover:bg-accent"
>
{chip}
</button>
))}
</div>
)}
<Textarea {...register('reason')} placeholder="Reason for status change..." rows={3} />
</div>
{showInterestPicker && (
<div className="space-y-2">
<Label>Linked prospect (optional)</Label>
<Select
value={interestId ?? '__none__'}
onValueChange={(v) => setValue('interestId', v === '__none__' ? undefined : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select an interest…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> No interest </SelectItem>
{interestOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.clientName} · {opt.pipelineStage}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Picking an interest auto-creates a primary berth link if one doesn&apos;t already
exist, so the deal timeline + heat scorer attribute the change correctly.
</p>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Update Status'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
const [editOpen, setEditOpen] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
return (
<>
<DetailHeaderStrip>
fix(mobile): clipping, dropdown-tabs and stale phone metadata Five mobile-UX issues caught in the 2026-05-03 audit, fixed in one pass: 1. SpecRow on berth detail clipped at right edge on phone widths. "Length 49.21 ft / 15 r" (the "m" cut off). Mobile-first stack: label on top, value full-width below; flex row only from sm up. 2. ResponsiveTabs collapsed to a Select on phone widths, which read like a generic dropdown and obscured the existence of peer tabs. Replaced with a horizontally-scrollable strip that auto-scrolls the active trigger into view (so the user sees neighbors and gets a discovery cue that more exists beyond the edge). Removes the phone-only Select and unifies the tab UI across viewport sizes. 3. Documents page tab strip ("All / EOI queue / Awaiting them / ...") overflowed the 390px viewport because the wrapper was a fixed flex row. Same horizontal-scroll fix as (2); inherits because Documents uses ResponsiveTabs. 4. Berth detail header: "Change Status" + "Edit" buttons crowded the area subtitle on mobile, causing "North Pier" to wrap to two lines ("North" / "Pier"). Stacked vertically on phone widths; from sm up the buttons sit beside the title block as before. 5. Empty contact rows on client detail rendered a stale "Add tag · star" metadata strip even when the contact value was unset, which cluttered the row and offered no useful action. The metadata block now only shows when contact.value is non-empty; the trash icon stays visible so users can clean up the empty placeholder. Verification: - pnpm exec vitest run: 858/858 passing - pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing on feat/mobile-foundation, none introduced) - lint clean Defers: - Mobile More sheet last-row alignment / "Email" label specificity - Admin index grouping (Access / System / Configuration / Content) - Interest detail header icon labels (trophy/X discoverability) - Pipeline funnel x-axis label abbreviations - Reminders rail width allocation on dashboard Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:03:56 +02:00
{/* Stacks vertically on phone widths so the action buttons don't
squeeze the area subtitle into a two-line wrap. From sm up the
title/area block sits side-by-side with the action buttons. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl sm:text-2xl font-bold text-foreground">
Berth {berth.mooringNumber}
</h1>
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
>
{STATUS_LABELS[berth.status] ?? berth.status}
</span>
</div>
feat(berths): NocoDB-aligned dropdown enums + dual-unit auto-fill Pull verbatim SingleSelect choices from NocoDB Berths via MCP and lock them into BERTH_*_OPTIONS / _TYPES in lib/constants.ts: Side Pontoon (10 values), Mooring Type (5), Cleat Type (2), Cleat Capacity (2), Bollard Type (2), Bollard Capacity (2), Access (5), Area (A–E), Bow Facing (4-value UX-only constraint over a SingleLineText). Power Capacity / Voltage stay numeric inputs (NocoDB stores Number). Add `toSelectOptions()` mapper for shadcn `<Select>` `{value,label}` pairs. Wire every berth dropdown — both the modal form and the inline-edit detail tabs — to `<Select>`. Inline `EditableSpec` gains `selectOptions` for the variant and `linkedUnit { field, multiplier }` to auto-patch the metric column on save (× 0.3048 for ft→m on length, width, draft, nominal boat size, water depth). Promote nominal boat size + tenure type from read-only `<SpecRow>` to `<EditableSpec>` so reps can edit them. Tenure type currently uses the validator's `'permanent' | 'fixed_term'` set; will swap to per-port configurable list once Vocabularies admin lands (Wave 5). Mobile berth cards: replace status-coloured stripe with `mooringLetterDot()` so it groups by dock letter; status conveyed by the existing pill below. Berth detail header: "{Letter} Dock" chip instead of bare "A" / "B" text. Berth area filter: `<Select>` over A/B/C/D/E (renamed to "Dock"). Documents tab gets a one-paragraph explainer disambiguating the spec PDF from deal documents (Interests tab). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:10:24 +02:00
{berth.area && (
<div className="mt-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-0.5 text-xs font-semibold uppercase tracking-wide text-white ${mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-400'}`}
>
{berth.area} Dock
</span>
</div>
)}
</div>
fix(mobile): clipping, dropdown-tabs and stale phone metadata Five mobile-UX issues caught in the 2026-05-03 audit, fixed in one pass: 1. SpecRow on berth detail clipped at right edge on phone widths. "Length 49.21 ft / 15 r" (the "m" cut off). Mobile-first stack: label on top, value full-width below; flex row only from sm up. 2. ResponsiveTabs collapsed to a Select on phone widths, which read like a generic dropdown and obscured the existence of peer tabs. Replaced with a horizontally-scrollable strip that auto-scrolls the active trigger into view (so the user sees neighbors and gets a discovery cue that more exists beyond the edge). Removes the phone-only Select and unifies the tab UI across viewport sizes. 3. Documents page tab strip ("All / EOI queue / Awaiting them / ...") overflowed the 390px viewport because the wrapper was a fixed flex row. Same horizontal-scroll fix as (2); inherits because Documents uses ResponsiveTabs. 4. Berth detail header: "Change Status" + "Edit" buttons crowded the area subtitle on mobile, causing "North Pier" to wrap to two lines ("North" / "Pier"). Stacked vertically on phone widths; from sm up the buttons sit beside the title block as before. 5. Empty contact rows on client detail rendered a stale "Add tag · star" metadata strip even when the contact value was unset, which cluttered the row and offered no useful action. The metadata block now only shows when contact.value is non-empty; the trash icon stays visible so users can clean up the empty placeholder. Verification: - pnpm exec vitest run: 858/858 passing - pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing on feat/mobile-foundation, none introduced) - lint clean Defers: - Mobile More sheet last-row alignment / "Email" label specificity - Admin index grouping (Access / System / Configuration / Content) - Interest detail header icon labels (trophy/X discoverability) - Pipeline funnel x-axis label abbreviations - Reminders rail width allocation on dashboard Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:03:56 +02:00
<div className="flex flex-wrap items-center gap-2 sm:shrink-0">
<PermissionGate resource="berths" action="edit">
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
<RefreshCw className="mr-1.5 h-4 w-4" />
Change Status
</Button>
<Button size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-4 w-4" />
Edit
</Button>
</PermissionGate>
</div>
</div>
</DetailHeaderStrip>
<BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} />
<StatusChangeDialog
berthId={berth.id}
currentStatus={berth.status}
open={statusOpen}
onOpenChange={setStatusOpen}
/>
</>
);
}