Files
pn-new-crm/src/lib/validators/berths.ts
Matt b93fdadb59 feat(berths): link prospect on status change + reason chips from vocabulary
When status moves to under_offer or sold, the dialog now surfaces an
interest selector below the reason textarea. Picking an interest
passes interestId on the PATCH, which the service uses to call
setPrimaryBerth — auto-creates a primary interest_berths row if not
present, demoting any prior primary in the same transaction so the
unique partial index never fires. Cross-port leakage is blocked inside
the existing interest-berths helper.

Reasons are now offered as quick-pick chips above the textarea,
sourced from the new berth_status_change_reasons vocabulary
(Wave 5). Clicking a chip fills the textarea so reps stay on the
keyboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:37:04 +02:00

130 lines
5.4 KiB
TypeScript

import { z } from 'zod';
import { BERTH_STATUSES } from '@/lib/constants';
import { baseListQuerySchema } from '@/lib/api/list-query';
// ─── Create Berth ────────────────────────────────────────────────────────────
export const createBerthSchema = z.object({
mooringNumber: z.string().min(1),
area: z.string().min(1),
lengthFt: z.coerce.number().optional(),
lengthM: z.coerce.number().optional(),
widthFt: z.coerce.number().optional(),
widthM: z.coerce.number().optional(),
draftFt: z.coerce.number().optional(),
draftM: z.coerce.number().optional(),
price: z.coerce.number().optional(),
priceCurrency: z.string().optional(),
status: z.enum(BERTH_STATUSES).default('available'),
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
mooringType: z.string().optional(),
powerCapacity: z.coerce.number().optional(), // kW
voltage: z.coerce.number().optional(), // V at 60Hz
access: z.string().optional(),
bowFacing: z.string().optional(),
sidePontoon: z.string().optional(),
});
export type CreateBerthInput = z.infer<typeof createBerthSchema>;
// ─── Update Berth ─────────────────────────────────────────────────────────────
export const updateBerthSchema = z.object({
area: z.string().optional(),
lengthFt: z.coerce.number().optional(),
lengthM: z.coerce.number().optional(),
widthFt: z.coerce.number().optional(),
widthM: z.coerce.number().optional(),
draftFt: z.coerce.number().optional(),
draftM: z.coerce.number().optional(),
widthIsMinimum: z.boolean().optional(),
nominalBoatSize: z.coerce.number().optional(), // ft
nominalBoatSizeM: z.coerce.number().optional(), // m
waterDepth: z.coerce.number().optional(),
waterDepthM: z.coerce.number().optional(),
waterDepthIsMinimum: z.boolean().optional(),
sidePontoon: z.string().optional(),
powerCapacity: z.coerce.number().optional(), // kW
voltage: z.coerce.number().optional(), // V at 60Hz
mooringType: z.string().optional(),
cleatType: z.string().optional(),
cleatCapacity: z.string().optional(),
bollardType: z.string().optional(),
bollardCapacity: z.string().optional(),
access: z.string().optional(),
price: z.coerce.number().optional(),
priceCurrency: z.string().optional(),
bowFacing: z.string().optional(),
berthApproved: z.boolean().optional(),
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
tenureYears: z.coerce.number().int().optional(),
tenureStartDate: z.string().optional(),
tenureEndDate: z.string().optional(),
});
export type UpdateBerthInput = z.infer<typeof updateBerthSchema>;
// ─── Update Berth Status ──────────────────────────────────────────────────────
export const updateBerthStatusSchema = z.object({
status: z.enum(BERTH_STATUSES),
reason: z.string().min(1, 'Reason is required'),
/**
* Optional: when status moves to under_offer or sold, the rep can pin
* the interest that triggered the change. We auto-create a primary
* interest_berths link for the chosen interest so the timeline +
* heat scorer attribute the deal correctly.
*/
interestId: z.string().min(1).optional(),
});
export type UpdateBerthStatusInput = z.infer<typeof updateBerthStatusSchema>;
// ─── List Berths ──────────────────────────────────────────────────────────────
export const listBerthsSchema = baseListQuerySchema.extend({
status: z.enum(BERTH_STATUSES).optional(),
area: z.string().optional(),
minLength: z.coerce.number().optional(),
maxLength: z.coerce.number().optional(),
minPrice: z.coerce.number().optional(),
maxPrice: z.coerce.number().optional(),
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
tagIds: z
.string()
.optional()
.transform((v) => (v ? v.split(',') : undefined)),
});
export type ListBerthsQuery = z.infer<typeof listBerthsSchema>;
// ─── Add Maintenance Log ──────────────────────────────────────────────────────
export const addMaintenanceLogSchema = z.object({
category: z.enum(['routine', 'repair', 'inspection', 'upgrade']),
description: z.string().min(1),
cost: z.coerce.number().optional(),
costCurrency: z.string().optional(),
responsibleParty: z.string().optional(),
performedDate: z.string().min(1, 'Performed date is required'),
photoFileIds: z.array(z.string()).optional(),
});
export type AddMaintenanceLogInput = z.infer<typeof addMaintenanceLogSchema>;
// ─── Update Waiting List ──────────────────────────────────────────────────────
export const updateWaitingListSchema = z.object({
entries: z.array(
z.object({
clientId: z.string(),
position: z.number().int().min(1),
priority: z.enum(['normal', 'high']).optional(),
notifyPref: z.enum(['email', 'in_app', 'both']).optional(),
notes: z.string().optional(),
}),
),
});
export type UpdateWaitingListInput = z.infer<typeof updateWaitingListSchema>;