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:
2026-05-21 22:22:30 +02:00
parent a0a4a5d487
commit 991e2223c7
6 changed files with 575 additions and 36 deletions

View File

@@ -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`