Files
pn-new-crm/src/lib/validators/residential.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

142 lines
6.4 KiB
TypeScript

import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/list-query';
import {
optionalCountryIsoSchema,
optionalIanaTimezoneSchema,
optionalPhoneE164Schema,
optionalSubdivisionIsoSchema,
} from '@/lib/validators/i18n';
// ─── Residential client ──────────────────────────────────────────────────────
export const createResidentialClientSchema = z.object({
fullName: z.string().min(1).max(200),
email: z
.string()
.email()
.optional()
.or(z.literal('').transform(() => undefined)),
phone: z.string().optional(),
/** E.164-normalized phone alongside the legacy free-text `phone`. */
phoneE164: optionalPhoneE164Schema.optional(),
/** ISO-3166-1 alpha-2 the phone was parsed against. */
phoneCountry: optionalCountryIsoSchema.optional(),
/** ISO-3166-1 alpha-2 nationality. */
nationalityIso: optionalCountryIsoSchema.optional(),
/** IANA timezone. */
timezone: optionalIanaTimezoneSchema.optional(),
placeOfResidence: z.string().optional(),
/** ISO-3166-1 alpha-2 country of residence. */
placeOfResidenceCountryIso: optionalCountryIsoSchema.optional(),
/** ISO 3166-2 subdivision code for place of residence. */
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
preferredContactMethod: z.enum(['email', 'phone']).optional(),
status: z.enum(['prospect', 'active', 'inactive']).optional().default('prospect'),
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
notes: z.string().optional(),
});
export const updateResidentialClientSchema = createResidentialClientSchema.partial();
export const listResidentialClientsSchema = baseListQuerySchema.extend({
status: z.enum(['prospect', 'active', 'inactive']).optional(),
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
});
// ─── Residential interest ────────────────────────────────────────────────────
/**
* Default pipeline stages - used as the fallback when a port hasn't
* configured its own list via the residential admin page. Mirror the
* legacy hard-coded set so existing data continues to validate.
*
* Per-port stages are stored in `system_settings.residential_pipeline_stages`
* (JSON array of stage ids). The validators below accept any string and
* defer the membership check to a runtime helper that reads the live
* stage list. This lets admins add/rename stages without a deploy.
*/
export const DEFAULT_RESIDENTIAL_PIPELINE_STAGES = [
'new',
'contacted',
'viewing_scheduled',
'offer_made',
'offer_accepted',
'closed_won',
'closed_lost',
] as const;
/** Backwards-compat alias kept for any existing imports. */
export const PIPELINE_STAGES = DEFAULT_RESIDENTIAL_PIPELINE_STAGES;
export const createResidentialInterestSchema = z.object({
residentialClientId: z.string().min(1),
pipelineStage: z.string().optional().default('new'),
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
notes: z.string().optional(),
preferences: z.string().optional(),
assignedTo: z.string().optional(),
});
export const updateResidentialInterestSchema = createResidentialInterestSchema
.omit({ residentialClientId: true })
.partial();
export const listResidentialInterestsSchema = baseListQuerySchema.extend({
// pipelineStage accepts a string OR string[] so the FilterBar can filter
// multi-select. The legacy single-string form stays accepted so existing
// callers (e.g. residential-client-tabs) don't need to migrate.
pipelineStage: z.union([z.string(), z.array(z.string())]).optional(),
// Source filter - mirrors the main interest list. Comma-separated when
// submitted as a query string ("website,referral").
source: z.union([z.string(), z.array(z.string())]).optional(),
assignedTo: z.string().optional(),
residentialClientId: z.string().optional(),
});
// ─── Public website inquiry ──────────────────────────────────────────────────
/**
* Shape posted by the public website's residential interest form. Coerces
* to internal create-shapes inside the public route.
*
* The legacy `phone` field stays free-text - older website builds may post
* raw international strings ('+44 7700 900123'). The route handler parses
* it server-side into `phoneE164` + `phoneCountry`. Newer website builds
* can post normalized values directly.
*/
export const publicResidentialInquirySchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().min(1),
/** Pre-normalized E.164 form, optional for backwards compat. */
phoneE164: optionalPhoneE164Schema.optional(),
/** ISO-3166-1 alpha-2 the phone was parsed against. */
phoneCountry: optionalCountryIsoSchema.optional(),
/** ISO-3166-1 alpha-2 nationality. */
nationalityIso: optionalCountryIsoSchema.optional(),
/** IANA timezone. */
timezone: optionalIanaTimezoneSchema.optional(),
placeOfResidence: z.string().optional(),
/** ISO-3166-1 alpha-2 country of residence. */
placeOfResidenceCountryIso: optionalCountryIsoSchema.optional(),
/** ISO 3166-2 subdivision code for place of residence. */
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
preferredContactMethod: z.enum(['email', 'phone']).optional(),
notes: z.string().optional(),
preferences: z.string().optional(),
});
// ─── Types ────────────────────────────────────────────────────────────────────
export type CreateResidentialClientInput = z.infer<typeof createResidentialClientSchema>;
export type UpdateResidentialClientInput = z.infer<typeof updateResidentialClientSchema>;
export type ListResidentialClientsInput = z.infer<typeof listResidentialClientsSchema>;
export type CreateResidentialInterestInput = z.infer<typeof createResidentialInterestSchema>;
export type UpdateResidentialInterestInput = z.infer<typeof updateResidentialInterestSchema>;
export type ListResidentialInterestsInput = z.infer<typeof listResidentialInterestsSchema>;
export type PublicResidentialInquiryInput = z.infer<typeof publicResidentialInquirySchema>;