@inspectorhub/field-ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1519 @@
1
+ import * as Y from 'yjs';
2
+
3
+ /**
4
+ * Date/time SHAPE resolution — see #270. Sibling to server/lib/locale.ts and
5
+ * server/lib/tz.ts, and the third of the four independent display preferences
6
+ * (language, timezone, currency, shape).
7
+ *
8
+ * Shape is its own axis because no locale expresses "English words, American
9
+ * order, 24-hour clock", and that is a normal field preference: 14:30 is
10
+ * unambiguous over a radio where "2:30" is not.
11
+ *
12
+ * Resolution is per FIELD, not per object. A user who set only the clock keeps
13
+ * the tenant's date order; collapsing to a per-object choice silently reverts a
14
+ * preference the user did set.
15
+ *
16
+ * The drizzle `{ enum: [...] }` is type-layer only — D1 stores plain TEXT with
17
+ * no CHECK constraint — so an unrecognized stored value falls back rather than
18
+ * reaching `Intl` as an unknown key.
19
+ */
20
+ declare const DATE_FORMATS: readonly ["us", "iso", "eu"];
21
+ declare const TIME_FORMATS: readonly ["12h", "24h"];
22
+ type DateFormat = (typeof DATE_FORMATS)[number];
23
+ type TimeFormat = (typeof TIME_FORMATS)[number];
24
+
25
+ /** Item types — `rich` is the headline interactive type (rating + three
26
+ * canned-comment tabs). The 8 simpler types cover non-rated data points
27
+ * the editor surfaces (booleans, numbers with min/max/unit, single- and
28
+ * multi-select with choices, date pickers, photo-only fields, and plain
29
+ * text / textarea inputs). */
30
+ type ItemType = 'rich' | 'text' | 'boolean' | 'textarea' | 'number' | 'select' | 'multi_select' | 'date' | 'photo_only';
31
+
32
+ interface PublishBlockingDefect {
33
+ sectionId: string;
34
+ sectionTitle: string;
35
+ itemId: string;
36
+ itemLabel: string;
37
+ cannedId: string;
38
+ cannedTitle: string;
39
+ missing: Array<'location' | 'trade'>;
40
+ unresolvedTokens: string[];
41
+ }
42
+ interface PublishReadiness {
43
+ ready: boolean;
44
+ blockingDefects: PublishBlockingDefect[];
45
+ /** Track H (IA-7) — gaps below the tenant's required threshold: shown as
46
+ * yellow warnings, never block. Optional so stale server responses
47
+ * (pre-split) still type-check. */
48
+ warningDefects?: PublishBlockingDefect[];
49
+ }
50
+
51
+ /**
52
+ * Pure structural-edit snapshot operations for inspection template editing (D8).
53
+ *
54
+ * All functions are pure: they never mutate their inputs, always return a new
55
+ * Snapshot, and every mutator passes its result through stripRuntimeKeys so the
56
+ * output is persist-ready (matches the strict Zod template schema).
57
+ *
58
+ * NO React, NO DB imports — this module may run in any JS environment.
59
+ */
60
+
61
+ type Snapshot = {
62
+ schemaVersion: 2;
63
+ sections: Section[];
64
+ ratingSystem?: unknown;
65
+ [k: string]: unknown;
66
+ };
67
+ type Section = {
68
+ id: string;
69
+ title: string;
70
+ items: Item[];
71
+ [k: string]: unknown;
72
+ };
73
+ type Item = {
74
+ id: string;
75
+ label: string;
76
+ type: ItemType;
77
+ parentId?: string | null;
78
+ [k: string]: unknown;
79
+ };
80
+
81
+ interface PropertyMetaField {
82
+ id: string;
83
+ label: string;
84
+ type: 'text' | 'number' | 'select' | 'boolean' | 'date';
85
+ group?: string;
86
+ required?: boolean;
87
+ unit?: string;
88
+ options?: string[];
89
+ }
90
+
91
+ /**
92
+ * Commercial PCA Phase S — editable narrative blocks for the report.
93
+ *
94
+ * Stored as the `pca_narrative` JSON column on `inspections`. Each block is a
95
+ * free-prose region (textarea, NO RTE per the project notes=textarea rule)
96
+ * seeded with ASTM-appropriate default copy so a fresh report is presentable
97
+ * with zero editing. `userReliance` + `deviations` are STRUCTURED (Phase M /
98
+ * the deviations store), not free prose, so they are not keys here.
99
+ *
100
+ * Pre-launch: the first-cut 5-block shape is RESET, not migrated — the resolver
101
+ * just ignores unknown/old keys and falls back to seed.
102
+ */
103
+ interface PcaNarrative {
104
+ transmittalLetter: string;
105
+ summaryGeneralDescription: string;
106
+ summaryPhysicalCondition: string;
107
+ summaryRecommendations: string;
108
+ purpose: string;
109
+ scopeOfWork: string;
110
+ limitationsExceptions: string;
111
+ reconnaissance: string;
112
+ additionalConsiderations: string;
113
+ }
114
+
115
+ type AutoAdvanceMode = 'always' | 'keyboard' | 'off';
116
+
117
+ /**
118
+ * Media Studio — flatten the Media Center API ({attached, pool}) into one
119
+ * deduped, labeled photo list for the gallery + cover picker. Dedup by R2 key.
120
+ */
121
+ interface GalleryPhoto {
122
+ key: string;
123
+ url: string;
124
+ label: string;
125
+ itemId?: string | undefined;
126
+ photoIndex?: number | undefined;
127
+ annotated?: boolean | undefined;
128
+ originalKey?: string | undefined;
129
+ defectId?: string | undefined;
130
+ /** Plan 4 — the baked cropped derivative key, when this photo has one. */
131
+ croppedKey?: string;
132
+ /** Plan 7 — media discriminator. Absent ⇒ treated as 'photo'. */
133
+ mediaType?: "photo" | "video";
134
+ /** Plan 7 — video backend provider. 'stream' = Cloudflare Stream; 'r2' = R2 bucket. */
135
+ provider?: "stream" | "r2";
136
+ /** Plan 7 — Cloudflare Stream UID (Stream videos only). */
137
+ streamUid?: string;
138
+ /** Plan 7 — pool row id / media id (R2 videos: used to build r2-object URLs). */
139
+ mediaId?: string;
140
+ /** Plan 7 — poster timestamp as a fraction of duration (0..1). */
141
+ posterPct?: number;
142
+ /** Plan 7 — video duration in seconds (for the m:ss strip badge). */
143
+ durationSec?: number;
144
+ /**
145
+ * #181 PR-G — this photo (or its crop/annotate derivative) is still uploading
146
+ * from the local offline pending store. The strip/viewer shows an "uploading"
147
+ * affordance. `url` points at the LOCAL objectURL when this client owns the
148
+ * blob; when `pendingPlaceholder` is also set the blob lives on ANOTHER device
149
+ * and there is nothing local to render (show a placeholder, not a broken image).
150
+ */
151
+ pending?: boolean;
152
+ /** #181 PR-G — pending entry whose blob is on another device (no local URL). */
153
+ pendingPlaceholder?: boolean;
154
+ }
155
+
156
+ /**
157
+ * Single source of truth for the `inspection_results.data` JSON projection.
158
+ *
159
+ * A Yjs Durable Object will materialize this shape as its authoritative
160
+ * projection so that all existing readers (report service, PDF renderer, etc.)
161
+ * remain unchanged when the bespoke per-field-CAS path is retired.
162
+ *
163
+ * IMPORTANT — what is NOT here by design:
164
+ * The per-field CAS metadata (<field>_v / <field>_by / <field>_at,
165
+ * _lastWriter, _lastWriteAt) is intentionally excluded from this projection
166
+ * shape. Those fields are part of the bespoke conflict-detection layer that
167
+ * the Yjs CRDT migration retires (see #181). They live in the raw
168
+ * inspection_results.data blob today but must not leak into the stable
169
+ * Yjs-projected view.
170
+ */
171
+ /** Re-editable crop transform (source-pixel coords) baked into a cropped photo. */
172
+ interface PhotoCropTransform$1 {
173
+ aspect: string;
174
+ orientation: 'landscape' | 'portrait';
175
+ x: number;
176
+ y: number;
177
+ width: number;
178
+ height: number;
179
+ }
180
+ /** A photo / video attachment stored inside an inspection result. */
181
+ interface PhotoEntry {
182
+ key: string;
183
+ croppedKey?: string;
184
+ /** Re-editable crop transform recorded alongside `croppedKey`. */
185
+ crop?: PhotoCropTransform$1;
186
+ annotatedKey?: string;
187
+ annotationsJson?: string;
188
+ mediaType?: 'photo' | 'video';
189
+ provider?: 'stream' | 'r2';
190
+ streamUid?: string;
191
+ mediaId?: string;
192
+ posterKey?: string;
193
+ posterPct?: number;
194
+ durationSec?: number;
195
+ /** #181 PR-G — true while the binary is only in the local pending store (not yet on R2). */
196
+ pendingUpload?: boolean;
197
+ /** #181 PR-G — id into the local media-pending IndexedDB store; resolves to a local blob URL. */
198
+ pendingId?: string;
199
+ /**
200
+ * #181 PR-G — which offline operation produced this pending entry. `'photo'`
201
+ * is a brand-new offline photo (paired with `pendingUpload: true` + an empty
202
+ * `key`, so the report skips it). `'crop'` / `'annotate'` are offline
203
+ * derivatives of an EXISTING photo: the base `key` is KEPT (and serves the
204
+ * report as an honest fallback) and `pendingUpload` is NOT set, while the
205
+ * editor shows the local derivative preview until the upload drains.
206
+ */
207
+ pendingKind?: 'photo' | 'crop' | 'annotate';
208
+ }
209
+ /** Toggle-state for a single canned information or limitation comment. */
210
+ interface CannedState {
211
+ cannedId: string;
212
+ included: boolean;
213
+ comment?: string | null;
214
+ }
215
+ /**
216
+ * Snapshot of a repair item attached to a finding.
217
+ *
218
+ * Read by `server/lib/aggregate-recommendations.ts`
219
+ * (`aggregateAttachedRecommendations`, the `GET /:id/recommendations` list).
220
+ *
221
+ * `estimateSnapshotMin` / `estimateSnapshotMax` are LEGACY and OPTIONAL: they
222
+ * exist on findings recorded while repair items still carried a catalogue
223
+ * price. Nothing writes them any more — the editor's authoring type
224
+ * (`AttachedRepairItem`, packages/field-ui/src/shared/findings-shared.ts) has no such field, and a
225
+ * repair item snapshots scope, not a figure. They stay declared so a document
226
+ * written before the change still parses; do not reintroduce them as a write
227
+ * path (`scripts/check-price-capability.mjs` holds the line).
228
+ */
229
+ interface RepairItemSnapshot {
230
+ recommendationId: string;
231
+ /** @deprecated legacy read-only; never written. */
232
+ estimateSnapshotMin?: number | null;
233
+ /** @deprecated legacy read-only; never written. */
234
+ estimateSnapshotMax?: number | null;
235
+ summarySnapshot: string;
236
+ contractorTypeSnapshot: string | null;
237
+ attachedAt: number;
238
+ }
239
+ /**
240
+ * A per-inspection custom comment (inspector free-text, not from the library).
241
+ *
242
+ * Read by `server/services/inspection/inspection-report.service.ts`
243
+ * (`mapCustomDefectsForReport` reads `res.customComments.defects`).
244
+ */
245
+ interface CustomCommentEntry {
246
+ id: string;
247
+ title: string;
248
+ comment: string;
249
+ included: boolean;
250
+ category?: string;
251
+ location?: string;
252
+ /** One of `DEFECT_TRADES` (`packages/field-ui/src/shared/defect-fields.types.ts`), or null.
253
+ * Same field, same vocabulary and same sanitizer rule as `DefectState.trade`:
254
+ * a hand-written defect states WHO should fix it exactly as a canned one
255
+ * does, and the contractor-facing repair list reads both by this key. Typed
256
+ * `string` rather than `DefectTrade` because this projection describes what
257
+ * the JSON blob may hold, not what the write path accepts — the guard in
258
+ * `sanitizeDefectStates` is what narrows it. */
259
+ trade?: string | null;
260
+ photos?: PhotoEntry[];
261
+ }
262
+ /**
263
+ * Toggle-state for a single canned defect, with per-defect overrides.
264
+ *
265
+ * A defect states WHAT is wrong and WHO should look at it — category, trade,
266
+ * deadline, timeframe. It carries no repair price: `estimateLow` /
267
+ * `estimateHigh` were removed, `projectResults` drops them on the way to D1,
268
+ * and `sanitizeDefectStates` drops them on the way in. Nothing here is a figure
269
+ * (`scripts/check-price-capability.mjs` holds the line).
270
+ */
271
+ interface DefectState {
272
+ cannedId: string;
273
+ included: boolean;
274
+ comment?: string | null;
275
+ /** References a `defect_categories.id` (or a legacy seed name). Widened
276
+ * from the former 3-value enum — Authoring unification Plan-4 module K. */
277
+ category?: string;
278
+ location?: string | null;
279
+ photos?: PhotoEntry[];
280
+ recommendationId?: string | null;
281
+ trade?: string | null;
282
+ deadline?: string | null;
283
+ timeframe?: string | null;
284
+ }
285
+ /**
286
+ * Composite finding key for one result entry.
287
+ * Runtime format: `"_default:{sectionId}:{itemId}"` (via `findingKey()`).
288
+ */
289
+ type FindingKey = string;
290
+ /**
291
+ * Per-item result entry — the value stored at each `FindingKey` inside
292
+ * `inspection_results.data`.
293
+ *
294
+ * This is the shape the report service reads verbatim: rating, notes, value,
295
+ * attributes, tabs (information / limitations / defects), photos, original
296
+ * (re-inspection snapshot), followupStatus, followupNotes.
297
+ *
298
+ * NOTE: the per-field CAS metadata (<field>_v/_by/_at, _lastWriter,
299
+ * _lastWriteAt) is intentionally NOT part of this projection shape — it is
300
+ * being retired as part of the Yjs CRDT migration (#181).
301
+ */
302
+ interface ItemEntry {
303
+ rating?: string;
304
+ notes?: string;
305
+ photos?: PhotoEntry[];
306
+ /** WHAT should be done (a repair-item slug or label) — never what it costs.
307
+ * The former `estimateMin` / `estimateMax` pair is gone; see DefectState. */
308
+ recommendation?: string;
309
+ attributes?: Record<string, unknown>;
310
+ value?: unknown;
311
+ tabs?: {
312
+ information?: CannedState[];
313
+ limitations?: CannedState[];
314
+ defects?: DefectState[];
315
+ };
316
+ /** Re-inspection: snapshot of the original finding before the follow-up. */
317
+ original?: {
318
+ rating?: string | null;
319
+ notes?: string | null;
320
+ photos?: PhotoEntry[];
321
+ };
322
+ /** Re-inspection disposition assigned by the inspector. */
323
+ followupStatus?: string | null;
324
+ followupNotes?: string | null;
325
+ /** Repair items attached to this finding (read by aggregate-recommendations). */
326
+ recommendations?: RepairItemSnapshot[];
327
+ /** Per-inspection custom comments, grouped by tab. */
328
+ customComments?: {
329
+ information?: CustomCommentEntry[];
330
+ limitations?: CustomCommentEntry[];
331
+ defects?: CustomCommentEntry[];
332
+ };
333
+ }
334
+ /**
335
+ * The full `inspection_results.data` projection: a map of composite finding
336
+ * keys to per-item result entries.
337
+ */
338
+ type ResultsProjection = Record<FindingKey, ItemEntry>;
339
+
340
+ /** Data types of the host resources (cost lines, repair items, media pool, settings sheet, version history, video). */
341
+ /**
342
+ * The cost lines of one inspection, as the host's cost-items read answers them. `reserveEnabled` is the
343
+ * tenant's reserve-schedule switch (it shows the EUL / effective age / RUL fields); a host that cannot
344
+ * read it answers false, which hides them.
345
+ */
346
+ interface CostItemsListing {
347
+ items: CostItemView[];
348
+ reserveEnabled: boolean;
349
+ }
350
+ /**
351
+ * One write to the cost lines. `create` answers the stored line's id as `data.id` (the panel swaps its
352
+ * draft id for it); `update` replaces the stored line by id; `delete` removes it by id.
353
+ */
354
+ type CostItemWrite = {
355
+ intent: 'create';
356
+ item: CostItemView;
357
+ } | {
358
+ intent: 'update';
359
+ item: CostItemView;
360
+ } | {
361
+ intent: 'delete';
362
+ itemId: string;
363
+ };
364
+ /** One catalogue entry the repair-item picker offers. */
365
+ interface RepairItemOption {
366
+ id: string;
367
+ name: string;
368
+ category: string | null;
369
+ defaultRepairSummary: string;
370
+ contractorTypeName: string | null;
371
+ }
372
+ /**
373
+ * The photos of an inspection as the gallery lists them. `answered` is the difference between "this
374
+ * inspection has no photos" (answered, empty) and "no answer came back" (not answered): only the first
375
+ * may be shown as a statement about the inspection.
376
+ */
377
+ interface MediaPoolListing {
378
+ photos: GalleryPhoto[];
379
+ answered: boolean;
380
+ }
381
+ /** One photo the report-cover picker offers. */
382
+ interface CoverPhotoOption {
383
+ key: string;
384
+ url: string;
385
+ label: string;
386
+ }
387
+ /** One report appearance profile the settings sheet offers. Presentational: only its id and name. */
388
+ interface ReportProfileOption {
389
+ id: string;
390
+ name: string;
391
+ }
392
+ /**
393
+ * What the inspection settings sheet reads when it opens. `inspection` is the stored inspection row (null
394
+ * when the host has none to give); the sheet reads `templateId`, `requireDefectFieldsOverride`,
395
+ * `profileOverride`, `coverPhotoId` / `coverPhoto` and `coverCrop` off it.
396
+ */
397
+ interface SettingsSheetData {
398
+ inspection: Record<string, unknown> | null;
399
+ templates: TemplateSummary[];
400
+ photos: CoverPhotoOption[];
401
+ profiles: ReportProfileOption[];
402
+ }
403
+ /** One stored version of an inspection's report content. */
404
+ interface VersionSnapshotEntry {
405
+ seq: number;
406
+ atMs: number;
407
+ byUserId: string | null;
408
+ /** Why it was captured; absent on legacy rows. */
409
+ reason?: 'periodic' | 'manual' | 'connect';
410
+ }
411
+ /** One stored version's content: the per-finding projection a compare diffs against the live results. */
412
+ interface VersionSnapshotContent {
413
+ projection: ResultsProjection;
414
+ }
415
+ /** The provider-specific reference a video upload is addressed by. */
416
+ type VideoUploadRef = {
417
+ provider: 'stream';
418
+ streamUid: string;
419
+ } | {
420
+ provider: 'r2';
421
+ mediaId: string;
422
+ r2Key: string;
423
+ };
424
+ /**
425
+ * What `video.createUpload` answers: where to send the bytes and the backend reference the upload will
426
+ * end up under. For r2 the `ref` is a placeholder; the real `mediaId` and `r2Key` come back from the
427
+ * file upload.
428
+ */
429
+ interface VideoUploadTicket {
430
+ uploadURL: string;
431
+ provider: 'stream' | 'r2';
432
+ ref: VideoUploadRef;
433
+ }
434
+ /** What `video.finalize` takes: the final reference (r2 may carry the poster frame's key). */
435
+ type VideoFinalizeInput = {
436
+ provider: 'stream';
437
+ streamUid: string;
438
+ } | {
439
+ provider: 'r2';
440
+ mediaId: string;
441
+ r2Key: string;
442
+ posterKey?: string;
443
+ };
444
+
445
+ /**
446
+ * Data types of the `FieldDataSource` contract.
447
+ *
448
+ * The package may not import from `app/` or `server/` (see
449
+ * `scripts/check-field-ui-boundary.mjs`). Types that live in `./shared` are
450
+ * re-exported from there, so the contract and the editor logic share one
451
+ * definition. The shapes the editor reads that still live in the host
452
+ * (`packages/field-ui/src/hooks/useInspection.ts`, the inspection-edit loader, server libraries)
453
+ * are declared here from what `app/routes/inspection-edit/loader.server.ts`
454
+ * returns and what `app/routes/inspection-edit.tsx` reads off it.
455
+ */
456
+
457
+ /** Mirrors `Inspection` in packages/field-ui/src/hooks/useInspection.ts. */
458
+ interface EditorInspection {
459
+ id: string;
460
+ propertyAddress?: string;
461
+ clientName?: string;
462
+ clientEmail?: string;
463
+ date?: string;
464
+ scheduledDate?: string;
465
+ createdAt?: string;
466
+ status?: string;
467
+ reportStatus?: string;
468
+ propertyType?: string;
469
+ teamMode?: boolean;
470
+ paymentRequired?: boolean;
471
+ agreementRequired?: boolean;
472
+ /** The loader deletes this from the row and returns it once, top-level. */
473
+ templateSnapshot?: unknown;
474
+ /** The loader row is the full inspection record; the editor reads further keys defensively. */
475
+ [key: string]: unknown;
476
+ }
477
+ /** Mirrors `RatingLevel` in packages/field-ui/src/hooks/useInspection.ts. */
478
+ interface RatingLevel {
479
+ id: string;
480
+ label: string;
481
+ name?: string;
482
+ abbreviation?: string;
483
+ color: string;
484
+ severity?: string;
485
+ isDefect?: boolean;
486
+ description?: string;
487
+ pausesAdvance?: boolean;
488
+ }
489
+ /**
490
+ * Item bodies carry canned-comment tabs, attributes, options and more; the
491
+ * editor reads them through narrower local types.
492
+ */
493
+ type TemplateItemBody = Record<string, unknown>;
494
+ /** Mirrors `SchemaItem` in packages/field-ui/src/hooks/useInspection.ts. */
495
+ interface SchemaItem extends TemplateItemBody {
496
+ id: string;
497
+ label: string;
498
+ name?: string;
499
+ type: string;
500
+ }
501
+ /** Mirrors `SchemaSection` in packages/field-ui/src/hooks/useInspection.ts. */
502
+ interface SchemaSection {
503
+ id: string;
504
+ title: string;
505
+ name?: string;
506
+ icon?: string;
507
+ items: SchemaItem[];
508
+ }
509
+ /** The schema after the loader overlays report-data sections. */
510
+ interface TemplateSchema {
511
+ sections: SchemaSection[];
512
+ }
513
+ /** Mirrors `ResultMap` in packages/field-ui/src/hooks/useInspection.ts. */
514
+ type ResultMap = Record<string, Record<string, unknown>>;
515
+ /** Mirrors `TagPin` in packages/field-ui/src/components/editor/TagChipRow.tsx. */
516
+ interface TagLibraryEntry {
517
+ id: string;
518
+ name: string;
519
+ color: string;
520
+ }
521
+ /** The loader returns `{ id, name, color }` rows. */
522
+ interface DefectCategory {
523
+ id: string;
524
+ name: string;
525
+ color: string;
526
+ }
527
+ /** Mirrors `UnitScopeRow` in packages/field-ui/src/components/editor/BreadcrumbDropdown.tsx. */
528
+ interface InspectionUnit {
529
+ id: string;
530
+ name: string;
531
+ /** 'building' | 'floor' | 'unit'. */
532
+ kind: string;
533
+ /** 'unit' | 'common'. */
534
+ type: string;
535
+ parentUnitId: string | null;
536
+ sortOrder: number;
537
+ }
538
+ interface UnitProgress {
539
+ units: Array<{
540
+ unitId: string;
541
+ rated: number;
542
+ total: number;
543
+ }>;
544
+ commonRated: number;
545
+ total: number;
546
+ }
547
+ /**
548
+ * The raw, un-normalised template snapshot structural operations rebuild from.
549
+ * This is the `Snapshot` the structural operations in `./shared/structure-ops`
550
+ * take and return, so a snapshot read from the data source feeds them directly.
551
+ */
552
+ type TemplateSnapshot = Snapshot;
553
+ /**
554
+ * Source today: `RevisionStatus` in server/lib/statutory/revision-status.ts, which
555
+ * is NOT moved into the package (it pulls in pdf-lib). The data source computes
556
+ * it, so this declaration is the package's own contract.
557
+ */
558
+ type RevisionStatus = {
559
+ kind: 'current';
560
+ } | {
561
+ kind: 'superseding_soon';
562
+ nextVersion: string;
563
+ from: number;
564
+ } | {
565
+ kind: 'superseded_elsewhere';
566
+ nextVersion: string;
567
+ from: number;
568
+ } | {
569
+ kind: 'cannot_produce';
570
+ applicableVersion: string;
571
+ templateVersion: string;
572
+ } | {
573
+ kind: 'withdrawn';
574
+ version: string;
575
+ /** The server's `WithdrawalReason` union. */
576
+ reason: string;
577
+ withdrawnAt: number;
578
+ replacementVersion: string | null;
579
+ };
580
+ /** Mirrors the local `StatutoryDetails` in loader.server.ts. */
581
+ interface StatutoryDetails {
582
+ inspectorSignatureDate: string | null;
583
+ employeePrintedName: string | null;
584
+ ownerName: string | null;
585
+ ownerEmail: string | null;
586
+ ownerMailingAddress: string | null;
587
+ ownerHomePhone: string | null;
588
+ ownerWorkPhone: string | null;
589
+ ownerCellPhone: string | null;
590
+ }
591
+ /** Mirrors the local `StatutoryCoverage` in loader.server.ts. */
592
+ interface StatutoryCoverage {
593
+ formId: string;
594
+ formTitle: string;
595
+ revision: string | null;
596
+ requiredTotal: number;
597
+ missing: Array<{
598
+ field: string;
599
+ provenance: 'pre_inspection' | 'per_inspection' | 'unknown';
600
+ }>;
601
+ }
602
+ interface ReportSignoffView {
603
+ role: 'field_observer' | 'pcr_reviewer';
604
+ personId: string;
605
+ name: string;
606
+ license: string | null;
607
+ qualificationsRef?: string | null;
608
+ signedAt?: number;
609
+ dualRole?: boolean;
610
+ }
611
+ interface PsqView {
612
+ status: 'sent' | 'received' | 'declined';
613
+ responses: Record<string, unknown> | null;
614
+ }
615
+ interface DocumentReviewItemView {
616
+ documentKey: string;
617
+ label: string;
618
+ requested: boolean;
619
+ received: boolean;
620
+ reviewed: boolean;
621
+ na: boolean;
622
+ notes: string | null;
623
+ }
624
+ interface RelianceText {
625
+ userReliance: string;
626
+ pointInTime: string;
627
+ siteSpecific: string;
628
+ }
629
+ /** What the compliance panel renders. The compliance panel and the editor loader share it. */
630
+ interface CompliancePanelData {
631
+ reportSignoffs: ReportSignoffView[];
632
+ psq: PsqView | null;
633
+ documentReview: DocumentReviewItemView[];
634
+ conformance: {
635
+ standard: 'E2018-24';
636
+ conforms: boolean;
637
+ };
638
+ relianceText: RelianceText;
639
+ }
640
+ /** The loader returns the `commercial:<subtype>`-keyed subset of METADATA_PRESETS. */
641
+ type CommercialPresets = Record<string, PropertyMetaField[]>;
642
+ /** Mirrors `ReportLinkTtl` in server/lib/report-link-ttl.ts (server code, not importable here). */
643
+ type ReportLinkTtl = 'never' | {
644
+ count: number;
645
+ unit: 'days' | 'months' | 'years';
646
+ };
647
+ /**
648
+ * The tenant's inspection-editor preferences, as `useInspectionPrefs` reads and
649
+ * patches them (packages/field-ui/src/hooks/useInspectionPrefs.ts, `InspectionPrefs`).
650
+ */
651
+ interface InspectionPrefs {
652
+ cloneDefault: 'rating' | 'rating_notes' | 'all';
653
+ autoAdvance: AutoAdvanceMode;
654
+ autoAdvanceDelayMs: number;
655
+ pinnedTagIds: string[];
656
+ /** Which defect fields the publish gate requires tenant-wide. */
657
+ requireDefectFields: 'none' | 'location' | 'trade' | 'both';
658
+ agentRepairAccess: 'off' | 'read' | 'readwrite';
659
+ reportLinkTtl: ReportLinkTtl;
660
+ }
661
+ /**
662
+ * A crop rectangle in source-image pixels, as the editor serialises it to the
663
+ * crop endpoints (usePhotoOps `cropTransform`: `{ aspect, orientation, ...pixels }`).
664
+ */
665
+ interface PhotoCropTransform {
666
+ /** 'free' or a preset such as '3:2'. */
667
+ aspect: string;
668
+ orientation: 'landscape' | 'portrait';
669
+ x: number;
670
+ y: number;
671
+ width: number;
672
+ height: number;
673
+ }
674
+ /** The cooling-window signal the publish dialog reads. */
675
+ interface OutboundCoolingWindow {
676
+ unlockAtMs: number;
677
+ turnstileSiteKey: string | null;
678
+ }
679
+ /** The Appendix narrative blocks of a commercial (PCA) report; same shape as `PcaNarrative`. */
680
+ type PcaNarrativeData = PcaNarrative;
681
+ /**
682
+ * One cost line of a commercial report, client-side mirror of the server's
683
+ * pca-costs shape (server code is not importable here).
684
+ */
685
+ interface CostItemView {
686
+ id: string;
687
+ system: string;
688
+ component: string;
689
+ location: string;
690
+ action: 'repair' | 'replace' | 'further_study';
691
+ costMethod: 'unit' | 'lump_sum';
692
+ quantity: number | null;
693
+ uom: string | null;
694
+ unitCostCents: number | null;
695
+ lumpSumCents: number | null;
696
+ eul: number | null;
697
+ effAge: number | null;
698
+ rul: number | null;
699
+ suggestedRemedy: string;
700
+ bucket: 'immediate' | 'short_term' | 'long_term';
701
+ sectionRef: string | null;
702
+ photoRef: string | null;
703
+ sortOrder: number;
704
+ }
705
+ /** One template row of the template picker. Mirrors the rows `/resources/template-search` returns. */
706
+ interface TemplateSummary {
707
+ id: string;
708
+ name: string;
709
+ }
710
+ /**
711
+ * One page of the template picker's search. Mirrors the loader of
712
+ * `app/routes/resources/template-search.tsx`. The host pages by page number
713
+ * (25 rows per page), not by offset, so the contract does too.
714
+ */
715
+ interface TemplateSearchResult {
716
+ templates: TemplateSummary[];
717
+ hasMore: boolean;
718
+ page: number;
719
+ totalPages: number;
720
+ }
721
+ /**
722
+ * The filters of a canned-comment library read. Every member is optional and an absent or
723
+ * empty one is not sent. The host serialises the members in their insertion order, so a
724
+ * caller that sets `sort` before `search` sends them in that order.
725
+ */
726
+ interface CommentsLibraryQuery {
727
+ search?: string;
728
+ /** 'relevance' or a host-defined sort name. */
729
+ sort?: string;
730
+ /** 'auto' narrows by the item context below; 'all' ignores it. */
731
+ filterMode?: string;
732
+ itemLabel?: string;
733
+ section?: string;
734
+ severity?: string;
735
+ pageSize?: string;
736
+ }
737
+ /** One library row as the host answers it. `lastUsedAt` is present when the host tracks use. */
738
+ interface CommentsLibraryRow {
739
+ id: string;
740
+ text: string;
741
+ category: string | null;
742
+ severity: string | null;
743
+ section: string | null;
744
+ libraryId?: string | null;
745
+ useCount?: number;
746
+ lastUsedAt?: string | null;
747
+ }
748
+ /**
749
+ * What a library save or edit carries. An empty string for `section`, `category` or `itemLabel`
750
+ * means "none". `severity` is a rating severity or 'all'; the host stores anything else as none.
751
+ */
752
+ interface CommentsLibraryEntry {
753
+ text: string;
754
+ severity: string;
755
+ section: string;
756
+ category: string;
757
+ itemLabel: string;
758
+ repairSummary?: string;
759
+ recommendedContractorTypeId?: string;
760
+ }
761
+ /**
762
+ * One AI writing-assistance request. `assist` asks for a rewrite of `text` (with the item
763
+ * `context` when there is one); `review` records that a person reviewed the output of call
764
+ * `aiCallId` for the artifact `artifactId`.
765
+ */
766
+ type AiAssistInput = {
767
+ intent: 'assist';
768
+ payload: {
769
+ text: string;
770
+ context?: string;
771
+ };
772
+ } | {
773
+ intent: 'review';
774
+ payload: {
775
+ artifactId: string;
776
+ aiCallId: string;
777
+ };
778
+ };
779
+ /**
780
+ * What an AI assist or review answers. A successful `assist` carries BOTH the text and the id of the
781
+ * call that produced it (a suggestion without an id could never be reviewed, so a host answers
782
+ * `ok: false` instead); a successful `review` carries neither. `error` is the sentence to show.
783
+ */
784
+ type AiAssistResult = {
785
+ ok: true;
786
+ intent: 'assist';
787
+ text: string;
788
+ aiCallId: string;
789
+ } | {
790
+ ok: true;
791
+ intent: 'review';
792
+ } | {
793
+ ok: false;
794
+ error: string;
795
+ };
796
+
797
+ /**
798
+ * The write commands the editor issues. One member per route-action intent of
799
+ * `app/routes/inspection-edit/action.server.ts` (and the two modules it chains
800
+ * to, `action-statutory.server.ts` and `action-followup.server.ts`).
801
+ *
802
+ * NOT in this union, on purpose:
803
+ * - `upload-photo`. Photo bytes go through `FieldDataSource.media.uploader`
804
+ * (online) or `media.store` (offline); see `MediaUploader` in `media-types.ts`.
805
+ * - The `PATCH`es the editor writes straight into the live results document.
806
+ */
807
+ type FieldCommand = {
808
+ type: 'complete';
809
+ }
810
+ /**
811
+ * `markComplete` absent or false skips the complete call. `autoSignOnPublish`
812
+ * ABSENT means "do not touch the stored flag" (the action only PATCHes it when
813
+ * the form field is present), so a host that has no such toggle must omit it.
814
+ */
815
+ | {
816
+ type: 'publish';
817
+ markComplete?: boolean;
818
+ autoSignOnPublish?: boolean;
819
+ summary?: string;
820
+ }
821
+ /** The body is relayed unchanged to the property-facts PATCH; its keys belong to PropertyInfoForm (Task 5). */
822
+ | {
823
+ type: 'save-property-facts';
824
+ payload: Record<string, unknown>;
825
+ }
826
+ /** Read-only lookup: nothing is persisted. See `CommandDataMap['autofill-property-facts']`. */
827
+ | {
828
+ type: 'autofill-property-facts';
829
+ addressString: string;
830
+ }
831
+ /** The settings sheet's whole form; the data source sanitises it (empty strings mean "unchanged"). */
832
+ | {
833
+ type: 'save-settings';
834
+ payload: Record<string, unknown>;
835
+ }
836
+ /** An empty `key` is refused. */
837
+ | {
838
+ type: 'save-pca-narrative';
839
+ key: string;
840
+ value: string;
841
+ }
842
+ /** An empty `signatureBase64` is a no-op that still answers ok. */
843
+ | {
844
+ type: 'sign-inspector';
845
+ signatureBase64: string;
846
+ }
847
+ /** An empty string clears the cover. */
848
+ | {
849
+ type: 'set-cover';
850
+ coverPhotoId: string;
851
+ }
852
+ /** Uploads to the media pool, then sets it as the cover. The action requires a `File`; hosts wrap the blob. */
853
+ | {
854
+ type: 'upload-cover';
855
+ file: Blob;
856
+ }
857
+ /** `crop` is the JSON string of a `PhotoCropTransform` (the action forwards it as a form field). */
858
+ | {
859
+ type: 'crop-cover';
860
+ sourceKey: string;
861
+ crop: string;
862
+ image: Blob;
863
+ }
864
+ /**
865
+ * `nodes` is the JSON string of the serialised annotation node tree; always send it (the
866
+ * action tolerates a missing value by treating it as `'[]'`, which is not part of this
867
+ * contract). `photoIndex` must be >= 0. `sectionId` is optional: the action omits it
868
+ * from the request when empty.
869
+ */
870
+ | {
871
+ type: 'annotate';
872
+ itemId: string;
873
+ photoIndex: number;
874
+ nodes: string;
875
+ image: Blob;
876
+ sectionId?: string;
877
+ }
878
+ /** `collab` true also asks the live document to converge; a refused convergence answers `ok: false` with `error`. */
879
+ | {
880
+ type: 'restructure';
881
+ snapshot: TemplateSnapshot;
882
+ collab: boolean;
883
+ } | SaveStructureTemplateCommand | {
884
+ type: 'add-statutory-instance';
885
+ groupId: string;
886
+ index: number;
887
+ fields: Record<string, unknown>;
888
+ }
889
+ /** Absent keys mean "leave it alone", so a partial payload is safe. */
890
+ | {
891
+ type: 'save-statutory-details';
892
+ payload: Partial<StatutoryDetails>;
893
+ }
894
+ /** `status: null` clears the verdict; an absent `notes` leaves the stored note alone. */
895
+ | {
896
+ type: 'set-followup';
897
+ itemId: string;
898
+ status: string | null;
899
+ notes?: string;
900
+ } | UnitCommand | ComplianceCommand
901
+ /** Pulls one unit's findings. See `CommandDataMap['load-scope']`. */
902
+ | {
903
+ type: 'load-scope';
904
+ scope: string;
905
+ };
906
+ /**
907
+ * `mode: 'new'` forks a template (name defaults to a placeholder when blank).
908
+ * `mode: 'back'` updates the source template and REQUIRES its id: the action
909
+ * answers an error without it.
910
+ */
911
+ type SaveStructureTemplateCommand = {
912
+ type: 'save-structure-template';
913
+ mode: 'new';
914
+ snapshot: TemplateSnapshot;
915
+ name?: string;
916
+ } | {
917
+ type: 'save-structure-template';
918
+ mode: 'back';
919
+ snapshot: TemplateSnapshot;
920
+ templateId: string;
921
+ };
922
+ /** Body of the unit bulk-create route (`BulkCreateUnitsSchema`). */
923
+ type BulkCreateUnitsBody = {
924
+ mode: 'floors_stacks';
925
+ floors: number[];
926
+ stacks: number;
927
+ startAt?: number;
928
+ parentUnitId?: string | null;
929
+ } | {
930
+ mode: 'csv';
931
+ csv: string;
932
+ parentUnitId?: string | null;
933
+ };
934
+ /**
935
+ * Per-unit editor mutations (action intents `unit-*`). The payload keys per op
936
+ * are exactly what each action branch reads:
937
+ * - create: `name` (trimmed, required; the unit is created top-level)
938
+ * - rename: `unitId`, `name`
939
+ * - delete / duplicate: `unitId`
940
+ * - bulk-create: `payload`, the whole discriminated body
941
+ * - mode-switch: `mode`
942
+ * None of them returns data.
943
+ */
944
+ type UnitCommand = {
945
+ type: 'unit';
946
+ op: 'create';
947
+ name: string;
948
+ } | {
949
+ type: 'unit';
950
+ op: 'rename';
951
+ unitId: string;
952
+ name: string;
953
+ } | {
954
+ type: 'unit';
955
+ op: 'delete';
956
+ unitId: string;
957
+ } | {
958
+ type: 'unit';
959
+ op: 'duplicate';
960
+ unitId: string;
961
+ } | {
962
+ type: 'unit';
963
+ op: 'bulk-create';
964
+ payload: BulkCreateUnitsBody;
965
+ } | {
966
+ type: 'unit';
967
+ op: 'mode-switch';
968
+ mode: 'tagged' | 'per_unit';
969
+ };
970
+ /**
971
+ * Compliance panel mutations (action intents `compliance-*`). Keys per op:
972
+ * - signoff: `role`, `personId`, `name` (all required), `license` (empty becomes null), `dualRole`
973
+ * - remove-signoff: `role`
974
+ * - doc-review-seed: none (populates an empty checklist)
975
+ * - doc-review: `documentKey`, the four flags, `notes` (empty becomes null)
976
+ * - psq: `responses`
977
+ * - psq-status: `status`, and `reason` (context carried into the disclosed deviation when declined)
978
+ */
979
+ type ComplianceCommand = {
980
+ type: 'compliance';
981
+ op: 'signoff';
982
+ role: ReportSignoffView['role'];
983
+ personId: string;
984
+ name: string;
985
+ license: string | null;
986
+ dualRole: boolean;
987
+ } | {
988
+ type: 'compliance';
989
+ op: 'remove-signoff';
990
+ role: ReportSignoffView['role'];
991
+ } | {
992
+ type: 'compliance';
993
+ op: 'doc-review-seed';
994
+ } | {
995
+ type: 'compliance';
996
+ op: 'doc-review';
997
+ documentKey: string;
998
+ requested: boolean;
999
+ received: boolean;
1000
+ reviewed: boolean;
1001
+ na: boolean;
1002
+ notes: string | null;
1003
+ } | {
1004
+ type: 'compliance';
1005
+ op: 'psq';
1006
+ responses: Record<string, unknown>;
1007
+ } | {
1008
+ type: 'compliance';
1009
+ op: 'psq-status';
1010
+ status: PsqView['status'];
1011
+ reason?: string;
1012
+ };
1013
+ /**
1014
+ * Why an autofill returned no facts. The first three are the property-lookup
1015
+ * service's own codes (`LookupReason`); the last two are the route action's
1016
+ * (address shorter than five characters, and a non-ok API response).
1017
+ */
1018
+ type AutofillReason = 'NO_API_KEY' | 'NOT_FOUND' | 'PROVIDER_ERROR' | 'BAD_ADDRESS' | 'REQUEST_FAILED';
1019
+ /**
1020
+ * The value a command answers with, keyed by `CommandKey`. A command absent from
1021
+ * this map answers no data. Shapes are what the action returns beside `ok`.
1022
+ */
1023
+ interface CommandDataMap {
1024
+ /**
1025
+ * Present on BOTH outcomes: a failed lookup answers `{ ok: false, data: { facts: null, reason } }`,
1026
+ * and a successful lookup that found nothing answers `ok: true` with a `reason`. This is the
1027
+ * channel for the "auto-fill not configured" hint; do not parse `error` for it.
1028
+ */
1029
+ 'autofill-property-facts': {
1030
+ facts: Record<string, unknown> | null;
1031
+ reason: AutofillReason | null;
1032
+ };
1033
+ /** The composite-keyed slice for the editor to merge. */
1034
+ 'load-scope': {
1035
+ scope: string;
1036
+ results: ResultMap;
1037
+ };
1038
+ /** `coverUrl` is null when the upload response carried none. */
1039
+ 'upload-cover': {
1040
+ coverKey: string;
1041
+ coverUrl: string | null;
1042
+ };
1043
+ /** `coverKey` is null when the crop response carried none. */
1044
+ 'crop-cover': {
1045
+ coverKey: string | null;
1046
+ };
1047
+ 'compliance:signoff': {
1048
+ role: ReportSignoffView['role'];
1049
+ };
1050
+ 'compliance:remove-signoff': {
1051
+ role: ReportSignoffView['role'];
1052
+ };
1053
+ 'compliance:doc-review': {
1054
+ documentKey: string;
1055
+ };
1056
+ 'compliance:psq-status': {
1057
+ status: PsqView['status'];
1058
+ };
1059
+ }
1060
+ /** `type`, or `type:op` for the two commands that carry an `op`. */
1061
+ type CommandKey<C extends FieldCommand> = C extends {
1062
+ type: infer T extends string;
1063
+ op: infer O extends string;
1064
+ } ? `${T}:${O}` : C['type'];
1065
+ type DataOfKey<K> = K extends keyof CommandDataMap ? CommandDataMap[K] : undefined;
1066
+ /** The data type `execute` answers for command `C`; `undefined` when the command returns none. */
1067
+ type CommandData<C extends FieldCommand> = DataOfKey<CommandKey<C>>;
1068
+ /**
1069
+ * `ok` is the write's outcome. `error` is the sentence to show, when the action
1070
+ * wrote one (publish, restructure, save-structure-template, set-followup,
1071
+ * add-statutory-instance). `data` is typed per command by `CommandDataMap`.
1072
+ */
1073
+ interface CommandResult<D = undefined> {
1074
+ ok: boolean;
1075
+ error?: string;
1076
+ data?: D;
1077
+ /**
1078
+ * True on a failure that is NOT a refusal: the host re-sent the command with the same key while the server was
1079
+ * still processing it, and gave up waiting. The write may still land. The caller should say so (the `error` text
1080
+ * does) and must not roll back or retry blindly; checking the inspection shows the real state.
1081
+ */
1082
+ inProgress?: boolean;
1083
+ }
1084
+
1085
+ /**
1086
+ * What a pending record needs to be attached once its bytes have uploaded. The
1087
+ * `findingKey` is the composite key the results document is keyed by
1088
+ * (`<unit>:<section>:<item>`; `_default` for the common scope), which is how the
1089
+ * drain finds the doc entry to swap the real key into.
1090
+ */
1091
+ interface PendingMediaTarget {
1092
+ findingKey: string;
1093
+ itemId: string;
1094
+ }
1095
+ /**
1096
+ * A photo bound for an item, or for one defect row of an item. Mirrors the
1097
+ * `upload-photo` form fields of the inspection-edit action: `itemId` always;
1098
+ * `customId` and `defectKind` only with `targetType: 'defect'`.
1099
+ */
1100
+ type PhotoUploadMeta = (PendingMediaTarget & {
1101
+ targetType: 'item';
1102
+ }) | (PendingMediaTarget & {
1103
+ targetType: 'defect';
1104
+ customId: string;
1105
+ defectKind: 'canned' | 'custom';
1106
+ });
1107
+ /**
1108
+ * A baked crop of an existing photo. Cropping always re-derives from the ORIGINAL `photoKey`.
1109
+ *
1110
+ * `photoKey` is the authority; `photoIndex` is only the position the photo had when the
1111
+ * record was queued. The route addresses a photo by index and does not check the key, so
1112
+ * an uploader MUST re-resolve the index from `findingKey` + `photoKey` against the current
1113
+ * results document at upload time (the web uploader does this in `app/lib/field/web-uploader.ts`) and
1114
+ * must fail the record when the photo is gone, never trust the queued index: a photo
1115
+ * removed while the device was offline would otherwise shift every later index and land
1116
+ * the crop on a different photo. Task 4 owns this when the drain moves into the package.
1117
+ */
1118
+ interface PhotoCropMeta extends PendingMediaTarget {
1119
+ photoIndex: number;
1120
+ photoKey: string;
1121
+ crop: PhotoCropTransform;
1122
+ /** Optional on the route; omitted when the item has no section hint. */
1123
+ sectionId?: string;
1124
+ }
1125
+ /**
1126
+ * A baked annotation layered on a photo. `nodesJson` is the serialised annotation node tree.
1127
+ * `photoKey` is authoritative and `photoIndex` a queued snapshot, exactly as for `PhotoCropMeta`.
1128
+ */
1129
+ interface PhotoAnnotateMeta extends PendingMediaTarget {
1130
+ photoIndex: number;
1131
+ photoKey: string;
1132
+ nodesJson: string;
1133
+ sectionId?: string;
1134
+ }
1135
+ interface PendingMediaBase {
1136
+ pendingId: string;
1137
+ inspectionId: string;
1138
+ blob: Blob;
1139
+ createdAt: number;
1140
+ }
1141
+ /** `kind` decides which `meta` it carries, so a consumer narrows on `kind` and never casts. */
1142
+ type PendingMediaRecord = (PendingMediaBase & {
1143
+ kind: 'photo';
1144
+ meta: PhotoUploadMeta;
1145
+ }) | (PendingMediaBase & {
1146
+ kind: 'crop';
1147
+ meta: PhotoCropMeta;
1148
+ }) | (PendingMediaBase & {
1149
+ kind: 'annotate';
1150
+ meta: PhotoAnnotateMeta;
1151
+ });
1152
+ interface PendingMediaStore {
1153
+ /** Must reject on failure. */
1154
+ put(rec: PendingMediaRecord): Promise<void>;
1155
+ get(pendingId: string): Promise<PendingMediaRecord | null>;
1156
+ list(inspectionId?: string): Promise<PendingMediaRecord[]>;
1157
+ delete(pendingId: string): Promise<void>;
1158
+ /** Web: URL.createObjectURL; app: Capacitor.convertFileSrc. */
1159
+ objectUrl(pendingId: string): Promise<string | null>;
1160
+ }
1161
+ /**
1162
+ * One file's outcome, the same element shape as `results[]` in the
1163
+ * `upload-photo` action's answer (`index` is the file's position in its batch).
1164
+ */
1165
+ interface MediaFileResult {
1166
+ index: number;
1167
+ ok: boolean;
1168
+ key?: string;
1169
+ /** `NO_KEY`, `HTTP_<status>` or `EXCEPTION`. */
1170
+ error?: string;
1171
+ }
1172
+ /**
1173
+ * Online media upload. It is NOT a `FieldCommand`: the editor never routes photo
1174
+ * bytes through `execute`, because a command is a small structured write and
1175
+ * this is a binary body that the offline queue must also be able to replay.
1176
+ *
1177
+ * One call uploads one record (one file). The `upload-photo` action takes a batch
1178
+ * and answers `{ ok, keys: string[], results: MediaFileResult[], itemId, targetType,
1179
+ * customId, defectKind }`; a host that batches maps each file to one `upload` call.
1180
+ * The single `key` / `result` here are that file's outcome: `results[index]` lines up with
1181
+ * the file's position in the batch, while `keys[]` holds only the successful files and is
1182
+ * not aligned with it, so read `results[index].key` rather than `keys[index]`.
1183
+ *
1184
+ * - `kind: 'photo'`: `key` is the stored photo key.
1185
+ * - `kind: 'crop'`: `key` is the derived `croppedKey`.
1186
+ * - `kind: 'annotate'`: `key` is the derived `annotatedKey`.
1187
+ *
1188
+ * Failure is `{ ok: false, error }` and never a throw.
1189
+ */
1190
+ interface MediaUploader {
1191
+ upload(rec: PendingMediaRecord): Promise<{
1192
+ ok: true;
1193
+ key: string;
1194
+ result?: MediaFileResult;
1195
+ } | {
1196
+ ok: false;
1197
+ error: string;
1198
+ }>;
1199
+ }
1200
+
1201
+ /** Everything the editor reads on open. Mirrors the inspection-edit loader's return value. */
1202
+ interface EditorData {
1203
+ inspection: EditorInspection;
1204
+ /** The schema with report-data sections overlaid. Never PATCH this; use `templateSnapshot`. */
1205
+ schema: TemplateSchema;
1206
+ results: ResultMap;
1207
+ ratingLevels: RatingLevel[];
1208
+ tagLibrary: TagLibraryEntry[];
1209
+ defectCategories: DefectCategory[];
1210
+ units: InspectionUnit[];
1211
+ unitProgress: UnitProgress;
1212
+ /** 'tagged' is the loader's default; 'per_unit' loads findings per scope. */
1213
+ unitInspectionMode: 'tagged' | 'per_unit';
1214
+ /**
1215
+ * Always an object. The data source supplies the loader's default shape
1216
+ * (`{ schemaVersion: 2, sections: [] }`) when the inspection has no snapshot
1217
+ * (loader.server.ts, the `templateSnapshot` const).
1218
+ */
1219
+ templateSnapshot: TemplateSnapshot;
1220
+ /**
1221
+ * Null for every inspection whose template declares no statutory form, or names
1222
+ * no revision (`revisionStatusForInspection` returns null). Computed by the data
1223
+ * source; the package never imports revision-status (it pulls in pdf-lib).
1224
+ */
1225
+ revisionStatus: RevisionStatus | null;
1226
+ /** Null when the template declares no statutory form, or the read failed (loader: `statutoryDetails`). */
1227
+ statutoryDetails: StatutoryDetails | null;
1228
+ /**
1229
+ * Null means "could not be answered", NOT "nothing is missing" (loader:
1230
+ * `statutoryCoverage`). An empty `missing` array is the other thing.
1231
+ */
1232
+ statutoryCoverage: StatutoryCoverage | null;
1233
+ /**
1234
+ * Always an object: the data source supplies the loader's default shape (empty
1235
+ * sign-offs and checklist, `psq: null`, `conforms: false`) when the inspection
1236
+ * has no compliance rows or the read failed. The route merges
1237
+ * `{ ...compliance, relianceText }`, so a null here would reach the panel as undefined.
1238
+ */
1239
+ compliance: Omit<CompliancePanelData, 'relianceText'>;
1240
+ /** Always an object: the seed-resolved narrative (`resolvePcaNarrative`); the data source supplies the seed. */
1241
+ pcaNarrative: PcaNarrative;
1242
+ /** Always an object: stored text, falling back to the standard boilerplate per field; the data source supplies it. */
1243
+ relianceText: RelianceText;
1244
+ commercialPresets: CommercialPresets;
1245
+ nextPublishIsAmendment: boolean;
1246
+ /** Null unless the session context reports a cooling window. */
1247
+ outboundCoolingWindow: OutboundCoolingWindow | null;
1248
+ /** Null on legacy inspections created before every inspection got a results row. */
1249
+ resultId: string | null;
1250
+ /** The layout band this device showed last time, if the host remembers it: the first frame renders in it, then the viewport decides. Absent means 'desktop'. */
1251
+ initialLayout?: 'phone' | 'tablet' | 'desktop';
1252
+ }
1253
+ interface SessionInfo {
1254
+ tenantSlug: string | null;
1255
+ /** The company's video backend; the session-context contract always answers one of the two. */
1256
+ videoProvider: 'r2' | 'stream';
1257
+ streamCustomerSubdomain: string | null;
1258
+ collabEditing: boolean;
1259
+ /** Who the viewer is for presence. Null when the host cannot tell: no presence is announced. Never a placeholder shared by several people. */
1260
+ userId: string | null;
1261
+ role: string;
1262
+ /**
1263
+ * The VIEWER's display language: user override, else the tenant default, else
1264
+ * 'en-US' (`useDisplayLocale`). Language follows the viewer on every surface.
1265
+ */
1266
+ locale: string;
1267
+ /**
1268
+ * The VIEWER's display time zone (IANA id): user override, else the tenant's.
1269
+ * Null when the host has none to give; the package then falls back to 'UTC',
1270
+ * which is where `useDisplayTimeZone` ends (the tenant column is NOT NULL
1271
+ * DEFAULT 'UTC', so the web host never answers null).
1272
+ */
1273
+ timeZone: string | null;
1274
+ /**
1275
+ * The VIEWER's date order, resolved per field (user, else tenant, else 'us'),
1276
+ * for workspace chrome (`useChromeDateTimeFormat`). Never use it for a date a
1277
+ * second party also reads.
1278
+ */
1279
+ dateFormat: DateFormat;
1280
+ /** The VIEWER's clock, resolved like `dateFormat`. */
1281
+ timeFormat: TimeFormat;
1282
+ /**
1283
+ * The TENANT's date order, ignoring any personal override: the shape for
1284
+ * inspection, report and appointment dates (`useInspectionDateTimeFormat` /
1285
+ * `useTenantFormatPrefs`). Resolved with the same fallback as `dateFormat`.
1286
+ */
1287
+ tenantDateFormat: DateFormat;
1288
+ /** The TENANT's clock; see `tenantDateFormat`. */
1289
+ tenantTimeFormat: TimeFormat;
1290
+ }
1291
+ /** What `notify` can say: a neutral note, a success, a degraded outcome that is not a failure, or a failure. */
1292
+ type NotifyVariant = 'info' | 'success' | 'warning' | 'error';
1293
+ /**
1294
+ * Per-call options of `notify`. `durationMs` replaces the host's default display time for the variant.
1295
+ * `action` adds one button to the message (the editor's "Undo"): `run` is called when it is pressed.
1296
+ */
1297
+ interface NotifyOptions {
1298
+ durationMs?: number;
1299
+ action?: {
1300
+ label: string;
1301
+ run: () => void;
1302
+ };
1303
+ }
1304
+ /**
1305
+ * Per-call options of `execute`. The key is the write's idempotency key; how it reaches the server is the host's
1306
+ * business. The web host sends it as the `idempotencyKey` form field and its route action lifts it to an
1307
+ * `Idempotency-Key` header on the one write of each command that would duplicate a record if repeated (unit create,
1308
+ * statutory instance, cover upload); other commands are idempotent and ignore it. `useFieldCommand` holds the key
1309
+ * across a retry of a failed attempt and rotates it after an `ok` answer; without one `execute` is not idempotent.
1310
+ */
1311
+ interface ExecuteOptions {
1312
+ idempotencyKey?: string;
1313
+ }
1314
+
1315
+ interface ResultsDocHandle {
1316
+ doc: Y.Doc;
1317
+ persistenceSynced: Promise<void>;
1318
+ synced: () => boolean;
1319
+ destroy(): void;
1320
+ }
1321
+ interface CollabConnector {
1322
+ /** Open a results document: local persistence first, then the network. */
1323
+ connect(inspectionId: string, opts: {
1324
+ onChange?: () => void;
1325
+ onSynced?: () => void;
1326
+ }): ResultsDocHandle;
1327
+ purge(inspectionId: string): Promise<void>;
1328
+ }
1329
+ interface FieldUrls {
1330
+ photo(inspectionId: string, key: string): string;
1331
+ reportPreview(tenantSlug: string, inspectionId: string): string | null;
1332
+ reportPdf(inspectionId: string): string | null;
1333
+ /** The cost tables' download (`csv` or `xlsx`); a plain link the browser follows. */
1334
+ costExport(inspectionId: string, format: 'csv' | 'xlsx'): string;
1335
+ statutoryPreviewPdf(inspectionId: string): string | null;
1336
+ videoObject(inspectionId: string, ref: string): string | null;
1337
+ /**
1338
+ * The collaborative-presence WebSocket URL (absolute `ws:`/`wss:`). Today
1339
+ * `usePresence` builds it from `window.location`.
1340
+ */
1341
+ presenceSocket(inspectionId: string): string;
1342
+ /** The in-app path of this inspection's editor (the publish dialog reads `window.location.pathname`). */
1343
+ editorPath(inspectionId: string): string;
1344
+ /**
1345
+ * The in-app path of the inspection list, which the header's back control points at. Optional: a host
1346
+ * without addressable pages (a native shell) leaves it out and the control is a plain button that calls
1347
+ * `exit()`. When present the control is a real link (new tab, copy link, link semantics) whose plain
1348
+ * left click still goes through `exit()`, so the host's unsaved-changes guard sees the navigation.
1349
+ */
1350
+ inspectionsList?(): string;
1351
+ }
1352
+ /**
1353
+ * Optional host capabilities. An absent member hides its entry point.
1354
+ * The `unknown` payloads are tightened by plan B Task 8, which gives each
1355
+ * capability the concrete shape its panel reads.
1356
+ */
1357
+ interface FieldResources {
1358
+ /**
1359
+ * The tenant's editor preferences (auto-advance, pinned tags, required defect
1360
+ * fields, ...). Owner: `useInspectionPrefs`, which reads and saves through this
1361
+ * (the web host serves it from `/resources/inspection-prefs`). Absent means the host
1362
+ * has no preference store, and the editor uses its built-in defaults.
1363
+ * `patch` takes a partial delta; a not-ok result means the host did not save it, and
1364
+ * an ok result carries the host's validated, merged preferences as `data`, which the
1365
+ * editor adopts in place of its optimistic copy.
1366
+ */
1367
+ prefs?: {
1368
+ load(): Promise<InspectionPrefs>;
1369
+ patch(delta: Partial<InspectionPrefs>): Promise<CommandResult<InspectionPrefs>>;
1370
+ };
1371
+ /**
1372
+ * The tenant's canned-comment library. Owner: `useCannedComments` (the web host serves it from
1373
+ * `/resources/comments-library`). `list` never rejects: a failed read answers `[]`. `save` creates
1374
+ * an entry, `edit` replaces one, `touch` records one use; a not-ok answer means the host did not
1375
+ * store it. There is no remove: the library page deletes through its own route.
1376
+ */
1377
+ commentsLibrary?: {
1378
+ list(query?: CommentsLibraryQuery): Promise<CommentsLibraryRow[]>;
1379
+ touch(id: string): Promise<CommandResult>;
1380
+ save(entry: CommentsLibraryEntry): Promise<CommandResult>;
1381
+ edit(id: string, entry: CommentsLibraryEntry): Promise<CommandResult>;
1382
+ };
1383
+ /**
1384
+ * The cost lines. `list` never rejects: a failed read answers no lines and no reserve fields. `save`
1385
+ * never rejects either: a refused or unreachable write answers `{ ok: false }`, and a `create` that
1386
+ * landed answers the stored line's id as `data.id`. The caller owns the same-frame guard: a `create`
1387
+ * is not idempotent.
1388
+ */
1389
+ costItems?: {
1390
+ list(inspectionId: string): Promise<CostItemsListing>;
1391
+ save(inspectionId: string, write: CostItemWrite): Promise<CommandResult<{
1392
+ id?: string;
1393
+ }>>;
1394
+ };
1395
+ /** The tenant's repair-item catalogue (it is not per inspection). `list` never rejects: a failed read answers none. */
1396
+ repairItems?: {
1397
+ list(): Promise<RepairItemOption[]>;
1398
+ };
1399
+ /** The inspection's photos for the gallery. `list` never rejects: a failed read answers `answered: false`. */
1400
+ mediaPool?: {
1401
+ list(inspectionId: string): Promise<MediaPoolListing>;
1402
+ };
1403
+ /**
1404
+ * What the inspection settings sheet reads when it opens. Never rejects; `null` means the host could not
1405
+ * read it (the sheet then offers a retry instead of a form that would save blanks over the stored values).
1406
+ */
1407
+ settingsSheet?: {
1408
+ load(inspectionId: string): Promise<SettingsSheetData | null>;
1409
+ };
1410
+ /**
1411
+ * AI writing assistance for an inspection note (the web host serves it from `/resources/ai-assist`).
1412
+ * Never rejects: every failure answers `{ ok: false, error }`. The caller owns the same-frame guard:
1413
+ * an `assist` spends a metered AI call.
1414
+ */
1415
+ aiAssist?: {
1416
+ run(input: AiAssistInput): Promise<AiAssistResult>;
1417
+ };
1418
+ /**
1419
+ * The pre-publish check. `check` never rejects: `null` means the host could not answer (a failed or
1420
+ * unreadable read), and the caller then goes on to the publish dialog rather than blocking on a flaky
1421
+ * pre-flight. A non-null answer is the host's verdict, ready or not. Absent means the host has no
1422
+ * pre-flight and the publish dialog opens directly.
1423
+ */
1424
+ publishReadiness?: {
1425
+ check(inspectionId: string): Promise<PublishReadiness | null>;
1426
+ };
1427
+ /** The template picker's search. Typed from the existing route loader (see `TemplateSearchResult`). */
1428
+ templateSearch?: {
1429
+ search(input: {
1430
+ q: string;
1431
+ page: number;
1432
+ }): Promise<TemplateSearchResult>;
1433
+ };
1434
+ /**
1435
+ * Stored versions of the report content. None of them rejects. A not-ok `list` or `get` means the host
1436
+ * could not read it (the panel shows its retry); a not-ok `restore` carries the sentence to show. The
1437
+ * caller owns the same-frame guard: `capture` and `restore` each store a version row.
1438
+ */
1439
+ versionHistory?: {
1440
+ list(inspectionId: string): Promise<CommandResult<VersionSnapshotEntry[]>>;
1441
+ get(inspectionId: string, seq: number): Promise<CommandResult<VersionSnapshotContent>>;
1442
+ restore(inspectionId: string, seq: number): Promise<CommandResult>;
1443
+ /** The "Save version now" POST. */
1444
+ capture(inspectionId: string): Promise<CommandResult>;
1445
+ };
1446
+ /**
1447
+ * The video upload flow, in the order the capture sheet calls it: `createUpload`, `uploadFile`, (r2)
1448
+ * `uploadPoster`, `finalize`. None of them rejects: a failure answers `{ ok: false, error }` where `error` is
1449
+ * the reason to show, or absent when the host has none (the sheet then shows its own sentence). A host
1450
+ * without the video flow leaves the member out, and the capture entry point is not offered.
1451
+ */
1452
+ video?: {
1453
+ createUpload(inspectionId: string, input: {
1454
+ itemId?: string;
1455
+ }): Promise<CommandResult<VideoUploadTicket>>;
1456
+ /**
1457
+ * Send the clip to the ticket's `uploadURL`, reporting `0..100` progress. For r2 the answer carries the real
1458
+ * `{ mediaId, r2Key }` the finalize needs; for stream it carries no data.
1459
+ */
1460
+ uploadFile(ticket: VideoUploadTicket, file: Blob, onProgress: (pct: number) => void): Promise<CommandResult<{
1461
+ mediaId: string;
1462
+ r2Key: string;
1463
+ } | undefined>>;
1464
+ /** r2 only: store the clip's first frame as its poster. A failure is not fatal to the upload. */
1465
+ uploadPoster(inspectionId: string, ticket: VideoUploadTicket, blob: Blob): Promise<CommandResult<{
1466
+ posterKey: string;
1467
+ }>>;
1468
+ /** Pick the poster frame of a Stream video; `posterPct` is 0..1. */
1469
+ setPoster(inspectionId: string, input: {
1470
+ streamUid: string;
1471
+ posterPct: number;
1472
+ }): Promise<CommandResult>;
1473
+ /** Turn the uploaded clip into a media-pool row; `data.poolId` is that row's id. */
1474
+ finalize(inspectionId: string, input: VideoFinalizeInput): Promise<CommandResult<{
1475
+ poolId: string;
1476
+ }>>;
1477
+ remove(inspectionId: string, ref: string): Promise<CommandResult>;
1478
+ };
1479
+ }
1480
+
1481
+ interface FieldDataSource {
1482
+ load(inspectionId: string, scope?: string): Promise<EditorData>;
1483
+ session(): SessionInfo;
1484
+ /** The answer's `data` is typed from the command (see `CommandDataMap`). */
1485
+ execute<C extends FieldCommand>(inspectionId: string, cmd: C, opts?: ExecuteOptions): Promise<CommandResult<CommandData<C>>>;
1486
+ /** Show a transient message (replaces the host's toast inside the package). */
1487
+ notify(message: string, variant?: NotifyVariant, opts?: NotifyOptions): void;
1488
+ reload(): void;
1489
+ /**
1490
+ * Start the editor over from a clean mount. `reload()` only re-reads the loader data, but the editor freezes
1491
+ * its template schema (and seeds the collab document from it) when it mounts, so a template change needs a
1492
+ * fresh mount. The web host reloads the page. A host that cannot do this leaves the member out: the editor
1493
+ * then tells the person to reopen the inspection instead of silently showing the old template.
1494
+ */
1495
+ restartSession?(): void;
1496
+ /** Open an address outside the editor (the report preview), in a new tab without an opener on the web. A host that
1497
+ * cannot leaves the member out: the editor then tells the person the preview is unavailable. */
1498
+ openUrl?(url: string): void;
1499
+ exit(): void;
1500
+ isOnline(): boolean;
1501
+ onOnlineChange(cb: (online: boolean) => void): () => void;
1502
+ collab: CollabConnector;
1503
+ media: {
1504
+ store: PendingMediaStore;
1505
+ /**
1506
+ * `doc` is the live results document, or null when the host has none yet. A crop or
1507
+ * annotation addresses its photo by index, so the uploader resolves the CURRENT index from
1508
+ * `doc` at upload time and answers `{ ok: false }` when `doc` is null or the photo is gone.
1509
+ */
1510
+ uploader(inspectionId: string, doc: Y.Doc | null): MediaUploader;
1511
+ };
1512
+ urls: FieldUrls;
1513
+ resources: FieldResources;
1514
+ /** Where the editor runs. It picks the phone and tablet app bar; a native host sets `viewport-fit=cover`, since the native
1515
+ * bars and the bottom tabs reserve `env(safe-area-inset-*)`. The web adapter reports 'web': the bar stays as it was. */
1516
+ platform: 'web' | 'ios' | 'android';
1517
+ }
1518
+
1519
+ export type { ResultsDocHandle as $, AiAssistInput as A, BulkCreateUnitsBody as B, CommandResult as C, DateFormat as D, EditorData as E, FieldCommand as F, NotifyVariant as G, PcaNarrativeData as H, InspectionPrefs as I, PendingMediaRecord as J, PendingMediaStore as K, PhotoAnnotateMeta as L, MediaFileResult as M, NotifyOptions as N, OutboundCoolingWindow as O, PcaNarrative as P, PhotoCropMeta as Q, PhotoCropTransform as R, PhotoUploadMeta as S, PropertyMetaField as T, PsqView as U, RatingLevel as V, RelianceText as W, RepairItemOption as X, ReportLinkTtl as Y, ReportSignoffView as Z, ResultMap as _, CommandData as a, RevisionStatus as a0, SaveStructureTemplateCommand as a1, SchemaItem as a2, SchemaSection as a3, SessionInfo as a4, SettingsSheetData as a5, StatutoryCoverage as a6, StatutoryDetails as a7, TagLibraryEntry as a8, TemplateItemBody as a9, TemplateSchema as aa, TemplateSearchResult as ab, TemplateSnapshot as ac, TemplateSummary as ad, TimeFormat as ae, UnitCommand as af, UnitProgress as ag, VersionSnapshotContent as ah, VersionSnapshotEntry as ai, VideoFinalizeInput as aj, VideoUploadRef as ak, VideoUploadTicket as al, FieldDataSource as b, AiAssistResult as c, AutoAdvanceMode as d, AutofillReason as e, CollabConnector as f, CommandDataMap as g, CommandKey as h, CommentsLibraryEntry as i, CommentsLibraryQuery as j, CommentsLibraryRow as k, CommercialPresets as l, ComplianceCommand as m, CompliancePanelData as n, CostItemView as o, CostItemWrite as p, CostItemsListing as q, DefectCategory as r, DocumentReviewItemView as s, EditorInspection as t, ExecuteOptions as u, FieldResources as v, FieldUrls as w, InspectionUnit as x, MediaPoolListing as y, MediaUploader as z };