feat(uat-batch): Group C Berth list features (3 new ships + 1 verified)
C20–C23 from the 2026-05-21 plan.
Shipped now:
C21 Dimensions ft/m column toggle persisted to user prefs.
`TablePreferences.dimensionUnit` ('ft' | 'm') added to the user-
profiles JSONB. `useTablePreferences` returns `dimensionUnit` +
`setDimensionUnit` alongside hidden/density. New
`getBerthColumns(unit)` factory rewrites the dimensions /
nominalBoatSize / waterDepth cells when ft is requested
(waterDepth converts on-the-fly from the canonical meters
column at 3.2808 ft/m). Berth-list toolbar gains a small
ft/m toggle button next to the density toggle.
C22 ft/m switching on Berth Requirements rows.
`interest-tabs.tsx` Berth-requirements section now honours
`interest.desiredLengthUnit`. Labels flip to "(m)" when set;
value reads from `desired*M` columns; on save, both the chosen-
unit and the canonical counterpart columns are PATCHed (3.28084
ratio) so downstream surfaces (recommender, EOI merge fields)
stay in lockstep. `InterestPatchField` widened with `desired*M`
variants.
C23 Berth list bulk-edit affordance.
New `POST /api/v1/berths/bulk` (mirror of /interests/bulk):
discriminated union of `change_status` / `change_tenure_type` /
`add_tag` / `remove_tag` / `archive`, 500-id cap, per-row
failure reporting, single `berths.edit` permission gate
(no separate `archive` perm exists on berths today). Status
mutations route through `updateBerthStatus` so under-offer /
sold transitions still trigger the primary interest_berths
auto-link + the rules-engine evaluation.
BerthList toolbar wires `bulkActions` on the DataTable —
Change status (Select dialog), Change tenure (permanent /
fixed-term), Add tag, Remove tag, Archive (destructive +
confirmation). Each dialog uses the same `bulkMutation` so
toast + cache-invalidation behaviour is consistent across
actions.
Already shipped (verified):
C20 Berth list rates / pricing valid columns hidden by default —
already in `BERTH_DEFAULT_HIDDEN`.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,9 @@ type InterestPatchField =
|
||||
| 'desiredLengthFt'
|
||||
| 'desiredWidthFt'
|
||||
| 'desiredDraftFt'
|
||||
| 'desiredLengthM'
|
||||
| 'desiredWidthM'
|
||||
| 'desiredDraftM'
|
||||
| 'dateEoiSent'
|
||||
| 'dateEoiSigned'
|
||||
| 'dateReservationSigned'
|
||||
@@ -95,6 +98,12 @@ interface InterestTabsOptions {
|
||||
desiredLengthFt?: string | null;
|
||||
desiredWidthFt?: string | null;
|
||||
desiredDraftFt?: string | null;
|
||||
/** Metric counterparts persisted alongside the imperial columns. The
|
||||
* Berth-requirements row toggles between the two based on
|
||||
* `desiredLengthUnit`. */
|
||||
desiredLengthM?: string | null;
|
||||
desiredWidthM?: string | null;
|
||||
desiredDraftM?: string | null;
|
||||
/** Unit the rep originally entered the dims in — drives the
|
||||
* recommender header's display so a metric-entered deal doesn't
|
||||
* render as ft. The three columns share an entry unit in practice. */
|
||||
@@ -1161,32 +1170,78 @@ function OverviewTab({
|
||||
BerthRecommenderPanel rankings below. */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Berth requirements</h3>
|
||||
<dl>
|
||||
<EditableRow label="Desired length (ft)">
|
||||
<InlineEditableField
|
||||
value={interest.desiredLengthFt ?? null}
|
||||
onSave={save('desiredLengthFt')}
|
||||
placeholder="e.g. 60"
|
||||
emptyText=" - "
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Desired width (ft)">
|
||||
<InlineEditableField
|
||||
value={interest.desiredWidthFt ?? null}
|
||||
onSave={save('desiredWidthFt')}
|
||||
placeholder="e.g. 25"
|
||||
emptyText=" - "
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Desired draft (ft)">
|
||||
<InlineEditableField
|
||||
value={interest.desiredDraftFt ?? null}
|
||||
onSave={save('desiredDraftFt')}
|
||||
placeholder="e.g. 6"
|
||||
emptyText=" - "
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
{(() => {
|
||||
// Honour the interest's `desiredLengthUnit` so a deal whose rep
|
||||
// entered metric values doesn't render labelled "(ft)" with
|
||||
// empty inputs. On save we patch BOTH the chosen-unit column
|
||||
// and the canonical counterpart so downstream surfaces
|
||||
// (recommender, EOI merge fields) stay in lockstep.
|
||||
const unitIsM = interest.desiredLengthUnit === 'm';
|
||||
const FT_PER_M = 3.28084;
|
||||
const toCounterpart = (v: string | null): string | null => {
|
||||
if (!v) return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4);
|
||||
};
|
||||
const onSavePair =
|
||||
(
|
||||
primary: InterestPatchField,
|
||||
counterpart: InterestPatchField,
|
||||
): ((next: string | null) => Promise<void>) =>
|
||||
async (next: string | null) => {
|
||||
await mutation.mutateAsync({
|
||||
[primary]: next,
|
||||
[counterpart]: toCounterpart(next),
|
||||
});
|
||||
};
|
||||
const unitLabel = unitIsM ? 'm' : 'ft';
|
||||
return (
|
||||
<dl>
|
||||
<EditableRow label={`Desired length (${unitLabel})`}>
|
||||
<InlineEditableField
|
||||
value={
|
||||
unitIsM
|
||||
? (interest.desiredLengthM ?? null)
|
||||
: (interest.desiredLengthFt ?? null)
|
||||
}
|
||||
onSave={onSavePair(
|
||||
unitIsM ? 'desiredLengthM' : 'desiredLengthFt',
|
||||
unitIsM ? 'desiredLengthFt' : 'desiredLengthM',
|
||||
)}
|
||||
placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'}
|
||||
emptyText=" - "
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label={`Desired width (${unitLabel})`}>
|
||||
<InlineEditableField
|
||||
value={
|
||||
unitIsM ? (interest.desiredWidthM ?? null) : (interest.desiredWidthFt ?? null)
|
||||
}
|
||||
onSave={onSavePair(
|
||||
unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
|
||||
unitIsM ? 'desiredWidthFt' : 'desiredWidthM',
|
||||
)}
|
||||
placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'}
|
||||
emptyText=" - "
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label={`Desired draft (${unitLabel})`}>
|
||||
<InlineEditableField
|
||||
value={
|
||||
unitIsM ? (interest.desiredDraftM ?? null) : (interest.desiredDraftFt ?? null)
|
||||
}
|
||||
onSave={onSavePair(
|
||||
unitIsM ? 'desiredDraftM' : 'desiredDraftFt',
|
||||
unitIsM ? 'desiredDraftFt' : 'desiredDraftM',
|
||||
)}
|
||||
placeholder={unitIsM ? 'e.g. 2' : 'e.g. 6'}
|
||||
emptyText=" - "
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Legacy `interest.reminderEnabled` / `reminderDays` / `reminderLastFired`
|
||||
|
||||
Reference in New Issue
Block a user