Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
480 lines
16 KiB
TypeScript
480 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
import { type DetailTab } from '@/components/shared/detail-layout';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { formatCurrency } from '@/lib/utils/currency';
|
|
import {
|
|
BERTH_ACCESS_OPTIONS,
|
|
BERTH_BOLLARD_CAPACITIES,
|
|
BERTH_BOLLARD_TYPES,
|
|
BERTH_BOW_FACING_OPTIONS,
|
|
BERTH_CLEAT_CAPACITIES,
|
|
BERTH_CLEAT_TYPES,
|
|
BERTH_MOORING_TYPES,
|
|
BERTH_SIDE_PONTOON_OPTIONS,
|
|
toSelectOptions,
|
|
} from '@/lib/constants';
|
|
import { BerthReservationsTab } from './berth-reservations-tab';
|
|
import { BerthInterestsTab } from './berth-interests-tab';
|
|
import { BerthInterestPulse } from './berth-interest-pulse';
|
|
import { BerthDocumentsTab } from './berth-documents-tab';
|
|
import { BerthDealDocumentsTab } from './berth-deal-documents-tab';
|
|
|
|
type BerthData = {
|
|
id: string;
|
|
mooringNumber: string;
|
|
area: string | null;
|
|
status: string;
|
|
lengthFt: string | null;
|
|
lengthM: string | null;
|
|
widthFt: string | null;
|
|
widthM: string | null;
|
|
draftFt: string | null;
|
|
draftM: string | null;
|
|
widthIsMinimum: boolean | null;
|
|
nominalBoatSize: string | null;
|
|
nominalBoatSizeM: string | null;
|
|
waterDepth: string | null;
|
|
waterDepthM: string | null;
|
|
waterDepthIsMinimum: boolean | null;
|
|
sidePontoon: string | null;
|
|
powerCapacity: string | null;
|
|
voltage: string | null;
|
|
mooringType: string | null;
|
|
cleatType: string | null;
|
|
cleatCapacity: string | null;
|
|
bollardType: string | null;
|
|
bollardCapacity: string | null;
|
|
access: string | null;
|
|
price: string | null;
|
|
priceCurrency: string;
|
|
bowFacing: string | null;
|
|
berthApproved: boolean | null;
|
|
tenureType: string;
|
|
tenureYears: number | null;
|
|
tenureStartDate: string | null;
|
|
tenureEndDate: string | null;
|
|
statusLastChangedReason: string | null;
|
|
statusLastModified: string | null;
|
|
tags: Array<{ id: string; name: string; color: string }>;
|
|
};
|
|
|
|
/**
|
|
* Compact ft/m segmented control for the Specifications card. Two
|
|
* tappable pills with `min-h-[36px]` for an Apple-HIG-friendly touch
|
|
* target. The active option gets the brand primary background; the
|
|
* other reads as muted.
|
|
*/
|
|
function UnitToggle({ value, onChange }: { value: 'ft' | 'm'; onChange: (v: 'ft' | 'm') => void }) {
|
|
return (
|
|
<div
|
|
role="tablist"
|
|
aria-label="Display unit"
|
|
className="inline-flex items-center gap-0.5 rounded-md border bg-muted/40 p-0.5 text-xs"
|
|
>
|
|
{(['ft', 'm'] as const).map((opt) => (
|
|
<button
|
|
key={opt}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={value === opt}
|
|
onClick={() => onChange(opt)}
|
|
className={cn(
|
|
'min-h-[28px] min-w-[40px] rounded px-2 font-medium transition-colors',
|
|
value === opt
|
|
? 'bg-background text-foreground shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{opt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|
if (!value && value !== 0 && value !== false) return null;
|
|
// Mobile-first: stack vertically with label on top so long values
|
|
// (e.g. "206.69 ft / 62.99 m") never clip at the right edge.
|
|
// From `sm` (>=640px) up: switch to the original two-column layout.
|
|
return (
|
|
<div className="flex flex-col gap-0.5 py-2 text-sm sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
|
|
<span className="text-muted-foreground">{label}</span>
|
|
<span className="font-medium sm:max-w-[60%] sm:text-right">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useBerthPatch(berthId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (patch: Record<string, string | number | boolean | null>) =>
|
|
apiFetch(`/api/v1/berths/${berthId}`, {
|
|
method: 'PATCH',
|
|
body: patch,
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['berths', berthId] });
|
|
qc.invalidateQueries({ queryKey: ['berths'] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Editable spec row. Wraps SpecRow with InlineEditableField for fields
|
|
* the operator commonly tweaks (length, width, draft, side pontoon, etc).
|
|
* Read-only fields (mooringNumber, area) keep using plain SpecRow.
|
|
*
|
|
* Numeric fields are stored as strings in the schema (postgres NUMERIC);
|
|
* the `numeric` flag tells us to parse before sending and display "-" when
|
|
* blank.
|
|
*/
|
|
function EditableSpec({
|
|
label,
|
|
value,
|
|
displayValue,
|
|
field,
|
|
patch,
|
|
numeric = false,
|
|
suffix,
|
|
selectOptions,
|
|
linkedUnit,
|
|
}: {
|
|
label: string;
|
|
value: string | null;
|
|
/** Optional formatted version for display only (currency, percent,
|
|
* unit-suffixed). The edit input still works against the raw `value`. */
|
|
displayValue?: string | null;
|
|
field: string;
|
|
patch: ReturnType<typeof useBerthPatch>;
|
|
numeric?: boolean;
|
|
suffix?: string;
|
|
/** When provided, the inline editor uses a `<Select>` constrained to these
|
|
* values. Mirrors the canonical NocoDB SingleSelect choices for the field. */
|
|
selectOptions?: readonly string[];
|
|
/** When set, saving this numeric field also patches the paired column with
|
|
* the converted unit (e.g. ft → m via 0.3048). Mirrors the legacy NocoDB
|
|
* Formula columns; without this the form's two-unit editors stay out of
|
|
* sync after an inline edit. */
|
|
linkedUnit?: { field: string; multiplier: number };
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-0.5 py-2 text-sm sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
|
|
<span className="text-muted-foreground">{label}</span>
|
|
<span className="font-medium sm:max-w-[60%] sm:text-right">
|
|
{selectOptions ? (
|
|
<InlineEditableField
|
|
variant="select"
|
|
value={value}
|
|
options={toSelectOptions(selectOptions)}
|
|
onSave={async (next) => {
|
|
await patch.mutateAsync({ [field]: next });
|
|
}}
|
|
/>
|
|
) : (
|
|
<InlineEditableField
|
|
value={value}
|
|
displayValue={displayValue}
|
|
onSave={async (next) => {
|
|
if (numeric) {
|
|
if (next === null || next.trim() === '') {
|
|
const clear = linkedUnit
|
|
? { [field]: null, [linkedUnit.field]: null }
|
|
: { [field]: null };
|
|
await patch.mutateAsync(clear);
|
|
return;
|
|
}
|
|
const n = Number.parseFloat(next);
|
|
if (Number.isNaN(n)) throw new Error('Must be a number');
|
|
const body: Record<string, number | null> = { [field]: n };
|
|
if (linkedUnit) {
|
|
body[linkedUnit.field] = Number((n * linkedUnit.multiplier).toFixed(2));
|
|
}
|
|
await patch.mutateAsync(body);
|
|
return;
|
|
}
|
|
await patch.mutateAsync({ [field]: next });
|
|
}}
|
|
placeholder={suffix ? `e.g. 25${suffix ? ` ${suffix}` : ''}` : undefined}
|
|
/>
|
|
)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Conversion factors between feet and meters. 0.3048 is the exact
|
|
// definition (1 ft = 0.3048 m by international agreement).
|
|
const FT_TO_M = 0.3048;
|
|
const M_TO_FT = 1 / FT_TO_M;
|
|
|
|
function OverviewTab({ berth }: { berth: BerthData }) {
|
|
const patch = useBerthPatch(berth.id);
|
|
// User-selected display unit for dimensions. Persisted in localStorage
|
|
// so reps' preferred unit sticks across navigations + sessions.
|
|
const [units, setUnits] = useState<'ft' | 'm'>('ft');
|
|
useEffect(() => {
|
|
const stored = localStorage.getItem('berth-overview-units');
|
|
if (stored === 'ft' || stored === 'm') setUnits(stored);
|
|
}, []);
|
|
useEffect(() => {
|
|
localStorage.setItem('berth-overview-units', units);
|
|
}, [units]);
|
|
|
|
const u = units;
|
|
// For each dimension, pick the column matching the selected unit and
|
|
// point linkedUnit at the opposite column so edits keep both in sync.
|
|
const dim = (ftField: string, mField: string) =>
|
|
units === 'ft'
|
|
? { field: ftField, linkedUnit: { field: mField, multiplier: FT_TO_M } }
|
|
: { field: mField, linkedUnit: { field: ftField, multiplier: M_TO_FT } };
|
|
const dimValue = (ftValue: string | null, mValue: string | null) =>
|
|
units === 'ft' ? ftValue : mValue;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Sales pulse - top-of-page so reps doing berth-level triage can see
|
|
who's interested + how warm without clicking into the Interests tab. */}
|
|
<BerthInterestPulse berthId={berth.id} />
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Specifications */}
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-3">
|
|
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
|
|
<UnitToggle value={units} onChange={setUnits} />
|
|
</CardHeader>
|
|
<CardContent className="pt-0 divide-y">
|
|
<EditableSpec
|
|
label={`Length (${u})`}
|
|
value={dimValue(berth.lengthFt, berth.lengthM)}
|
|
{...dim('lengthFt', 'lengthM')}
|
|
patch={patch}
|
|
numeric
|
|
suffix={u}
|
|
/>
|
|
<EditableSpec
|
|
label={`Width (${u})`}
|
|
value={dimValue(berth.widthFt, berth.widthM)}
|
|
{...dim('widthFt', 'widthM')}
|
|
patch={patch}
|
|
numeric
|
|
suffix={u}
|
|
/>
|
|
<EditableSpec
|
|
label={`Draft (${u})`}
|
|
value={dimValue(berth.draftFt, berth.draftM)}
|
|
{...dim('draftFt', 'draftM')}
|
|
patch={patch}
|
|
numeric
|
|
suffix={u}
|
|
/>
|
|
<EditableSpec
|
|
label={`Nominal Boat Size (${u})`}
|
|
value={dimValue(berth.nominalBoatSize, berth.nominalBoatSizeM)}
|
|
{...dim('nominalBoatSize', 'nominalBoatSizeM')}
|
|
patch={patch}
|
|
numeric
|
|
suffix={u}
|
|
/>
|
|
<EditableSpec
|
|
label={`Water Depth (${u})`}
|
|
value={dimValue(berth.waterDepth, berth.waterDepthM)}
|
|
{...dim('waterDepth', 'waterDepthM')}
|
|
patch={patch}
|
|
numeric
|
|
suffix={u}
|
|
/>
|
|
<EditableSpec
|
|
label="Mooring Type"
|
|
value={berth.mooringType}
|
|
field="mooringType"
|
|
patch={patch}
|
|
selectOptions={BERTH_MOORING_TYPES}
|
|
/>
|
|
<EditableSpec
|
|
label="Side Pontoon"
|
|
value={berth.sidePontoon}
|
|
field="sidePontoon"
|
|
patch={patch}
|
|
selectOptions={BERTH_SIDE_PONTOON_OPTIONS}
|
|
/>
|
|
<EditableSpec
|
|
label="Bow Facing"
|
|
value={berth.bowFacing}
|
|
field="bowFacing"
|
|
patch={patch}
|
|
selectOptions={BERTH_BOW_FACING_OPTIONS}
|
|
/>
|
|
<EditableSpec
|
|
label="Access"
|
|
value={berth.access}
|
|
field="access"
|
|
patch={patch}
|
|
selectOptions={BERTH_ACCESS_OPTIONS}
|
|
/>
|
|
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Infrastructure & Pricing */}
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0 divide-y">
|
|
<EditableSpec
|
|
label="Power Capacity (kW)"
|
|
value={berth.powerCapacity}
|
|
field="powerCapacity"
|
|
patch={patch}
|
|
numeric
|
|
suffix="kW"
|
|
/>
|
|
<EditableSpec
|
|
label="Voltage (V)"
|
|
value={berth.voltage}
|
|
field="voltage"
|
|
patch={patch}
|
|
numeric
|
|
suffix="V"
|
|
/>
|
|
<EditableSpec
|
|
label="Cleat Type"
|
|
value={berth.cleatType}
|
|
field="cleatType"
|
|
patch={patch}
|
|
selectOptions={BERTH_CLEAT_TYPES}
|
|
/>
|
|
<EditableSpec
|
|
label="Cleat Capacity"
|
|
value={berth.cleatCapacity}
|
|
field="cleatCapacity"
|
|
patch={patch}
|
|
selectOptions={BERTH_CLEAT_CAPACITIES}
|
|
/>
|
|
<EditableSpec
|
|
label="Bollard Type"
|
|
value={berth.bollardType}
|
|
field="bollardType"
|
|
patch={patch}
|
|
selectOptions={BERTH_BOLLARD_TYPES}
|
|
/>
|
|
<EditableSpec
|
|
label="Bollard Capacity"
|
|
value={berth.bollardCapacity}
|
|
field="bollardCapacity"
|
|
patch={patch}
|
|
selectOptions={BERTH_BOLLARD_CAPACITIES}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0 divide-y">
|
|
{/* Tenure type values are admin-configurable per port (Wave 5).
|
|
Today the list is the static union from the zod validator. */}
|
|
<EditableSpec
|
|
label="Tenure Type"
|
|
value={berth.tenureType}
|
|
field="tenureType"
|
|
patch={patch}
|
|
selectOptions={['permanent', 'fixed_term'] as const}
|
|
/>
|
|
{berth.tenureType === 'fixed_term' && (
|
|
<>
|
|
<SpecRow label="Years" value={berth.tenureYears} />
|
|
<SpecRow label="Start Date" value={berth.tenureStartDate} />
|
|
<SpecRow label="End Date" value={berth.tenureEndDate} />
|
|
</>
|
|
)}
|
|
<EditableSpec
|
|
label={`Price (${berth.priceCurrency || 'USD'})`}
|
|
value={berth.price}
|
|
displayValue={
|
|
berth.price
|
|
? formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 })
|
|
: null
|
|
}
|
|
field="price"
|
|
patch={patch}
|
|
numeric
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">Tags</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<InlineTagEditor
|
|
endpoint={`/api/v1/berths/${berth.id}/tags`}
|
|
currentTags={berth.tags}
|
|
invalidateKey={['berths', berth.id]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function buildBerthTabs(berth: BerthData): DetailTab[] {
|
|
return [
|
|
{
|
|
id: 'overview',
|
|
label: 'Overview',
|
|
content: <OverviewTab berth={berth} />,
|
|
},
|
|
{
|
|
id: 'interests',
|
|
label: 'Interests',
|
|
content: <BerthInterestsTab berthId={berth.id} />,
|
|
},
|
|
{
|
|
id: 'reservations',
|
|
label: 'Reservations',
|
|
content: <BerthReservationsTab berthId={berth.id} />,
|
|
},
|
|
{
|
|
id: 'spec',
|
|
label: 'Spec',
|
|
content: <BerthDocumentsTab berthId={berth.id} />,
|
|
},
|
|
{
|
|
id: 'deal-documents',
|
|
label: 'Deal Documents',
|
|
content: <BerthDealDocumentsTab berthId={berth.id} />,
|
|
},
|
|
// Waiting List + Maintenance Log tabs were stubs ("coming soon")
|
|
// visible to every operator. Hidden here until the
|
|
// berth_waiting_list / berth_maintenance_log feature surfaces ship.
|
|
{
|
|
id: 'activity',
|
|
label: 'Activity',
|
|
content: (
|
|
<EntityActivityFeed
|
|
endpoint={`/api/v1/berths/${berth.id}/activity`}
|
|
emptyText="No activity recorded for this berth yet."
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
}
|