feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones

Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:50:58 +02:00
parent 638000bb58
commit 3ffee79f3f
132 changed files with 5784 additions and 997 deletions

View File

@@ -2,6 +2,10 @@ import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/list-query';
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
// react-hook-form posts empty strings for unfilled inputs; treat those
// as "not provided" before `.email()` / `.date()` validators fire.
const emptyToUndef = (v: unknown) => (v === '' ? undefined : v);
export const createCompanySchema = z.object({
name: z.string().min(1).max(200),
legalName: z.string().optional(),
@@ -11,9 +15,9 @@ export const createCompanySchema = z.object({
incorporationCountryIso: optionalCountryIsoSchema.optional(),
/** ISO 3166-2 state/province of incorporation. */
incorporationSubdivisionIso: optionalSubdivisionIsoSchema.optional(),
incorporationDate: z.coerce.date().optional(),
incorporationDate: z.preprocess(emptyToUndef, z.coerce.date().optional()),
status: z.enum(['active', 'dissolved']).optional().default('active'),
billingEmail: z.string().email().optional(),
billingEmail: z.preprocess(emptyToUndef, z.string().email().optional()),
notes: z.string().optional(),
tagIds: z.array(z.string()).optional().default([]),
});

View File

@@ -59,6 +59,14 @@ export const changeStageSchema = z.object({
* to hold the `interests.override_stage` permission. Reason becomes
* required when override=true (recorded in the audit log). */
override: z.boolean().optional(),
/** Optional ISO date (YYYY-MM-DD or full ISO timestamp) to stamp on the
* matching milestone column instead of "now". Used when a rep marks a
* milestone manually (e.g. deposit received yesterday) so the recorded
* date reflects the real event instead of the click time. */
milestoneDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}(T.*)?$/)
.optional(),
});
// ─── Outcome (Won / Lost) ─────────────────────────────────────────────────────
@@ -68,6 +76,7 @@ export const INTEREST_OUTCOMES = [
'lost_other_marina',
'lost_unqualified',
'lost_no_response',
'lost_other',
'cancelled',
] as const;

View File

@@ -15,6 +15,13 @@ export const updateUserPreferencesSchema = z.object({
locale: z.string().optional(),
timezone: z.string().optional(),
reminders: reminderPreferencesSchema.optional(),
/**
* Widget id → visible flag. Persists which dashboard cards the user
* wants to see; missing ids fall back to registry defaults. Kept loose
* (record-of-bool) so adding a new widget doesn't require a validator
* update.
*/
dashboardWidgets: z.record(z.string(), z.boolean()).optional(),
});
export type UpdateUserPreferencesInput = z.infer<typeof updateUserPreferencesSchema>;

View File

@@ -6,6 +6,15 @@ export const ownerRefSchema = z.object({
id: z.string().min(1),
});
// Numeric columns on the yachts table accept a stringified decimal or
// null. The form posts empty strings for unfilled fields, which Postgres
// rejects with `invalid input syntax for type numeric: ""`. Strip empty
// strings here so the service can confidently `?? null` them.
const optionalNumericString = z
.string()
.optional()
.transform((v) => (v === '' || v === undefined ? undefined : v));
export const createYachtSchema = z.object({
name: z.string().min(1).max(200),
hullNumber: z.string().optional(),
@@ -15,12 +24,12 @@ export const createYachtSchema = z.object({
builder: z.string().optional(),
model: z.string().optional(),
hullMaterial: z.string().optional(),
lengthFt: z.string().optional(),
widthFt: z.string().optional(),
draftFt: z.string().optional(),
lengthM: z.string().optional(),
widthM: z.string().optional(),
draftM: z.string().optional(),
lengthFt: optionalNumericString,
widthFt: optionalNumericString,
draftFt: optionalNumericString,
lengthM: optionalNumericString,
widthM: optionalNumericString,
draftM: optionalNumericString,
owner: ownerRefSchema, // required; yacht must have an owner
status: z.enum(['active', 'retired', 'sold_away']).optional().default('active'),
notes: z.string().optional(),