Files
pn-new-crm/src/lib/db/schema/berths.ts
Matt 04a594963f feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units
Berth surfaces
- New compact mooring-chip header (colored plate + status pill, dock-label
  in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack
- Berth list gains a "Latest deal stage" column showing the most-advanced
  pipeline stage of any active linked interest (server-aggregated, ranks by
  PIPELINE_STAGES index)
- "Linked prospect" Select on the status-change dialog rebuilt as a Command
  combobox: search, recent-first sort, stage-coloured pills

Pipeline UX
- Reverting an interest to Open with linked berths now prompts: keep the
  links, unlink and reset, or cancel. Silent when no berths are linked
- Activity feed + entity-activity feed normalise enum field values via
  STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as
  "10% Deposit → Contract Sent"

EOI generate dialog
- Inline-editable rows for client name, nationality (country combobox), and
  yacht name — pencil affordance saves directly via clients/yachts PATCH
- Replaces the single "Edit on client's page" link with two contextual links
  framed by short copy explaining what's inline vs what needs the canonical
  page
- Backend EoiContext now includes client.id + yacht.id so the dialog can
  PATCH without an extra round-trip

Company form
- New "Connections" section lets the rep attach members (clients) and yachts
  during create. Yacht attach uses the existing transfer endpoint so audit
  log + ownership history capture the change
- Inline "+ New client" / "+ New yacht" buttons open the canonical forms
  stacked over the company sheet
- After save, the form chains to a yacht pull-in prompt (if any attached
  client owns yachts not yet linked) and an optional "Create interest" step
  pre-filled with the first attached client

Admin
- /admin landing gains a searchable index — typed query flattens groups into
  a result list matching label + description + group title
- "Documenso & EOI" card relabelled to "EOI signing service" (consistent
  with the user-facing language rename from round 1)

Measurement units (migration 0053)
- interests gains desired_*_m columns + desired_*_unit discriminators so
  the rep's literal entry (ft OR m) is preserved verbatim instead of being
  reconstructed from a single canonical column on every render
- yachts + berths gain matching *_unit columns alongside their existing
  ft + m pairs; defaults to 'ft' so legacy rows still render normally
- Interest form POST/PATCH now sends both ft + m + unit; computed m is
  derived from the ft canonical to keep the recommender SQL unchanged

Misc
- Active-deals tile + topbar type their Link href as `Route` instead of `any`
- Unused REPORT_TYPE_LABELS const dropped from generate-report-form
- Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated
  to include the new id + unit fields on the EoiContext / Berth shapes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00

273 lines
12 KiB
TypeScript

import {
pgTable,
text,
boolean,
integer,
numeric,
timestamp,
date,
jsonb,
index,
uniqueIndex,
primaryKey,
} from 'drizzle-orm/pg-core';
import { ports } from './ports';
import { clients } from './clients';
import { yachts } from './yachts';
import { interests } from './interests';
export const berths = pgTable(
'berths',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
mooringNumber: text('mooring_number').notNull(),
area: text('area'),
status: text('status').notNull().default('available'), // available, under_offer, sold
lengthFt: numeric('length_ft'),
widthFt: numeric('width_ft'),
draftFt: numeric('draft_ft'),
lengthM: numeric('length_m'),
widthM: numeric('width_m'),
draftM: numeric('draft_m'),
widthIsMinimum: boolean('width_is_minimum').default(false),
// Numeric: ft (legacy NocoDB stored as plain numbers, no units in value).
nominalBoatSize: numeric('nominal_boat_size'),
nominalBoatSizeM: numeric('nominal_boat_size_m'),
waterDepth: numeric('water_depth'),
waterDepthM: numeric('water_depth_m'),
/** Entry-unit discriminators — see interests.desiredLengthUnit comment. */
lengthUnit: text('length_unit').notNull().default('ft'),
widthUnit: text('width_unit').notNull().default('ft'),
draftUnit: text('draft_unit').notNull().default('ft'),
nominalBoatSizeUnit: text('nominal_boat_size_unit').notNull().default('ft'),
waterDepthUnit: text('water_depth_unit').notNull().default('ft'),
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
sidePontoon: text('side_pontoon'),
powerCapacity: numeric('power_capacity'), // kW
voltage: numeric('voltage'), // V at 60Hz
mooringType: text('mooring_type'),
cleatType: text('cleat_type'),
cleatCapacity: text('cleat_capacity'),
bollardType: text('bollard_type'),
bollardCapacity: text('bollard_capacity'),
access: text('access'),
price: numeric('price'),
priceCurrency: text('price_currency').notNull().default('USD'),
// Lease/rental rates surfaced by the per-berth PDFs (Phase 6b). Null
// until reps upload PDFs; rendered on the berth detail page with a
// "Pricing data may be stale" chip when pricing_valid_until < today().
weeklyRateHighUsd: numeric('weekly_rate_high_usd'),
weeklyRateLowUsd: numeric('weekly_rate_low_usd'),
dailyRateHighUsd: numeric('daily_rate_high_usd'),
dailyRateLowUsd: numeric('daily_rate_low_usd'),
pricingValidUntil: date('pricing_valid_until'),
bowFacing: text('bow_facing'),
berthApproved: boolean('berth_approved').default(false),
// permanent, fixed_term, fee_simple, strata_lot (the last two map to
// the Fee Simple / Strata Lot tenures shown in the per-berth PDFs).
tenureType: text('tenure_type').notNull().default('permanent'),
tenureYears: integer('tenure_years'),
tenureStartDate: date('tenure_start_date'),
tenureEndDate: date('tenure_end_date'),
statusLastChangedBy: text('status_last_changed_by'), // user ID
statusLastChangedReason: text('status_last_changed_reason'),
statusLastModified: timestamp('status_last_modified', { withTimezone: true }),
// Optional override flag carried over from NocoDB ("auto" or null in legacy data).
// Reserved for future "manual override" semantics; not surfaced in the UI today.
statusOverrideMode: text('status_override_mode'),
// Set by scripts/import-berths-from-nocodb.ts. The import compares this
// against updated_at to detect human edits made after the last import,
// so re-running the import doesn't clobber CRM-side overrides.
lastImportedAt: timestamp('last_imported_at', { withTimezone: true }),
// Pointer to the active per-berth PDF version (Phase 6b). Null until a
// rep uploads the first PDF; a later rollback can re-target this column
// to any prior `berth_pdf_versions.id`. The full history lives in the
// junction table — this column is just the "current" pointer.
currentPdfVersionId: text('current_pdf_version_id'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_berths_port').on(table.portId),
index('idx_berths_status').on(table.portId, table.status),
index('idx_berths_area').on(table.portId, table.area),
uniqueIndex('idx_berths_mooring').on(table.portId, table.mooringNumber),
],
);
// Note: `berths.current_pdf_version_id` has an `ON DELETE SET NULL` FK to
// `berth_pdf_versions.id` installed by migration 0030. The column is left
// without a `.references()` / `foreignKey()` declaration in the Drizzle
// schema because the two tables form a circular FK (berth_pdf_versions →
// berths), and Drizzle's relation inference doesn't tolerate the cycle
// when both sides are declared via column-level `.references()`. The
// migration chain authoritatively maintains the constraint; a fresh
// `db:push` against an empty DB would skip the FK and require a follow-up
// generated migration to add it back. This is acceptable because we
// always apply migrations in order in dev/CI/prod.
export const berthMapData = pgTable(
'berth_map_data',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
berthId: text('berth_id')
.notNull()
.unique()
.references(() => berths.id, { onDelete: 'cascade' }),
svgPath: text('svg_path'),
x: numeric('x'),
y: numeric('y'),
transform: text('transform'),
fontSize: numeric('font_size'),
extraData: jsonb('extra_data').default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('berth_map_data_berth_id_idx').on(table.berthId)],
);
export const berthRecommendations = pgTable(
'berth_recommendations',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
interestId: text('interest_id')
.notNull()
.references(() => interests.id, { onDelete: 'cascade' }),
berthId: text('berth_id')
.notNull()
.references(() => berths.id, { onDelete: 'cascade' }),
matchScore: numeric('match_score'), // 0-100
matchReasons: jsonb('match_reasons'), // { "dimensional_fit": 95, "power_match": 80, ... }
source: text('source').notNull().default('ai'), // ai, manual
createdBy: text('created_by'), // user ID for manual recommendations
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('berth_rec_interest_berth_idx').on(table.interestId, table.berthId),
index('idx_br_interest').on(table.interestId),
],
);
export const berthWaitingList = pgTable(
'berth_waiting_list',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
berthId: text('berth_id')
.notNull()
.references(() => berths.id, { onDelete: 'cascade' }),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
position: integer('position').notNull(),
priority: text('priority').notNull().default('normal'), // normal, high
notifyPref: text('notify_pref').default('email'), // email, in_app, both
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('berth_waiting_list_berth_client_idx').on(table.berthId, table.clientId),
index('idx_bwl_berth').on(table.berthId, table.position),
],
);
export const berthMaintenanceLog = pgTable(
'berth_maintenance_log',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
berthId: text('berth_id')
.notNull()
.references(() => berths.id, { onDelete: 'cascade' }),
portId: text('port_id')
.notNull()
.references(() => ports.id),
category: text('category').notNull(), // routine, repair, inspection, upgrade
description: text('description').notNull(),
cost: numeric('cost'),
costCurrency: text('cost_currency').default('USD'),
responsibleParty: text('responsible_party'),
performedDate: date('performed_date').notNull(),
photoFileIds: text('photo_file_ids').array(), // references to files table
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_bml_berth').on(table.berthId), index('idx_bml_port').on(table.portId)],
);
/**
* Per-berth PDF version history (Phase 6b — see plan §3.3 / §4.7b).
*
* Each upload creates a new row with a monotonic `versionNumber` per berth.
* The active version is referenced by `berths.current_pdf_version_id`. The
* storage_key points at the file in the active `StorageBackend` (s3/filesystem),
* which is resolved at access time via `getStorageBackend()`.
*
* `parseResults` captures what the 3-tier reverse parser extracted at upload
* time plus any conflicts the rep resolved in the diff dialog. Kept as audit
* trail; rolling back to a prior version does NOT replay these (per §14.6).
*/
export const berthPdfVersions = pgTable(
'berth_pdf_versions',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
berthId: text('berth_id')
.notNull()
.references(() => berths.id, { onDelete: 'cascade' }),
versionNumber: integer('version_number').notNull(),
/** Object key in the active storage backend (renamed from `s3_key` per §4.7a). */
storageKey: text('storage_key').notNull(),
fileName: text('file_name').notNull(),
fileSizeBytes: integer('file_size_bytes').notNull(),
contentSha256: text('content_sha256').notNull(),
uploadedBy: text('uploaded_by').notNull(),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(),
/** Cached signed-URL expiry per §11.1 — re-sign only when within 1h of expiry. */
downloadUrlExpiresAt: timestamp('download_url_expires_at', { withTimezone: true }),
/** { engine: 'acroform'|'ocr'|'ai', extracted: {...}, conflicts: [...], appliedFields: [...] } */
parseResults: jsonb('parse_results'),
},
(table) => [
uniqueIndex('berth_pdf_versions_berth_version_idx').on(table.berthId, table.versionNumber),
index('idx_bpv_berth').on(table.berthId, table.uploadedAt),
],
);
export const berthTags = pgTable(
'berth_tags',
{
berthId: text('berth_id')
.notNull()
.references(() => berths.id, { onDelete: 'cascade' }),
tagId: text('tag_id').notNull(), // references tags.id
},
(table) => [primaryKey({ columns: [table.berthId, table.tagId] })],
);
export type Berth = typeof berths.$inferSelect;
export type NewBerth = typeof berths.$inferInsert;
export type BerthMapData = typeof berthMapData.$inferSelect;
export type NewBerthMapData = typeof berthMapData.$inferInsert;
export type BerthRecommendation = typeof berthRecommendations.$inferSelect;
export type NewBerthRecommendation = typeof berthRecommendations.$inferInsert;
export type BerthWaitingList = typeof berthWaitingList.$inferSelect;
export type NewBerthWaitingList = typeof berthWaitingList.$inferInsert;
export type BerthMaintenanceLog = typeof berthMaintenanceLog.$inferSelect;
export type NewBerthMaintenanceLog = typeof berthMaintenanceLog.$inferInsert;
export type BerthPdfVersion = typeof berthPdfVersions.$inferSelect;
export type NewBerthPdfVersion = typeof berthPdfVersions.$inferInsert;