feat(interests): desired-dimension form fields + size-desired column
Surfaces the recommender inputs added in Phase 2a (interests .desired_length_ft / desired_width_ft / desired_draft_ft) on the two interfaces reps actually use: - /interests list: new "Berth size desired" column rendered as a compact "60×18×6 ft" string. Cells with no dimensions show "-"; partial dimensions render "?" for the missing axis (recommender treats null as "no constraint"). - New/Edit Interest form: three optional length/width/draft inputs with explanatory subhead. Empty submissions collapse to undefined so the API doesn't see "" -> numeric coercion errors. - createInterestSchema gains the three optional desired-dim fields with a shared transform that coerces strings/numbers to a positive 2-decimal numeric string for the postgres `numeric` column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,10 +37,34 @@ export interface InterestRow {
|
|||||||
dateDepositReceived?: string | null;
|
dateDepositReceived?: string | null;
|
||||||
eoiStatus?: string | null;
|
eoiStatus?: string | null;
|
||||||
outcome?: string | null;
|
outcome?: string | null;
|
||||||
|
/** Imperial; nullable. Recommender treats nulls as "no constraint" on
|
||||||
|
* that axis. Rendered as a compact "60×18×6 ft" string in the list. */
|
||||||
|
desiredLengthFt?: string | number | null;
|
||||||
|
desiredWidthFt?: string | number | null;
|
||||||
|
desiredDraftFt?: string | number | null;
|
||||||
notesCount?: number;
|
notesCount?: number;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDim(value: string | number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === '') return '?';
|
||||||
|
const n = typeof value === 'number' ? value : parseFloat(value);
|
||||||
|
if (!Number.isFinite(n)) return '?';
|
||||||
|
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDesiredSize(row: InterestRow): string | null {
|
||||||
|
const { desiredLengthFt, desiredWidthFt, desiredDraftFt } = row;
|
||||||
|
if (
|
||||||
|
(desiredLengthFt === null || desiredLengthFt === undefined || desiredLengthFt === '') &&
|
||||||
|
(desiredWidthFt === null || desiredWidthFt === undefined || desiredWidthFt === '') &&
|
||||||
|
(desiredDraftFt === null || desiredDraftFt === undefined || desiredDraftFt === '')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${formatDim(desiredLengthFt)}×${formatDim(desiredWidthFt)}×${formatDim(desiredDraftFt)} ft`;
|
||||||
|
}
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
website: 'Website',
|
website: 'Website',
|
||||||
manual: 'Manual',
|
manual: 'Manual',
|
||||||
@@ -134,6 +158,16 @@ export function getInterestColumns({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'desiredSize',
|
||||||
|
header: 'Berth size desired',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const label = formatDesiredSize(row.original);
|
||||||
|
if (!label) return <span className="text-muted-foreground">-</span>;
|
||||||
|
return <span className="text-sm tabular-nums">{label}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'pipelineStage',
|
id: 'pipelineStage',
|
||||||
accessorKey: 'pipelineStage',
|
accessorKey: 'pipelineStage',
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ interface InterestFormProps {
|
|||||||
reminderEnabled?: boolean;
|
reminderEnabled?: boolean;
|
||||||
reminderDays?: number | null;
|
reminderDays?: number | null;
|
||||||
tags?: Array<{ id: string }>;
|
tags?: Array<{ id: string }>;
|
||||||
|
desiredLengthFt?: string | number | null;
|
||||||
|
desiredWidthFt?: string | number | null;
|
||||||
|
desiredDraftFt?: string | number | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +134,18 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
|||||||
reminderEnabled: interest.reminderEnabled ?? false,
|
reminderEnabled: interest.reminderEnabled ?? false,
|
||||||
reminderDays: interest.reminderDays ?? undefined,
|
reminderDays: interest.reminderDays ?? undefined,
|
||||||
tagIds: interest.tags?.map((t) => t.id) ?? [],
|
tagIds: interest.tags?.map((t) => t.id) ?? [],
|
||||||
|
desiredLengthFt:
|
||||||
|
interest.desiredLengthFt === null || interest.desiredLengthFt === undefined
|
||||||
|
? undefined
|
||||||
|
: String(interest.desiredLengthFt),
|
||||||
|
desiredWidthFt:
|
||||||
|
interest.desiredWidthFt === null || interest.desiredWidthFt === undefined
|
||||||
|
? undefined
|
||||||
|
: String(interest.desiredWidthFt),
|
||||||
|
desiredDraftFt:
|
||||||
|
interest.desiredDraftFt === null || interest.desiredDraftFt === undefined
|
||||||
|
? undefined
|
||||||
|
: String(interest.desiredDraftFt),
|
||||||
});
|
});
|
||||||
} else if (!interest && open) {
|
} else if (!interest && open) {
|
||||||
reset({
|
reset({
|
||||||
@@ -394,6 +409,54 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* Desired berth dimensions (recommender inputs) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Berth size desired
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Imperial. Optional - the recommender treats blank fields as no constraint on that
|
||||||
|
axis.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="desiredLengthFt">Length (ft)</Label>
|
||||||
|
<Input
|
||||||
|
id="desiredLengthFt"
|
||||||
|
{...register('desiredLengthFt')}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={0}
|
||||||
|
placeholder="e.g. 60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="desiredWidthFt">Width (ft)</Label>
|
||||||
|
<Input
|
||||||
|
id="desiredWidthFt"
|
||||||
|
{...register('desiredWidthFt')}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={0}
|
||||||
|
placeholder="e.g. 18"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="desiredDraftFt">Draft (ft)</Label>
|
||||||
|
<Input
|
||||||
|
id="desiredDraftFt"
|
||||||
|
{...register('desiredDraftFt')}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={0}
|
||||||
|
placeholder="e.g. 6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Notes</Label>
|
<Label>Notes</Label>
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ import {
|
|||||||
|
|
||||||
// ─── Create ──────────────────────────────────────────────────────────────────
|
// ─── Create ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desired-dimension input. Strings/numbers are coerced to a positive
|
||||||
|
* decimal (string-typed for postgres `numeric` column compatibility);
|
||||||
|
* empty strings collapse to `undefined` so a blank form field doesn't
|
||||||
|
* round-trip "" → numeric error on the API.
|
||||||
|
*/
|
||||||
|
const optionalDesiredDimSchema = z
|
||||||
|
.union([z.string(), z.number()])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => {
|
||||||
|
if (v === undefined || v === null || v === '') return undefined;
|
||||||
|
const n = typeof v === 'number' ? v : parseFloat(v);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return undefined;
|
||||||
|
return String(Math.round(n * 100) / 100);
|
||||||
|
});
|
||||||
|
|
||||||
export const createInterestSchema = z.object({
|
export const createInterestSchema = z.object({
|
||||||
clientId: z.string().min(1),
|
clientId: z.string().min(1),
|
||||||
yachtId: z.string().optional(),
|
yachtId: z.string().optional(),
|
||||||
@@ -21,6 +37,9 @@ export const createInterestSchema = z.object({
|
|||||||
tagIds: z.array(z.string()).optional().default([]),
|
tagIds: z.array(z.string()).optional().default([]),
|
||||||
reminderEnabled: z.boolean().optional().default(false),
|
reminderEnabled: z.boolean().optional().default(false),
|
||||||
reminderDays: z.number().int().min(1).optional(),
|
reminderDays: z.number().int().min(1).optional(),
|
||||||
|
desiredLengthFt: optionalDesiredDimSchema,
|
||||||
|
desiredWidthFt: optionalDesiredDimSchema,
|
||||||
|
desiredDraftFt: optionalDesiredDimSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Update ──────────────────────────────────────────────────────────────────
|
// ─── Update ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user