@braedonsaunders/appkit-forms 0.2.2
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.
- package/LICENSE +661 -0
- package/README.md +74 -0
- package/canvas-editor.d.ts +24 -0
- package/canvas-editor.d.ts.map +1 -0
- package/canvas-editor.js +115 -0
- package/canvas-editor.js.map +1 -0
- package/canvas-runtime.d.ts +12 -0
- package/canvas-runtime.d.ts.map +1 -0
- package/canvas-runtime.js +54 -0
- package/canvas-runtime.js.map +1 -0
- package/element-preview.d.ts +9 -0
- package/element-preview.d.ts.map +1 -0
- package/element-preview.js +167 -0
- package/element-preview.js.map +1 -0
- package/form-designer.d.ts +29 -0
- package/form-designer.d.ts.map +1 -0
- package/form-designer.js +214 -0
- package/form-designer.js.map +1 -0
- package/form-flow-validation.d.ts +5 -0
- package/form-flow-validation.d.ts.map +1 -0
- package/form-flow-validation.js +154 -0
- package/form-flow-validation.js.map +1 -0
- package/form-renderer.d.ts +37 -0
- package/form-renderer.d.ts.map +1 -0
- package/form-renderer.js +282 -0
- package/form-renderer.js.map +1 -0
- package/formula-builder.d.ts +25 -0
- package/formula-builder.d.ts.map +1 -0
- package/formula-builder.js +310 -0
- package/formula-builder.js.map +1 -0
- package/generated-copy.d.ts +17 -0
- package/generated-copy.d.ts.map +1 -0
- package/generated-copy.js +396 -0
- package/generated-copy.js.map +1 -0
- package/index.d.ts +27 -0
- package/index.d.ts.map +1 -0
- package/index.js +25 -0
- package/index.js.map +1 -0
- package/logic-builder.d.ts +27 -0
- package/logic-builder.d.ts.map +1 -0
- package/logic-builder.js +106 -0
- package/logic-builder.js.map +1 -0
- package/package.json +54 -0
- package/photo-field-state.d.ts +9 -0
- package/photo-field-state.d.ts.map +1 -0
- package/photo-field-state.js +11 -0
- package/photo-field-state.js.map +1 -0
- package/production-designer-adapter.d.ts +100 -0
- package/production-designer-adapter.d.ts.map +1 -0
- package/production-designer-adapter.js +2 -0
- package/production-designer-adapter.js.map +1 -0
- package/production-file-upload.d.ts +27 -0
- package/production-file-upload.d.ts.map +1 -0
- package/production-file-upload.js +105 -0
- package/production-file-upload.js.map +1 -0
- package/production-form-designer.d.ts +3 -0
- package/production-form-designer.d.ts.map +1 -0
- package/production-form-designer.js +1124 -0
- package/production-form-designer.js.map +1 -0
- package/production-form-renderer.d.ts +16 -0
- package/production-form-renderer.d.ts.map +1 -0
- package/production-form-renderer.js +2134 -0
- package/production-form-renderer.js.map +1 -0
- package/production-runtime-adapter.d.ts +187 -0
- package/production-runtime-adapter.d.ts.map +1 -0
- package/production-runtime-adapter.js +2 -0
- package/production-runtime-adapter.js.map +1 -0
- package/production-runtime-context.d.ts +29 -0
- package/production-runtime-context.d.ts.map +1 -0
- package/production-runtime-context.js +35 -0
- package/production-runtime-context.js.map +1 -0
- package/production-runtime-layout.d.ts +21 -0
- package/production-runtime-layout.d.ts.map +1 -0
- package/production-runtime-layout.js +33 -0
- package/production-runtime-layout.js.map +1 -0
- package/properties.d.ts +36 -0
- package/properties.d.ts.map +1 -0
- package/properties.js +133 -0
- package/properties.js.map +1 -0
- package/record-actions-panel.d.ts +28 -0
- package/record-actions-panel.d.ts.map +1 -0
- package/record-actions-panel.js +136 -0
- package/record-actions-panel.js.map +1 -0
- package/record-behavior-panel.d.ts +15 -0
- package/record-behavior-panel.d.ts.map +1 -0
- package/record-behavior-panel.js +59 -0
- package/record-behavior-panel.js.map +1 -0
- package/record-config.d.ts +44 -0
- package/record-config.d.ts.map +1 -0
- package/record-config.js +2 -0
- package/record-config.js.map +1 -0
- package/record-list-panel.d.ts +16 -0
- package/record-list-panel.d.ts.map +1 -0
- package/record-list-panel.js +86 -0
- package/record-list-panel.js.map +1 -0
- package/risk-matrix.d.ts +93 -0
- package/risk-matrix.d.ts.map +1 -0
- package/risk-matrix.js +239 -0
- package/risk-matrix.js.map +1 -0
- package/runtime-controls.d.ts +19 -0
- package/runtime-controls.d.ts.map +1 -0
- package/runtime-controls.js +61 -0
- package/runtime-controls.js.map +1 -0
- package/sketch-pad-client.d.ts +4 -0
- package/sketch-pad-client.d.ts.map +1 -0
- package/sketch-pad-client.js +54 -0
- package/sketch-pad-client.js.map +1 -0
- package/sketch-pad.d.ts +13 -0
- package/sketch-pad.d.ts.map +1 -0
- package/sketch-pad.js +17 -0
- package/sketch-pad.js.map +1 -0
- package/styles.css +7 -0
- package/text.d.ts +5 -0
- package/text.d.ts.map +1 -0
- package/text.js +10 -0
- package/text.js.map +1 -0
- package/workflow-properties.d.ts +16 -0
- package/workflow-properties.d.ts.map +1 -0
- package/workflow-properties.js +31 -0
- package/workflow-properties.js.map +1 -0
|
@@ -0,0 +1,2134 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { GeneratedText, useGeneratedTranslations, GeneratedValue, useGeneratedValueTranslations, } from './generated-copy.js';
|
|
4
|
+
// Form filler runtime.
|
|
5
|
+
//
|
|
6
|
+
// Features:
|
|
7
|
+
// - Multi-step rendering grouped by `section.step` against `workflow.steps`
|
|
8
|
+
// - Progress strip with click-to-jump on completed steps
|
|
9
|
+
// - Per-field visibility via `evaluateLogicRule(field.showIf, ctx)`
|
|
10
|
+
// - Section visibility via `evaluateLogicRule(section.showIf, ctx)`
|
|
11
|
+
// - Repeating sections with min/max-rows bounds + row-label-template
|
|
12
|
+
// - Formula fields rendered read-only, recomputed via evaluateFormulaTree
|
|
13
|
+
// - Default values resolved via resolveDefaultValue once per first render
|
|
14
|
+
// - Validation runs on Next and Submit; inline errors per field
|
|
15
|
+
//
|
|
16
|
+
// Storage shape: `data` is `Record<string, unknown>` keyed by field id for
|
|
17
|
+
// top-level fields, plus `data[sectionId]` = `Array<Record<fieldId, value>>`
|
|
18
|
+
// for repeating sections. Same convention the response-viewer already reads.
|
|
19
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition, } from 'react';
|
|
20
|
+
import { ProductionFormRuntimeProvider, useProductionLocale as useLocale, useProductionTranslations as useTranslations, useProductionFormRuntime, } from './production-runtime-context.js';
|
|
21
|
+
import { AlertTriangle, Bold, Check, ClipboardList, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Cloud, CloudOff, Eye, Italic, Link as LinkIcon, List, MapPin, Minus, Plus, ScanLine, Search, ShieldCheck, Sparkles, Sun, Trash2, X, } from 'lucide-react';
|
|
22
|
+
import { Alert, UiLink as Link, SignaturePad, WizardLayout, toast, AlertDescription, AlertTitle, Badge, Button, DetailHeader, Input, Label, SearchSelect, Select, Textarea, uploadReservedFile, } from '@braedonsaunders/appkit-ui';
|
|
23
|
+
import { evaluateFormulaTree, evaluateLogicRule, resolveDefaultValue, validateFieldValue, validateResponse, entityKindForPicker, } from '@braedonsaunders/appkit-forms-core';
|
|
24
|
+
import { normalizeFormResponseDraftData, normalizeRichTextLinkUrl, } from '@braedonsaunders/appkit-forms-core/response-normalize';
|
|
25
|
+
import { sanitizeDocumentHtml } from '@braedonsaunders/appkit-forms-core/sanitize';
|
|
26
|
+
import { localizeText } from '@braedonsaunders/appkit-i18n';
|
|
27
|
+
import { SketchPad } from './sketch-pad.js';
|
|
28
|
+
import { RiskMatrixField } from './risk-matrix.js';
|
|
29
|
+
import { ProductionFileUpload, dataUrlToFile, } from './production-file-upload.js';
|
|
30
|
+
import { ProductionPremiumSection as PremiumSection } from './production-runtime-layout.js';
|
|
31
|
+
import { ProductionSection as Section } from './production-runtime-layout.js';
|
|
32
|
+
import { canvasCss, columnsCss, gridClass, resolveCanvas } from './canvas-runtime.js';
|
|
33
|
+
import { attachmentIdsEqual, singlePrimaryPhoto } from './photo-field-state.js';
|
|
34
|
+
function RawImage({ optimizationReason: _, ...props }) {
|
|
35
|
+
return _jsx("img", { ...props });
|
|
36
|
+
}
|
|
37
|
+
function FileUpload(props) {
|
|
38
|
+
const { adapter } = useProductionFormRuntime();
|
|
39
|
+
if (!adapter.requestUpload || !adapter.finalizeUpload) {
|
|
40
|
+
return (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: "File upload is not configured for this form runtime." }) }));
|
|
41
|
+
}
|
|
42
|
+
return (_jsx(ProductionFileUpload, { ...props, requestUpload: adapter.requestUpload, finalizeUpload: adapter.finalizeUpload }));
|
|
43
|
+
}
|
|
44
|
+
// Cycle a tasteful tone palette across the dynamic schema sections so the fill
|
|
45
|
+
// page gets the same premium, colorful section cards as the incident /
|
|
46
|
+
// hazard-assessment pages (which assign tones semantically per section).
|
|
47
|
+
const SECTION_TONES = [
|
|
48
|
+
'teal',
|
|
49
|
+
'blue',
|
|
50
|
+
'purple',
|
|
51
|
+
'amber',
|
|
52
|
+
'indigo',
|
|
53
|
+
'emerald',
|
|
54
|
+
'rose',
|
|
55
|
+
'slate',
|
|
56
|
+
];
|
|
57
|
+
// Read-only context — when true, custom (non-form-control) inputs that a
|
|
58
|
+
// disabled <fieldset> can't reach (signature/sketch canvases, the rich-text
|
|
59
|
+
// contentEditable) render static. Provided by FormRenderer in record/view mode.
|
|
60
|
+
const FillReadOnlyContext = createContext(false);
|
|
61
|
+
// Per-MOUNT cache of org-unit picker options, keyed by level. Deliberately
|
|
62
|
+
// React state (provided by FormRenderer), never module scope: a tenant switch
|
|
63
|
+
// remounts the page subtree, so a fresh mount refetches instead of serving the
|
|
64
|
+
// previous tenant's org units.
|
|
65
|
+
const OrgUnitOptionsCacheContext = createContext(null);
|
|
66
|
+
function getOrCreateDraftSessionId(ref) {
|
|
67
|
+
if (ref.current)
|
|
68
|
+
return ref.current;
|
|
69
|
+
ref.current = crypto.randomUUID();
|
|
70
|
+
return ref.current;
|
|
71
|
+
}
|
|
72
|
+
const FIELD_MODE_STORAGE_KEY = 'appkit_form_field_mode';
|
|
73
|
+
const FIELD_MODE_CHANGE_EVENT = 'appkit:form-field-mode-change';
|
|
74
|
+
function readFieldModePreference() {
|
|
75
|
+
try {
|
|
76
|
+
return localStorage.getItem(FIELD_MODE_STORAGE_KEY) === '1';
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function subscribeFieldModePreference(onChange) {
|
|
83
|
+
const onStorage = (event) => {
|
|
84
|
+
if (event.key === FIELD_MODE_STORAGE_KEY)
|
|
85
|
+
onChange();
|
|
86
|
+
};
|
|
87
|
+
window.addEventListener('storage', onStorage);
|
|
88
|
+
window.addEventListener(FIELD_MODE_CHANGE_EVENT, onChange);
|
|
89
|
+
return () => {
|
|
90
|
+
window.removeEventListener('storage', onStorage);
|
|
91
|
+
window.removeEventListener(FIELD_MODE_CHANGE_EVENT, onChange);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Full production form runtime extracted behind application-owned persistence,
|
|
96
|
+
* hierarchy, data-source, AI, and storage adapters plus application-owned links.
|
|
97
|
+
*/
|
|
98
|
+
export function ProductionFormRenderer({ adapter, locale, labels, translateGenerated, ...input }) {
|
|
99
|
+
return (_jsx(ProductionFormRuntimeProvider, { adapter: adapter, locale: locale, labels: labels, translateGenerated: translateGenerated, children: _jsx(ProductionFormRendererBody, { ...input }) }));
|
|
100
|
+
}
|
|
101
|
+
function ProductionFormRendererBody({ templateId, templateName, version, schema, sites, people, entitiesByField: initialEntitiesByField, currentUser, initialResponseId = null, initialValues = {}, initialRows = {}, initialStepIndex = 0, initialDraftRevision = 0, isResumed = false, returnTo = null, recordsHref, readOnly = false, responseStatus = null, reviewHref = null, complianceObligationId = null, inlineAutosave = false, }) {
|
|
102
|
+
const { adapter } = useProductionFormRuntime();
|
|
103
|
+
const createDraftResponse = (input) => adapter.createDraft({ templateId: input.templateId, obligationId: input.complianceObligationId });
|
|
104
|
+
const saveFormResponseDraft = (input) => adapter.saveDraft(input);
|
|
105
|
+
const submitFormResponse = (input) => adapter.submit({
|
|
106
|
+
templateId: input.templateId,
|
|
107
|
+
data: input.data,
|
|
108
|
+
siteId: input.siteOrgUnitId,
|
|
109
|
+
obligationId: input.complianceObligationId,
|
|
110
|
+
responseId: input.responseId,
|
|
111
|
+
returnTo: input.returnTo,
|
|
112
|
+
});
|
|
113
|
+
const updateResponseField = adapter.updateField;
|
|
114
|
+
const fetchEntityAttrs = adapter.fetchEntityAttributes;
|
|
115
|
+
const tGeneratedValue = useGeneratedValueTranslations();
|
|
116
|
+
const tGenerated = useGeneratedTranslations();
|
|
117
|
+
const locale = useLocale();
|
|
118
|
+
// Per-step progress so users can click back into completed steps.
|
|
119
|
+
const [completedSteps, setCompletedSteps] = useState(new Set());
|
|
120
|
+
// Per-mount org-unit options cache shared by every org-unit picker in this
|
|
121
|
+
// render tree (see OrgUnitOptionsCacheContext).
|
|
122
|
+
const [orgUnitOptionsCache] = useState(() => ({}));
|
|
123
|
+
const [stepIndex, setStepIndex] = useState(initialStepIndex);
|
|
124
|
+
// Presentational tabs (single-step apps only). activeTabId drives which tab's
|
|
125
|
+
// sections render; validation still spans every tab.
|
|
126
|
+
const appTabs = schema.tabs ?? [];
|
|
127
|
+
const [activeTabId, setActiveTabId] = useState(appTabs[0]?.id ?? '');
|
|
128
|
+
const normalizedInitial = useMemo(() => normalizeFormResponseDraftData(schema, initialValues, initialRows), [schema, initialValues, initialRows]);
|
|
129
|
+
const [values, setValues] = useState(normalizedInitial.values);
|
|
130
|
+
const [rowsByStep, setRowsByStep] = useState(normalizedInitial.rows);
|
|
131
|
+
const [siteId, setSiteId] = useState('');
|
|
132
|
+
const [errors, setErrors] = useState(new Map());
|
|
133
|
+
const [serverError, setServerError] = useState(null);
|
|
134
|
+
const [pending, start] = useTransition();
|
|
135
|
+
// High-contrast, large-type "field mode" for direct sunlight. Treat browser
|
|
136
|
+
// storage as the external source of truth so tabs stay synchronized without
|
|
137
|
+
// a set-state-on-mount effect.
|
|
138
|
+
const fieldMode = useSyncExternalStore(subscribeFieldModePreference, readFieldModePreference, () => false);
|
|
139
|
+
const appliedDefaults = useRef(new Set());
|
|
140
|
+
const toggleFieldMode = useCallback(() => {
|
|
141
|
+
try {
|
|
142
|
+
localStorage.setItem(FIELD_MODE_STORAGE_KEY, readFieldModePreference() ? '0' : '1');
|
|
143
|
+
window.dispatchEvent(new Event(FIELD_MODE_CHANGE_EVENT));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* storage unavailable — leave the preference unchanged */
|
|
147
|
+
}
|
|
148
|
+
}, []);
|
|
149
|
+
// --- Autosave state -------------------------------------------------------
|
|
150
|
+
//
|
|
151
|
+
// `responseId` is the row we're writing against on each draft save. It's
|
|
152
|
+
// null until the user makes a content change AND `createDraftResponse`
|
|
153
|
+
// returns — see the dirty-tracking effect below. Once non-null, every
|
|
154
|
+
// subsequent save updates the same row.
|
|
155
|
+
const [responseId, setResponseId] = useState(initialResponseId);
|
|
156
|
+
// 'idle' before any change; 'pending' during in-flight save; 'saved' on
|
|
157
|
+
// success; 'error' on failure. Drives the indicator in the header.
|
|
158
|
+
const [saveStatus, setSaveStatus] = useState('idle');
|
|
159
|
+
const [lastSavedAt, setLastSavedAt] = useState(null);
|
|
160
|
+
const [saveError, setSaveError] = useState(null);
|
|
161
|
+
// Tick state so "Saved Xs ago" updates without us calling render every
|
|
162
|
+
// change. Bumped every 5s by an effect once a save has happened.
|
|
163
|
+
const [, setSavedTick] = useState(0);
|
|
164
|
+
// Tracks whether the user has actually interacted and gives each local edit
|
|
165
|
+
// a monotonic sequence. State keeps field event handlers ref-free; the
|
|
166
|
+
// unload path reads the mirrored latestRef below.
|
|
167
|
+
const [draftEditState, setDraftEditState] = useState({ dirty: false, sequence: 0 });
|
|
168
|
+
// Every caller awaits the same lazy-create request, so a second edit that
|
|
169
|
+
// arrives before the first round-trip is not dropped.
|
|
170
|
+
const creatingRef = useRef(null);
|
|
171
|
+
// Whole-draft saves are ordered independently of network completion order.
|
|
172
|
+
// The session id distinguishes tabs; the edit sequence distinguishes local
|
|
173
|
+
// payload snapshots; the revision detects a stale tab taking over.
|
|
174
|
+
const draftSessionIdRef = useRef(null);
|
|
175
|
+
const acknowledgedSequenceRef = useRef(0);
|
|
176
|
+
const draftRevisionRef = useRef(initialDraftRevision);
|
|
177
|
+
// The latest values + rows + stepIndex, captured in a ref so the
|
|
178
|
+
// beforeunload handler can read them synchronously without re-binding.
|
|
179
|
+
const latestRef = useRef({
|
|
180
|
+
values: normalizedInitial.values,
|
|
181
|
+
rows: normalizedInitial.rows,
|
|
182
|
+
stepIndex: initialStepIndex,
|
|
183
|
+
responseId: initialResponseId,
|
|
184
|
+
dirty: false,
|
|
185
|
+
editSequence: 0,
|
|
186
|
+
});
|
|
187
|
+
// Per-picker entity attribute maps, fed into the evaluator on every render
|
|
188
|
+
// so `entity_attr` formula fields stay live. Refreshed via the
|
|
189
|
+
// fetchEntityAttrs server action whenever a picker's value changes.
|
|
190
|
+
const [entitiesByField, setEntitiesByField] = useState(initialEntitiesByField);
|
|
191
|
+
// Picker field ids currently mid-flight to the fetchEntityAttrs action.
|
|
192
|
+
// Drives the small "Looking up…" indicator next to the picker.
|
|
193
|
+
const [pickerLoading, setPickerLoading] = useState(new Set());
|
|
194
|
+
// Entity lookups run through a small state-backed queue. Replacing a queued
|
|
195
|
+
// field selection cancels the effect handling the old value, so a slower old
|
|
196
|
+
// response can never overwrite attributes for the current selection.
|
|
197
|
+
const [pickerLookupQueue, setPickerLookupQueue] = useState([]);
|
|
198
|
+
// Group sections by their workflow step. Each step is a "page". Sections
|
|
199
|
+
// without an explicit `step` fall into the first workflow step.
|
|
200
|
+
const sectionsByStep = useMemo(() => {
|
|
201
|
+
const map = new Map();
|
|
202
|
+
const defaultStepKey = schema.workflow.steps[0]?.key ?? 'submit';
|
|
203
|
+
for (const sec of schema.sections) {
|
|
204
|
+
const k = sec.step ?? defaultStepKey;
|
|
205
|
+
const list = map.get(k) ?? [];
|
|
206
|
+
list.push(sec);
|
|
207
|
+
map.set(k, list);
|
|
208
|
+
}
|
|
209
|
+
return map;
|
|
210
|
+
}, [schema]);
|
|
211
|
+
// The list of *visible* steps, computed from the workflow steps that
|
|
212
|
+
// actually have sections bound to them. A workflow step with no sections
|
|
213
|
+
// is still rendered (so reviewers see the post-submit signature step), but
|
|
214
|
+
// its body becomes a single "ready to submit" pane.
|
|
215
|
+
const steps = schema.workflow.steps;
|
|
216
|
+
const totalSteps = steps.length;
|
|
217
|
+
const step = steps[stepIndex];
|
|
218
|
+
const stepSections = useMemo(() => sectionsByStep.get(step.key) ?? [], [sectionsByStep, step.key]);
|
|
219
|
+
// Tabs apply to single-step apps (multi-step wizards keep their own nav). We
|
|
220
|
+
// filter only the RENDERED sections — validation still spans every tab.
|
|
221
|
+
const tabbed = appTabs.length >= 2 && totalSteps === 1;
|
|
222
|
+
const renderedSections = tabbed
|
|
223
|
+
? stepSections.filter((s) => (s.tabId ?? appTabs[0].id) === activeTabId)
|
|
224
|
+
: stepSections;
|
|
225
|
+
// Build the eval context used by visibility + formula evaluation. Includes
|
|
226
|
+
// every section's rows under its section id so cross-step sum_section works,
|
|
227
|
+
// and the per-picker entity-attr maps that `entity_attr` reads from.
|
|
228
|
+
const evalCtx = useMemo(() => {
|
|
229
|
+
const requestContext = {
|
|
230
|
+
now: new Date(),
|
|
231
|
+
currentUserPersonId: currentUser.personId,
|
|
232
|
+
currentUserName: currentUser.name,
|
|
233
|
+
};
|
|
234
|
+
// Materialize per-row formula fields into the rows the
|
|
235
|
+
// evaluator sees, so `sum_section` / `avg_section` can roll up a computed
|
|
236
|
+
// column. Consistent with the read-time-projection model: computed values
|
|
237
|
+
// are never persisted (buildPayload uses the raw rows) — only derived here
|
|
238
|
+
// for evaluation + display. Pure derivation, so no setState / loop risk.
|
|
239
|
+
const materializedRows = {};
|
|
240
|
+
for (const sec of schema.sections) {
|
|
241
|
+
if (!sec.repeating)
|
|
242
|
+
continue;
|
|
243
|
+
const raw = rowsByStep[sec.id] ?? [];
|
|
244
|
+
const formulaFields = sec.fields.filter((f) => f.type === 'formula' && f.formula);
|
|
245
|
+
if (formulaFields.length === 0) {
|
|
246
|
+
materializedRows[sec.id] = raw;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
materializedRows[sec.id] = raw.map((row) => {
|
|
250
|
+
const rowCtx = {
|
|
251
|
+
values: { ...values, ...row },
|
|
252
|
+
rows: rowsByStep,
|
|
253
|
+
entities: entitiesByField,
|
|
254
|
+
requestContext,
|
|
255
|
+
};
|
|
256
|
+
const merged = { ...row };
|
|
257
|
+
for (const f of formulaFields) {
|
|
258
|
+
const computed = evaluateFormulaTree(f.formula, rowCtx);
|
|
259
|
+
if (computed !== null && computed !== undefined)
|
|
260
|
+
merged[f.id] = computed;
|
|
261
|
+
}
|
|
262
|
+
return merged;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// Carry through any rows for sections not in the schema (defensive).
|
|
266
|
+
for (const [k, v] of Object.entries(rowsByStep)) {
|
|
267
|
+
if (!(k in materializedRows))
|
|
268
|
+
materializedRows[k] = v;
|
|
269
|
+
}
|
|
270
|
+
return { values, rows: materializedRows, entities: entitiesByField, requestContext };
|
|
271
|
+
}, [values, rowsByStep, entitiesByField, currentUser, schema]);
|
|
272
|
+
// Apply default values on first render of a step. Tracked via a ref so we
|
|
273
|
+
// don't re-apply when the user clears the field intentionally.
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
const handle = window.setTimeout(() => {
|
|
276
|
+
setValues((current) => {
|
|
277
|
+
let mutated = null;
|
|
278
|
+
for (const sec of stepSections) {
|
|
279
|
+
// Skip repeating sections — defaults are applied per row on row add.
|
|
280
|
+
if (sec.repeating)
|
|
281
|
+
continue;
|
|
282
|
+
for (const f of sec.fields) {
|
|
283
|
+
if (!f.defaultValue)
|
|
284
|
+
continue;
|
|
285
|
+
const key = `${step.key}:${f.id}`;
|
|
286
|
+
if (appliedDefaults.current.has(key))
|
|
287
|
+
continue;
|
|
288
|
+
if (current[f.id] !== undefined && current[f.id] !== '' && current[f.id] !== null) {
|
|
289
|
+
appliedDefaults.current.add(key);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const v = resolveDefaultValue(f.defaultValue, {
|
|
293
|
+
...evalCtx,
|
|
294
|
+
values: mutated ?? current,
|
|
295
|
+
});
|
|
296
|
+
if (v !== undefined && v !== null) {
|
|
297
|
+
mutated = mutated ?? { ...current };
|
|
298
|
+
mutated[f.id] = v;
|
|
299
|
+
}
|
|
300
|
+
appliedDefaults.current.add(key);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return mutated ?? current;
|
|
304
|
+
});
|
|
305
|
+
}, 0);
|
|
306
|
+
return () => window.clearTimeout(handle);
|
|
307
|
+
}, [evalCtx, step.key, stepSections]);
|
|
308
|
+
// --- Autosave -------------------------------------------------------------
|
|
309
|
+
//
|
|
310
|
+
// Keep the latest values/rows/step in a ref so the beforeunload handler can
|
|
311
|
+
// read them synchronously when the browser is tearing down.
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
latestRef.current = {
|
|
314
|
+
values,
|
|
315
|
+
rows: rowsByStep,
|
|
316
|
+
stepIndex,
|
|
317
|
+
responseId,
|
|
318
|
+
dirty: draftEditState.dirty,
|
|
319
|
+
editSequence: draftEditState.sequence,
|
|
320
|
+
};
|
|
321
|
+
}, [draftEditState, values, rowsByStep, stepIndex, responseId]);
|
|
322
|
+
// Surface a one-shot toast when we resumed from a saved draft so the user
|
|
323
|
+
// understands they're not on a fresh form.
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
if (isResumed) {
|
|
326
|
+
toast.success(tGenerated('m_0bae7689763970'));
|
|
327
|
+
}
|
|
328
|
+
}, [isResumed, tGenerated]);
|
|
329
|
+
// Tick "Saved Xs ago" every 5s once a save has happened so the label
|
|
330
|
+
// stays current without re-saving.
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
if (!lastSavedAt)
|
|
333
|
+
return;
|
|
334
|
+
const t = setInterval(() => setSavedTick((n) => n + 1), 5000);
|
|
335
|
+
return () => clearInterval(t);
|
|
336
|
+
}, [lastSavedAt]);
|
|
337
|
+
// Core save helper. Centralised so the debounced effect, step navigation,
|
|
338
|
+
// and the retry-on-click path all share one implementation. Returns true
|
|
339
|
+
// on a successful save.
|
|
340
|
+
const persistDraft = useCallback(async (args) => {
|
|
341
|
+
const clientSequence = latestRef.current.editSequence;
|
|
342
|
+
const clientSessionId = getOrCreateDraftSessionId(draftSessionIdRef);
|
|
343
|
+
// Lazily create a draft row on the first save. Concurrent saves share
|
|
344
|
+
// and await the same request, then each submits its ordered snapshot.
|
|
345
|
+
let id = latestRef.current.responseId;
|
|
346
|
+
if (!id) {
|
|
347
|
+
const creation = creatingRef.current ?? createDraftResponse({ templateId, complianceObligationId });
|
|
348
|
+
creatingRef.current = creation;
|
|
349
|
+
try {
|
|
350
|
+
const res = await creation;
|
|
351
|
+
if (!res.ok) {
|
|
352
|
+
setSaveStatus('error');
|
|
353
|
+
setSaveError(res.error);
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
id = res.responseId;
|
|
357
|
+
setResponseId(id);
|
|
358
|
+
latestRef.current.responseId = id;
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
if (creatingRef.current === creation)
|
|
362
|
+
creatingRef.current = null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
setSaveStatus('pending');
|
|
366
|
+
setSaveError(null);
|
|
367
|
+
const res = await saveFormResponseDraft({
|
|
368
|
+
responseId: id,
|
|
369
|
+
values: args.values,
|
|
370
|
+
rows: args.rows,
|
|
371
|
+
stepIndex: args.stepIndex,
|
|
372
|
+
clientSessionId,
|
|
373
|
+
clientSequence,
|
|
374
|
+
baseRevision: draftRevisionRef.current,
|
|
375
|
+
});
|
|
376
|
+
if (!res.ok) {
|
|
377
|
+
if (clientSequence >= acknowledgedSequenceRef.current) {
|
|
378
|
+
setSaveStatus('error');
|
|
379
|
+
setSaveError(res.error);
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
draftRevisionRef.current = Math.max(draftRevisionRef.current, res.revision);
|
|
384
|
+
if (res.sequence >= acknowledgedSequenceRef.current) {
|
|
385
|
+
acknowledgedSequenceRef.current = res.sequence;
|
|
386
|
+
setLastSavedAt(new Date(res.savedAt));
|
|
387
|
+
if (latestRef.current.editSequence > res.sequence) {
|
|
388
|
+
setSaveStatus('pending');
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
// Clear dirty only when the acknowledged payload includes every
|
|
392
|
+
// local edit. An older response completing later must not erase a
|
|
393
|
+
// newer unsaved change.
|
|
394
|
+
setSaveStatus('saved');
|
|
395
|
+
setDraftEditState((current) => current.sequence <= res.sequence && current.dirty
|
|
396
|
+
? { ...current, dirty: false }
|
|
397
|
+
: current);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return true;
|
|
401
|
+
}, [complianceObligationId, templateId]);
|
|
402
|
+
// Debounced autosave on any values / rows / step change. The 1500ms delay
|
|
403
|
+
// is the spec; each new change cancels and reschedules. We also gate on
|
|
404
|
+
// draftEditState so the very first render (just hydrated state) doesn't trigger
|
|
405
|
+
// a no-op save.
|
|
406
|
+
useEffect(() => {
|
|
407
|
+
if (!draftEditState.dirty)
|
|
408
|
+
return;
|
|
409
|
+
const handle = setTimeout(() => {
|
|
410
|
+
void persistDraft({ values, rows: rowsByStep, stepIndex });
|
|
411
|
+
}, 1500);
|
|
412
|
+
return () => clearTimeout(handle);
|
|
413
|
+
}, [draftEditState.dirty, values, rowsByStep, stepIndex, persistDraft]);
|
|
414
|
+
// Save-on-unload. Uses navigator.sendBeacon — only this API reliably
|
|
415
|
+
// delivers a POST as the document unloads. Best-effort: failure is
|
|
416
|
+
// silent (the in-app autosave will have run on the previous keystroke).
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
function handleUnload() {
|
|
419
|
+
const { values: v, rows, stepIndex: si, responseId: rid, dirty, editSequence, } = latestRef.current;
|
|
420
|
+
if (!dirty)
|
|
421
|
+
return;
|
|
422
|
+
if (!rid)
|
|
423
|
+
return; // No draft row yet — nothing to persist to.
|
|
424
|
+
try {
|
|
425
|
+
const clientSessionId = getOrCreateDraftSessionId(draftSessionIdRef);
|
|
426
|
+
const payload = JSON.stringify({
|
|
427
|
+
responseId: rid,
|
|
428
|
+
values: v,
|
|
429
|
+
rows,
|
|
430
|
+
stepIndex: si,
|
|
431
|
+
clientSessionId,
|
|
432
|
+
clientSequence: editSequence,
|
|
433
|
+
baseRevision: draftRevisionRef.current,
|
|
434
|
+
});
|
|
435
|
+
const blob = new Blob([payload], { type: 'application/json' });
|
|
436
|
+
if (adapter.draftBeaconUrl)
|
|
437
|
+
navigator.sendBeacon?.(adapter.draftBeaconUrl, blob);
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// Swallow — there's nothing we can do mid-unload.
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
window.addEventListener('beforeunload', handleUnload);
|
|
444
|
+
// visibilitychange catches the "switched apps on mobile" case where
|
|
445
|
+
// beforeunload doesn't fire reliably.
|
|
446
|
+
function handleVisibility() {
|
|
447
|
+
if (document.visibilityState === 'hidden')
|
|
448
|
+
handleUnload();
|
|
449
|
+
}
|
|
450
|
+
document.addEventListener('visibilitychange', handleVisibility);
|
|
451
|
+
return () => {
|
|
452
|
+
window.removeEventListener('beforeunload', handleUnload);
|
|
453
|
+
document.removeEventListener('visibilitychange', handleVisibility);
|
|
454
|
+
};
|
|
455
|
+
}, [adapter.draftBeaconUrl]);
|
|
456
|
+
const markDirty = useCallback(() => {
|
|
457
|
+
// In read-only mode nothing is editable; never flip dirty so autosave +
|
|
458
|
+
// the unload beacon stay silent.
|
|
459
|
+
if (readOnly)
|
|
460
|
+
return;
|
|
461
|
+
setDraftEditState((current) => ({ dirty: true, sequence: current.sequence + 1 }));
|
|
462
|
+
}, [readOnly]);
|
|
463
|
+
// --- Helpers ---------------------------------------------------------------
|
|
464
|
+
// Top-level picker field ids that can drive entity-attribute formulas. The
|
|
465
|
+
// server independently proves the field and derives its type from the
|
|
466
|
+
// published template schema; this set only decides when the client refreshes.
|
|
467
|
+
const pickerFieldIds = useMemo(() => {
|
|
468
|
+
const ids = new Set();
|
|
469
|
+
for (const sec of schema.sections) {
|
|
470
|
+
if (sec.repeating)
|
|
471
|
+
continue;
|
|
472
|
+
for (const f of sec.fields) {
|
|
473
|
+
if (entityKindForPicker(f.type))
|
|
474
|
+
ids.add(f.id);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return ids;
|
|
478
|
+
}, [schema]);
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
const request = pickerLookupQueue[0];
|
|
481
|
+
if (!request)
|
|
482
|
+
return;
|
|
483
|
+
let active = true;
|
|
484
|
+
void fetchEntityAttrs({
|
|
485
|
+
templateId,
|
|
486
|
+
fieldId: request.fieldId,
|
|
487
|
+
entityId: request.entityId,
|
|
488
|
+
})
|
|
489
|
+
.then((res) => {
|
|
490
|
+
if (active && res.ok) {
|
|
491
|
+
setEntitiesByField((current) => ({ ...current, [request.fieldId]: res.attrs }));
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
.catch(() => {
|
|
495
|
+
// Picker selection remains usable; dependent formula fields show “—”.
|
|
496
|
+
})
|
|
497
|
+
.finally(() => {
|
|
498
|
+
if (!active)
|
|
499
|
+
return;
|
|
500
|
+
setPickerLookupQueue((current) => current[0]?.requestId === request.requestId ? current.slice(1) : current);
|
|
501
|
+
setPickerLoading((current) => {
|
|
502
|
+
const next = new Set(current);
|
|
503
|
+
next.delete(request.fieldId);
|
|
504
|
+
return next;
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
return () => {
|
|
508
|
+
active = false;
|
|
509
|
+
};
|
|
510
|
+
}, [pickerLookupQueue, templateId]);
|
|
511
|
+
const setValue = useCallback((fieldId, v) => {
|
|
512
|
+
markDirty();
|
|
513
|
+
setValues((s) => ({ ...s, [fieldId]: v }));
|
|
514
|
+
setErrors((m) => {
|
|
515
|
+
const next = new Map(m);
|
|
516
|
+
next.delete(fieldId);
|
|
517
|
+
return next;
|
|
518
|
+
});
|
|
519
|
+
// If this field is a picker, refresh its entity-attr map. We clear any
|
|
520
|
+
// stale entry immediately (so an `entity_attr` field that no longer
|
|
521
|
+
// applies stops rendering its old value) before kicking off the async
|
|
522
|
+
// fetch. Multi-pickers (`multi_person_picker` etc.) aren't supported by
|
|
523
|
+
// entity_attr — `entityKindForPicker` returns null for them.
|
|
524
|
+
if (pickerFieldIds.has(fieldId)) {
|
|
525
|
+
setEntitiesByField((m) => ({ ...m, [fieldId]: null }));
|
|
526
|
+
if (typeof v === 'string' && v.length > 0) {
|
|
527
|
+
setPickerLoading((s) => {
|
|
528
|
+
const next = new Set(s);
|
|
529
|
+
next.add(fieldId);
|
|
530
|
+
return next;
|
|
531
|
+
});
|
|
532
|
+
setPickerLookupQueue((current) => [
|
|
533
|
+
...current.filter((request) => request.fieldId !== fieldId),
|
|
534
|
+
{ fieldId, entityId: v, requestId: globalThis.crypto.randomUUID() },
|
|
535
|
+
]);
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
setPickerLookupQueue((current) => current.filter((request) => request.fieldId !== fieldId));
|
|
539
|
+
setPickerLoading((s) => {
|
|
540
|
+
if (!s.has(fieldId))
|
|
541
|
+
return s;
|
|
542
|
+
const next = new Set(s);
|
|
543
|
+
next.delete(fieldId);
|
|
544
|
+
return next;
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}, [markDirty, pickerFieldIds]);
|
|
549
|
+
function setRows(sectionId, rows) {
|
|
550
|
+
markDirty();
|
|
551
|
+
setRowsByStep((s) => ({ ...s, [sectionId]: rows }));
|
|
552
|
+
}
|
|
553
|
+
function addRow(section) {
|
|
554
|
+
const existing = rowsByStep[section.id] ?? [];
|
|
555
|
+
if (section.maxRows !== undefined && existing.length >= section.maxRows)
|
|
556
|
+
return;
|
|
557
|
+
// Apply per-field defaults to the new row.
|
|
558
|
+
const row = {};
|
|
559
|
+
for (const f of section.fields) {
|
|
560
|
+
if (!f.defaultValue)
|
|
561
|
+
continue;
|
|
562
|
+
const v = resolveDefaultValue(f.defaultValue, evalCtx);
|
|
563
|
+
if (v !== undefined && v !== null)
|
|
564
|
+
row[f.id] = v;
|
|
565
|
+
}
|
|
566
|
+
setRows(section.id, [...existing, row]);
|
|
567
|
+
}
|
|
568
|
+
function removeRow(section, idx) {
|
|
569
|
+
const rows = rowsByStep[section.id] ?? [];
|
|
570
|
+
setRows(section.id, rows.filter((_, i) => i !== idx));
|
|
571
|
+
}
|
|
572
|
+
function updateRow(section, idx, patch) {
|
|
573
|
+
const rows = rowsByStep[section.id] ?? [];
|
|
574
|
+
setRows(section.id, rows.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
|
575
|
+
}
|
|
576
|
+
// The full payload sent to the server: top-level field values plus repeating
|
|
577
|
+
// section rows merged in under the section id.
|
|
578
|
+
function buildPayload() {
|
|
579
|
+
const data = { ...values };
|
|
580
|
+
for (const [secId, rows] of Object.entries(rowsByStep)) {
|
|
581
|
+
data[secId] = rows;
|
|
582
|
+
}
|
|
583
|
+
return data;
|
|
584
|
+
}
|
|
585
|
+
// Validate everything visible in the current step. Hidden fields skip
|
|
586
|
+
// validation. Repeating sections check minRows + every visible row.
|
|
587
|
+
function validateCurrentStep() {
|
|
588
|
+
const errs = new Map();
|
|
589
|
+
for (const sec of stepSections) {
|
|
590
|
+
if (sec.showIf && !evaluateLogicRule(sec.showIf, evalCtx))
|
|
591
|
+
continue;
|
|
592
|
+
if (sec.repeating) {
|
|
593
|
+
const rows = rowsByStep[sec.id] ?? [];
|
|
594
|
+
if (sec.minRows !== undefined && rows.length < sec.minRows) {
|
|
595
|
+
errs.set(`__section_${sec.id}`, `Add at least ${sec.minRows} row${sec.minRows === 1 ? '' : 's'}`);
|
|
596
|
+
}
|
|
597
|
+
for (let i = 0; i < rows.length; i++) {
|
|
598
|
+
for (const f of sec.fields) {
|
|
599
|
+
// Per-row visibility is evaluated against the row's own values.
|
|
600
|
+
const rowCtx = { ...evalCtx, values: { ...evalCtx.values, ...rows[i] } };
|
|
601
|
+
if (f.showIf && !evaluateLogicRule(f.showIf, rowCtx))
|
|
602
|
+
continue;
|
|
603
|
+
// Computed fields are derived, never user-validated.
|
|
604
|
+
if (f.type === 'formula')
|
|
605
|
+
continue;
|
|
606
|
+
const error = validateFieldValue(f, rows[i][f.id]);
|
|
607
|
+
if (error)
|
|
608
|
+
errs.set(`${sec.id}.${i}.${f.id}`, error);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
for (const f of sec.fields) {
|
|
614
|
+
if (f.showIf && !evaluateLogicRule(f.showIf, evalCtx))
|
|
615
|
+
continue;
|
|
616
|
+
// Formula fields are auto-computed and never validated against the user.
|
|
617
|
+
if (f.type === 'formula')
|
|
618
|
+
continue;
|
|
619
|
+
const error = validateFieldValue(f, values[f.id]);
|
|
620
|
+
if (error)
|
|
621
|
+
errs.set(f.id, error);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return errs;
|
|
626
|
+
}
|
|
627
|
+
// Save-before-navigation. Best-effort and time-boxed: the user shouldn't
|
|
628
|
+
// be blocked by a slow save when they want to switch steps. If the save
|
|
629
|
+
// hasn't returned in 500ms we proceed anyway — the next debounce tick
|
|
630
|
+
// will catch up on the new step.
|
|
631
|
+
async function saveBeforeNavigation(targetStepIndex) {
|
|
632
|
+
if (!draftEditState.dirty || !responseId)
|
|
633
|
+
return;
|
|
634
|
+
const savePromise = persistDraft({
|
|
635
|
+
values,
|
|
636
|
+
rows: rowsByStep,
|
|
637
|
+
stepIndex: targetStepIndex,
|
|
638
|
+
});
|
|
639
|
+
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve('timeout'), 500));
|
|
640
|
+
const result = await Promise.race([savePromise, timeoutPromise]);
|
|
641
|
+
if (result === false) {
|
|
642
|
+
// persistDraft already set saveStatus/saveError; show a toast so the
|
|
643
|
+
// user knows their navigation succeeded but the save lagged.
|
|
644
|
+
toast.error(tGenerated('m_1d8a295c4b1d4b'));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function next() {
|
|
648
|
+
const errs = validateCurrentStep();
|
|
649
|
+
if (errs.size > 0) {
|
|
650
|
+
setErrors(errs);
|
|
651
|
+
toast.error(tGenerated('m_051735b7305b06', { value0: errs.size, value1: errs.size === 1 ? '' : 's' }));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
setErrors(new Map());
|
|
655
|
+
setCompletedSteps((s) => new Set(s).add(step.key));
|
|
656
|
+
const target = Math.min(totalSteps - 1, stepIndex + 1);
|
|
657
|
+
void saveBeforeNavigation(target);
|
|
658
|
+
setStepIndex(target);
|
|
659
|
+
}
|
|
660
|
+
function back() {
|
|
661
|
+
const target = Math.max(0, stepIndex - 1);
|
|
662
|
+
void saveBeforeNavigation(target);
|
|
663
|
+
setStepIndex(target);
|
|
664
|
+
setErrors(new Map());
|
|
665
|
+
}
|
|
666
|
+
function jumpTo(i) {
|
|
667
|
+
// Allow jumping to completed steps + the current step. Anything later is
|
|
668
|
+
// gated by per-step validation.
|
|
669
|
+
if (i === stepIndex)
|
|
670
|
+
return;
|
|
671
|
+
if (i < stepIndex || completedSteps.has(steps[i].key)) {
|
|
672
|
+
void saveBeforeNavigation(i);
|
|
673
|
+
setStepIndex(i);
|
|
674
|
+
setErrors(new Map());
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// Jump to the tab that owns a field id (so submit errors are never hidden
|
|
678
|
+
// behind an inactive tab).
|
|
679
|
+
function switchToFieldTab(fieldId) {
|
|
680
|
+
if (!tabbed || !fieldId)
|
|
681
|
+
return;
|
|
682
|
+
const owner = stepSections.find((s) => s.fields.some((f) => f.id === fieldId));
|
|
683
|
+
if (owner)
|
|
684
|
+
setActiveTabId(owner.tabId ?? appTabs[0].id);
|
|
685
|
+
}
|
|
686
|
+
function submit() {
|
|
687
|
+
setServerError(null);
|
|
688
|
+
const stepErrs = validateCurrentStep();
|
|
689
|
+
if (stepErrs.size > 0) {
|
|
690
|
+
setErrors(stepErrs);
|
|
691
|
+
switchToFieldTab(stepErrs.keys().next().value);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const payload = buildPayload();
|
|
695
|
+
// Full-form revalidation via the shared forms-core validator
|
|
696
|
+
// against the *combined* payload (so the server sees the same shape).
|
|
697
|
+
const globalErrs = validateResponse(schema, payload, 'submit');
|
|
698
|
+
if (globalErrs.length > 0) {
|
|
699
|
+
const map = new Map();
|
|
700
|
+
for (const e of globalErrs)
|
|
701
|
+
map.set(e.fieldId, e.message);
|
|
702
|
+
setErrors(map);
|
|
703
|
+
// Walk back to the first step (or tab) with an error.
|
|
704
|
+
const firstSection = globalErrs[0].sectionId;
|
|
705
|
+
if (firstSection) {
|
|
706
|
+
const ownerSec = schema.sections.find((s) => s.id === firstSection);
|
|
707
|
+
if (ownerSec) {
|
|
708
|
+
if (tabbed) {
|
|
709
|
+
setActiveTabId(ownerSec.tabId ?? appTabs[0].id);
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
const targetStepKey = ownerSec.step ?? steps[0].key;
|
|
713
|
+
const idx = steps.findIndex((s) => s.key === targetStepKey);
|
|
714
|
+
if (idx >= 0)
|
|
715
|
+
setStepIndex(idx);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
start(async () => {
|
|
722
|
+
const res = await submitFormResponse({
|
|
723
|
+
templateId,
|
|
724
|
+
complianceObligationId,
|
|
725
|
+
data: payload,
|
|
726
|
+
siteOrgUnitId: siteId || null,
|
|
727
|
+
// Pass the in-flight draft id (if any) so the server finalizes that
|
|
728
|
+
// row in-place rather than inserting a duplicate.
|
|
729
|
+
responseId,
|
|
730
|
+
returnTo,
|
|
731
|
+
});
|
|
732
|
+
if (!res.ok) {
|
|
733
|
+
if (res.errors) {
|
|
734
|
+
setErrors(new Map(res.errors.map((e) => [e.fieldId, e.message])));
|
|
735
|
+
toast.error(tGenerated('m_182296d9886284'));
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
setServerError('Submit failed');
|
|
739
|
+
toast.error(tGenerated('m_051fb158550e48'));
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
// Clear dirty so the unload-handler doesn't try to overwrite our
|
|
744
|
+
// freshly-submitted row with a stale draft payload.
|
|
745
|
+
setDraftEditState((current) => (current.dirty ? { ...current, dirty: false } : current));
|
|
746
|
+
toast.success(tGenerated('m_1de0dacba80c75'));
|
|
747
|
+
}
|
|
748
|
+
// ok-path navigates via server redirect.
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
const completion = Math.round(((stepIndex + 1) / Math.max(1, totalSteps)) * 100);
|
|
752
|
+
// --- Inline autosave mode -------------------------------------------------
|
|
753
|
+
//
|
|
754
|
+
// An opt-in body that REPLACES the wizard (no stepper, no footer, no
|
|
755
|
+
// DetailHeader — the parent page owns chrome). Renders every section as a
|
|
756
|
+
// vertical stack; each visible field saves itself via `updateResponseField`.
|
|
757
|
+
// Repeating sections + `table` fields persist their WHOLE array on any row
|
|
758
|
+
// add/remove/cell change. Computed/static elements never save.
|
|
759
|
+
if (inlineAutosave) {
|
|
760
|
+
// `initialResponseId` is always present in this mode (the parent page
|
|
761
|
+
// creates/loads the row). Fall back to live `responseId` defensively.
|
|
762
|
+
const rid = (initialResponseId ?? responseId);
|
|
763
|
+
// Persist one top-level field (scalar/array/object) by its id, validating
|
|
764
|
+
// first. Returns void; the per-field hook owns the status indicator.
|
|
765
|
+
const saveField = (field, v) => {
|
|
766
|
+
const err = validateFieldValue(field, v);
|
|
767
|
+
setErrors((m) => {
|
|
768
|
+
const next = new Map(m);
|
|
769
|
+
if (err)
|
|
770
|
+
next.set(field.id, err);
|
|
771
|
+
else
|
|
772
|
+
next.delete(field.id);
|
|
773
|
+
return next;
|
|
774
|
+
});
|
|
775
|
+
if (err)
|
|
776
|
+
return Promise.resolve({ ok: false, error: err });
|
|
777
|
+
return updateResponseField({ responseId: rid, fieldId: field.id, value: v });
|
|
778
|
+
};
|
|
779
|
+
// Persist a whole repeating-section / table array under its id (no
|
|
780
|
+
// per-field validation — submit-time still runs full validation).
|
|
781
|
+
const saveArray = (id, arr) => updateResponseField({ responseId: rid, fieldId: id, value: arr });
|
|
782
|
+
// Presentational content tabs (same model as the wizard/fill path). When
|
|
783
|
+
// the app authored 2+ tabs, render a tab bar and show only the active
|
|
784
|
+
// tab's sections. Sections with no `tabId` belong to the first tab —
|
|
785
|
+
// mirrors the wizard filter exactly.
|
|
786
|
+
const inlineTabbed = appTabs.length >= 2;
|
|
787
|
+
const inlineSections = inlineTabbed
|
|
788
|
+
? schema.sections.filter((s) => (s.tabId ?? appTabs[0].id) === activeTabId)
|
|
789
|
+
: schema.sections;
|
|
790
|
+
return (_jsx(FillReadOnlyContext.Provider, { value: readOnly, children: _jsx(OrgUnitOptionsCacheContext.Provider, { value: orgUnitOptionsCache, children: _jsxs("fieldset", { disabled: readOnly, className: "m-0 min-w-0 space-y-5 border-0 p-0", children: [_jsx(GeneratedValue, { value: inlineTabbed ? (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 border-b border-border pb-2", children: _jsx(GeneratedValue, { value: appTabs.map((t) => (_jsx("button", { type: "button", onClick: () => setActiveTabId(t.id), className: `rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors ${t.id === activeTabId
|
|
791
|
+
? 'border-primary bg-primary text-primary-fg'
|
|
792
|
+
: 'border-border bg-surface text-fg hover:border-primary hover:text-primary'}`, children: _jsx(GeneratedValue, { value: localizeText(t.title, locale, t.id) }) }, t.id))) }) })) : null }), _jsx(GeneratedValue, { value: inlineSections.map((sec) => {
|
|
793
|
+
if (sec.showIf && !evaluateLogicRule(sec.showIf, evalCtx))
|
|
794
|
+
return null;
|
|
795
|
+
return (_jsx(Section, { title: tGeneratedValue(localizeText(sec.title, locale, sec.id)), subtitle: tGeneratedValue(localizeText(sec.description, locale, sec.repeating ? 'Repeatable section' : '') || undefined), children: _jsx("div", { className: "space-y-4", children: _jsx(GeneratedValue, { value: sec.repeating ? (_jsx(RepeatingSection, { section: sec, rows: rowsByStep[sec.id] ?? [], onAdd: () => {
|
|
796
|
+
addRow(sec);
|
|
797
|
+
// addRow mutates state async; persist the resulting array.
|
|
798
|
+
const existing = rowsByStep[sec.id] ?? [];
|
|
799
|
+
if (sec.maxRows !== undefined && existing.length >= sec.maxRows)
|
|
800
|
+
return;
|
|
801
|
+
const next = {};
|
|
802
|
+
for (const f of sec.fields) {
|
|
803
|
+
if (!f.defaultValue)
|
|
804
|
+
continue;
|
|
805
|
+
const dv = resolveDefaultValue(f.defaultValue, evalCtx);
|
|
806
|
+
if (dv !== undefined && dv !== null)
|
|
807
|
+
next[f.id] = dv;
|
|
808
|
+
}
|
|
809
|
+
void saveArray(sec.id, [...existing, next]);
|
|
810
|
+
}, onRemove: (i) => {
|
|
811
|
+
removeRow(sec, i);
|
|
812
|
+
const arr = (rowsByStep[sec.id] ?? []).filter((_, idx) => idx !== i);
|
|
813
|
+
void saveArray(sec.id, arr);
|
|
814
|
+
}, onUpdate: (i, patch) => {
|
|
815
|
+
updateRow(sec, i, patch);
|
|
816
|
+
const arr = (rowsByStep[sec.id] ?? []).map((r, idx) => idx === i ? { ...r, ...patch } : r);
|
|
817
|
+
void saveArray(sec.id, arr);
|
|
818
|
+
}, people: people, evalCtx: evalCtx, errors: errors, sectionError: errors.get(`__section_${sec.id}`) ?? null })) : (sec.fields.map((f) => {
|
|
819
|
+
if (f.showIf && !evaluateLogicRule(f.showIf, evalCtx))
|
|
820
|
+
return null;
|
|
821
|
+
return (_jsx(InlineFieldRow, { field: f, value: values[f.id], onChange: (v) => setValue(f.id, v), onSetFieldValue: setValue, error: errors.get(f.id), people: people, evalCtx: evalCtx, loading: pickerLoading.has(f.id), readOnly: readOnly, saveField: saveField, saveArray: saveArray }, f.id));
|
|
822
|
+
})) }) }) }, sec.id));
|
|
823
|
+
}) })] }) }) }));
|
|
824
|
+
}
|
|
825
|
+
// Single-step apps + any read-only view render with a native DetailHeader
|
|
826
|
+
// (like the hazard/incident detail pages). Multi-step editing keeps the
|
|
827
|
+
// wizard header (with its step progress strip).
|
|
828
|
+
const recordLayout = readOnly || totalSteps === 1;
|
|
829
|
+
const reviewLink = reviewHref ? (_jsx(Link, { href: reviewHref, children: _jsxs(Button, { variant: "outline", size: "sm", children: [_jsx(Eye, { size: 14 }), " ", _jsx(GeneratedText, { id: "m_0e315ebf127b18" })] }) })) : null;
|
|
830
|
+
return (_jsx(FillReadOnlyContext.Provider, { value: readOnly, children: _jsx(OrgUnitOptionsCacheContext.Provider, { value: orgUnitOptionsCache, children: _jsxs(WizardLayout, { className: `ff-surface ${fieldMode ? 'field-mode' : ''}`, wide: readOnly, header: recordLayout ? (_jsx(DetailHeader, { back: {
|
|
831
|
+
href: returnTo ?? recordsHref,
|
|
832
|
+
label: returnTo ? 'Back to assessment' : 'Back',
|
|
833
|
+
}, title: tGeneratedValue(templateName), subtitle: tGeneratedValue(initialResponseId
|
|
834
|
+
? tGenerated('m_14c3dfbc8a08e9', {
|
|
835
|
+
value0: initialResponseId.slice(0, 8),
|
|
836
|
+
value1: version,
|
|
837
|
+
})
|
|
838
|
+
: tGenerated('m_1480a378beafd1', { value0: version })), badge: readOnly ? (responseStatus ? (_jsx(Badge, { variant: "secondary", children: _jsx(GeneratedValue, { value: responseStatus.replace(/_/g, ' ') }) })) : null) : (_jsx(SaveStatus, { status: saveStatus, lastSavedAt: lastSavedAt, error: saveError, onRetry: () => {
|
|
839
|
+
void persistDraft({ values, rows: rowsByStep, stepIndex });
|
|
840
|
+
} })), actions: _jsxs(_Fragment, { children: [_jsx(GeneratedValue, { value: !readOnly ? (_jsx("button", { type: "button", onClick: toggleFieldMode, "aria-pressed": fieldMode, title: tGenerated('m_0c388e73463aaf'), className: `inline-flex h-8 w-8 items-center justify-center rounded-full border ${fieldMode
|
|
841
|
+
? 'border-warning bg-warning-subtle text-warning'
|
|
842
|
+
: 'border-border bg-surface text-fg-muted hover:bg-bg-subtle'}`, children: _jsx(Sun, { size: 15 }) })) : null }), _jsx(GeneratedValue, { value: reviewLink })] }) })) : (_jsxs("div", { className: "space-y-3", children: [_jsxs(Link, { href: returnTo ?? recordsHref, className: "inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline", children: [_jsx(ChevronLeft, { size: 13 }), ' ', _jsx(GeneratedValue, { value: returnTo ? (_jsx(GeneratedText, { id: "m_0addbe9f7bc1a1" })) : (_jsx(GeneratedText, { id: "m_1a7cefe5a9894e" })) })] }), _jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("h1", { className: "truncate text-xl font-semibold text-fg", children: _jsx(GeneratedValue, { value: templateName }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "button", onClick: toggleFieldMode, "aria-pressed": fieldMode, title: tGenerated('m_0c388e73463aaf'), className: `inline-flex h-8 w-8 items-center justify-center rounded-full border ${fieldMode
|
|
843
|
+
? 'border-warning bg-warning-subtle text-warning'
|
|
844
|
+
: 'border-border bg-surface text-fg-muted hover:bg-bg-subtle'}`, children: _jsx(Sun, { size: 15 }) }), _jsx(SaveStatus, { status: saveStatus, lastSavedAt: lastSavedAt, error: saveError, onRetry: () => {
|
|
845
|
+
void persistDraft({ values, rows: rowsByStep, stepIndex });
|
|
846
|
+
} }), _jsxs(Badge, { variant: "outline", children: [_jsx(GeneratedText, { id: "m_1c693e59d64fb2" }), _jsx(GeneratedValue, { value: version })] }), _jsx(GeneratedValue, { value: reviewLink })] })] }), _jsx(GeneratedValue, { value: totalSteps > 1 ? (_jsxs(_Fragment, { children: [_jsx("ol", { className: "flex flex-wrap items-center gap-1 text-xs", children: _jsx(GeneratedValue, { value: steps.map((s, i) => {
|
|
847
|
+
const isCurrent = i === stepIndex;
|
|
848
|
+
const isCompleted = completedSteps.has(s.key);
|
|
849
|
+
const isClickable = i <= stepIndex || isCompleted;
|
|
850
|
+
return (_jsx("li", { children: _jsxs("button", { type: "button", disabled: !isClickable, onClick: () => jumpTo(i), className: `flex items-center gap-1.5 rounded-full border px-2.5 py-1 transition-colors ${isCurrent
|
|
851
|
+
? 'border-primary bg-primary text-primary-fg'
|
|
852
|
+
: isCompleted
|
|
853
|
+
? 'border-success bg-success-subtle text-success hover:bg-success-subtle'
|
|
854
|
+
: 'border-border bg-surface text-fg'} ${!isClickable ? 'cursor-not-allowed opacity-60' : ''}`, children: [_jsx("span", { className: `inline-flex h-4 w-4 items-center justify-center rounded-full text-[10px] font-semibold ${isCurrent
|
|
855
|
+
? 'bg-surface text-primary'
|
|
856
|
+
: isCompleted
|
|
857
|
+
? 'bg-success text-success-fg'
|
|
858
|
+
: 'bg-bg-subtle text-fg'}`, children: _jsx(GeneratedValue, { value: isCompleted && !isCurrent ? _jsx(Check, { size: 10 }) : i + 1 }) }), _jsx("span", { className: "truncate", children: _jsx(GeneratedValue, { value: localizeText(s.title, locale, s.key) }) })] }) }, s.key));
|
|
859
|
+
}) }) }), _jsx("div", { className: "h-1 overflow-hidden rounded-full bg-bg-subtle", children: _jsx("div", { className: "h-full rounded-full bg-primary transition-all", style: { width: `${Math.max(8, completion)}%` } }) })] })) : null })] })), footer: readOnly ? undefined : (_jsxs("div", { className: "space-y-2", children: [_jsx(GeneratedValue, { value: serverError ? (_jsxs(Alert, { variant: "destructive", children: [_jsx(AlertTitle, { children: _jsx(GeneratedText, { id: "m_051fb158550e48" }) }), _jsx(AlertDescription, { children: _jsx(GeneratedValue, { value: serverError }) })] })) : null }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Button, { variant: "outline", size: "lg", onClick: back, disabled: stepIndex === 0, className: "h-12 px-4", children: [_jsx(ChevronLeft, { size: 16 }), _jsx(GeneratedText, { id: "m_1a7cefe5a9894e" })] }), _jsx(GeneratedValue, { value: stepIndex < totalSteps - 1 ? (_jsxs(Button, { onClick: next, size: "lg", className: "h-12 flex-1 text-base", children: [_jsx(GeneratedText, { id: "m_08b5fa148b2af7" }), " ", _jsx(ChevronRight, { size: 16 })] })) : (_jsxs(Button, { onClick: submit, disabled: pending, size: "lg", className: "h-12 flex-1 text-base", children: [_jsx(Check, { size: 16 }), _jsx(GeneratedValue, { value: pending ? (_jsx(GeneratedText, { id: "m_00cfcb628bc131" })) : (_jsx(GeneratedText, { id: "m_09ee2ce911f04f" })) })] })) })] })] })), children: [_jsx(GeneratedValue, { value: readOnly ? (_jsxs(Alert, { variant: "warning", children: [_jsx(AlertTitle, { children: _jsx(GeneratedText, { id: "m_0cd6abb2df6fc8" }) }), _jsx(AlertDescription, { children: _jsx(GeneratedValue, { value: responseStatus ? (_jsx(GeneratedText, { id: "m_05829ac350a185", values: { value0: responseStatus.replace(/_/g, ' ') } })) : (_jsx(GeneratedText, { id: "m_171450f953a653" })) }) })] })) : null }), _jsxs("fieldset", { disabled: readOnly, className: "m-0 min-w-0 space-y-5 border-0 p-0", children: [_jsx(GeneratedValue, { value: stepIndex === 0 ? (_jsx(PremiumSection, { title: tGenerated('m_020146dd3d3d5a'), subtitle: tGenerated('m_16bca608598e31'), icon: _jsx(MapPin, { size: 20 }), tone: "teal", children: _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: _jsx(GeneratedText, { id: "m_020146dd3d3d5a" }) }), _jsxs(Select, { value: siteId, onChange: (e) => setSiteId(e.target.value), children: [_jsx("option", { value: "", children: '— select —' }), sites.map((s) => (_jsx("option", { value: s.id, children: s.name }, s.id)))] })] }) })) : null }), _jsx(GeneratedValue, { value: tabbed ? (_jsx("div", { className: "flex flex-wrap items-center gap-1.5 border-b border-border pb-2", children: _jsx(GeneratedValue, { value: appTabs.map((t) => (_jsx("button", { type: "button", onClick: () => setActiveTabId(t.id), className: `rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors ${t.id === activeTabId
|
|
860
|
+
? 'border-primary bg-primary text-primary-fg'
|
|
861
|
+
: 'border-border bg-surface text-fg hover:border-primary hover:text-primary'}`, children: _jsx(GeneratedValue, { value: localizeText(t.title, locale, t.id) }) }, t.id))) }) })) : null }), _jsx(GeneratedValue, { value: renderedSections.length === 0 ? (_jsx(PremiumSection, { title: tGeneratedValue(localizeText(step.title, locale, step.key)), icon: _jsx(ClipboardList, { size: 20 }), tone: "slate", children: _jsx("p", { className: "text-sm text-fg-muted", children: _jsx(GeneratedValue, { value: tabbed ? (_jsx(GeneratedText, { id: "m_04c0e447e102e8" })) : (_jsx(GeneratedText, { id: "m_1fa0b63118d05f" })) }) }) })) : (renderedSections.map((sec, i) => {
|
|
862
|
+
// Section-level visibility — completely hide the section if showIf
|
|
863
|
+
// is false against the current values.
|
|
864
|
+
if (sec.showIf && !evaluateLogicRule(sec.showIf, evalCtx))
|
|
865
|
+
return null;
|
|
866
|
+
return (_jsx(PremiumSection, { title: tGeneratedValue(localizeText(sec.title, locale, sec.id)), subtitle: tGeneratedValue(localizeText(sec.description, locale, sec.repeating ? 'Repeatable section' : '') || undefined), icon: _jsx(ClipboardList, { size: 20 }), tone: SECTION_TONES[i % SECTION_TONES.length], count: sec.repeating ? (rowsByStep[sec.id]?.length ?? 0) : undefined, children: _jsx("div", { className: "space-y-4", children: _jsx(GeneratedValue, { value: sec.repeating ? (_jsx(RepeatingSection, { section: sec, rows: rowsByStep[sec.id] ?? [], onAdd: () => addRow(sec), onRemove: (i) => removeRow(sec, i), onUpdate: (i, patch) => updateRow(sec, i, patch), people: people, evalCtx: evalCtx, errors: errors, sectionError: errors.get(`__section_${sec.id}`) ?? null })) : sec.canvas ? ((() => {
|
|
867
|
+
const cls = gridClass(sec.id);
|
|
868
|
+
const canvas = sec.canvas;
|
|
869
|
+
const visible = sec.fields.filter((f) => !f.showIf || evaluateLogicRule(f.showIf, evalCtx));
|
|
870
|
+
const { order, byId } = resolveCanvas(visible.map((f) => f.id), canvas.items, canvas.cols);
|
|
871
|
+
const byField = new Map(visible.map((f) => [f.id, f]));
|
|
872
|
+
return (_jsxs("div", { className: cls, children: [_jsx("style", { children: canvasCss(cls, canvas.cols, canvas.rowHeight, byId) }), _jsx(GeneratedValue, { value: order.map((id) => {
|
|
873
|
+
const f = byField.get(id);
|
|
874
|
+
return (_jsx("div", { "data-ci": id, children: _jsx(FieldRow, { field: f, value: values[f.id], onChange: (v) => setValue(f.id, v), onSetFieldValue: setValue, error: errors.get(f.id), people: people, evalCtx: evalCtx, loading: pickerLoading.has(f.id) }) }, id));
|
|
875
|
+
}) })] }));
|
|
876
|
+
})()) : sec.layout && sec.layout.columns > 1 ? ((() => {
|
|
877
|
+
const cls = gridClass(sec.id);
|
|
878
|
+
const cols = sec.layout.columns;
|
|
879
|
+
const visible = sec.fields.filter((f) => !f.showIf || evaluateLogicRule(f.showIf, evalCtx));
|
|
880
|
+
const css = columnsCss(cls, cols, visible.map((f) => ({ id: f.id, span: f.colSpan ?? cols })));
|
|
881
|
+
return (_jsxs("div", { className: cls, children: [_jsx("style", { children: css }), _jsx(GeneratedValue, { value: visible.map((f) => (_jsx("div", { "data-cs": f.id, children: _jsx(FieldRow, { field: f, value: values[f.id], onChange: (v) => setValue(f.id, v), onSetFieldValue: setValue, error: errors.get(f.id), people: people, evalCtx: evalCtx, loading: pickerLoading.has(f.id) }) }, f.id))) })] }));
|
|
882
|
+
})()) : (sec.fields.map((f) => {
|
|
883
|
+
if (f.showIf && !evaluateLogicRule(f.showIf, evalCtx))
|
|
884
|
+
return null;
|
|
885
|
+
return (_jsx(FieldRow, { field: f, value: values[f.id], onChange: (v) => setValue(f.id, v), onSetFieldValue: setValue, error: errors.get(f.id), people: people, evalCtx: evalCtx, loading: pickerLoading.has(f.id) }, f.id));
|
|
886
|
+
})) }) }) }, sec.id));
|
|
887
|
+
})) })] })] }) }) }));
|
|
888
|
+
}
|
|
889
|
+
// --- Repeating section -----------------------------------------------------
|
|
890
|
+
function RepeatingSection({ section, rows, onAdd, onRemove, onUpdate, people, evalCtx, errors, sectionError, }) {
|
|
891
|
+
const tGenerated = useGeneratedTranslations();
|
|
892
|
+
const max = section.maxRows;
|
|
893
|
+
const min = section.minRows ?? 0;
|
|
894
|
+
return (_jsxs("div", { className: "space-y-3", children: [_jsx(GeneratedValue, { value: sectionError ? (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: _jsx(GeneratedValue, { value: sectionError }) }) })) : null }), _jsx(GeneratedValue, { value: rows.length === 0 ? (_jsxs("p", { className: "text-sm text-fg-muted", children: [_jsx(GeneratedText, { id: "m_126f736e7419f7" }), _jsx(GeneratedValue, { value: min > 0 ? _jsx(GeneratedText, { id: "m_1217fe2f6bac7c", values: { value0: min } }) : '' })] })) : (rows.map((row, i) => {
|
|
895
|
+
// Per-row eval context merges the row's own values atop the global
|
|
896
|
+
// so showIf within the row can compare against its own fields.
|
|
897
|
+
const rowCtx = {
|
|
898
|
+
...evalCtx,
|
|
899
|
+
values: { ...evalCtx.values, ...row },
|
|
900
|
+
};
|
|
901
|
+
return (_jsxs("div", { className: "rounded-lg border border-border bg-bg-subtle/60 p-3", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between", children: [_jsx("div", { className: "text-xs font-semibold tracking-wide text-fg-muted uppercase", children: _jsx(GeneratedValue, { value: formatRowLabel(section, i, row) }) }), _jsx("button", { type: "button", onClick: () => onRemove(i), className: "ff-chip flex h-9 w-9 items-center justify-center rounded-md text-fg-muted hover:bg-danger-subtle hover:text-danger disabled:opacity-30", title: tGenerated('m_1a9d8d971b1edb'), disabled: rows.length <= min, children: _jsx(Trash2, { size: 16 }) })] }), _jsx("div", { className: "space-y-3", children: _jsx(GeneratedValue, { value: section.fields.map((f) => {
|
|
902
|
+
if (f.showIf && !evaluateLogicRule(f.showIf, rowCtx))
|
|
903
|
+
return null;
|
|
904
|
+
return (_jsx(FieldRow, { field: f, value: row[f.id], onChange: (v) => onUpdate(i, { [f.id]: v }), onSetFieldValue: (id, v) => onUpdate(i, { [id]: v }), error: errors.get(`${section.id}.${i}.${f.id}`), people: people, evalCtx: rowCtx }, f.id));
|
|
905
|
+
}) }) })] }, i));
|
|
906
|
+
})) }), _jsxs(Button, { variant: "outline", size: "lg", onClick: onAdd, disabled: max !== undefined && rows.length >= max, className: "ff-chip h-12 w-full border-dashed", children: [_jsx(Plus, { size: 16 }), _jsx(GeneratedText, { id: "m_1eabd71bbc0199" })] })] }));
|
|
907
|
+
}
|
|
908
|
+
// Format the section row header from the optional rowLabelTemplate.
|
|
909
|
+
// Supports `{index}`, `{index+1}`, and `{<fieldKey>}` interpolation.
|
|
910
|
+
function formatRowLabel(section, index, row) {
|
|
911
|
+
const tmpl = section.rowLabelTemplate ?? `Row {index+1}`;
|
|
912
|
+
return tmpl
|
|
913
|
+
.replace(/\{index\+1\}/g, String(index + 1))
|
|
914
|
+
.replace(/\{index\}/g, String(index))
|
|
915
|
+
.replace(/\{(\w+)\}/g, (_, key) => {
|
|
916
|
+
const v = row[key];
|
|
917
|
+
return v === undefined || v === null ? '' : String(v);
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
// --- Field row + input -----------------------------------------------------
|
|
921
|
+
function FieldRow({ field, value, onChange, error, people, evalCtx, loading, onSetFieldValue, }) {
|
|
922
|
+
const locale = useLocale();
|
|
923
|
+
const formT = useTranslations('Forms');
|
|
924
|
+
const helpText = localizeText(field.helpText, locale, '');
|
|
925
|
+
return (_jsxs("div", { className: "space-y-1", children: [_jsxs(Label, { children: [_jsx(GeneratedValue, { value: localizeText(field.label, locale, field.id) }), _jsx(GeneratedValue, { value: field.required || field.validation?.required ? (_jsx("span", { className: "text-danger", children: " *" })) : null }), _jsx(GeneratedValue, { value: loading ? (_jsx("span", { className: "ml-2 text-[10px] font-normal text-fg-muted", children: _jsx(GeneratedValue, { value: formT('lookingUp') }) })) : null })] }), _jsx(GeneratedValue, { value: helpText ? (_jsx("p", { className: "text-xs text-fg-muted", children: _jsx(GeneratedValue, { value: helpText }) })) : null }), _jsx(FieldInput, { field: field, value: value, onChange: onChange, people: people, evalCtx: evalCtx, onSetFieldValue: onSetFieldValue }), _jsx(GeneratedValue, { value: error ? (_jsx("p", { className: "text-xs text-danger", children: _jsx(GeneratedValue, { value: error }) })) : null })] }));
|
|
926
|
+
}
|
|
927
|
+
// --- Inline autosave field (LiveField parity) ------------------------------
|
|
928
|
+
//
|
|
929
|
+
// Render-only element types that hold no value and must never trigger a save
|
|
930
|
+
// in inline mode. Their FieldInput render path is purely presentational.
|
|
931
|
+
const INLINE_NON_SAVING_TYPES = new Set(['formula', 'metric', 'heading', 'paragraph', 'divider']);
|
|
932
|
+
/**
|
|
933
|
+
* Per-field autosave hook for inline mode — modelled on live-field.tsx
|
|
934
|
+
* `useAutoSave` but value-agnostic: it accepts any JSON `value: unknown` and
|
|
935
|
+
* persists it through a caller-supplied saver (which wires the right
|
|
936
|
+
* `updateResponseField` payload). Coalesces while a save is in flight and
|
|
937
|
+
* re-saves the latest value when the previous round-trip returns.
|
|
938
|
+
*/
|
|
939
|
+
// Sentinel so the very first save always runs even when the value is undefined.
|
|
940
|
+
const UNSAVED = Symbol('unsaved');
|
|
941
|
+
function useFieldAutosave(saver) {
|
|
942
|
+
const [state, setState] = useState('idle');
|
|
943
|
+
const [, start] = useTransition();
|
|
944
|
+
const latest = useRef(undefined);
|
|
945
|
+
const saved = useRef(UNSAVED);
|
|
946
|
+
const inFlight = useRef(false);
|
|
947
|
+
const saverRef = useRef(saver);
|
|
948
|
+
useEffect(() => {
|
|
949
|
+
saverRef.current = saver;
|
|
950
|
+
}, [saver]);
|
|
951
|
+
const save = useCallback((value) => {
|
|
952
|
+
latest.current = value;
|
|
953
|
+
if (inFlight.current)
|
|
954
|
+
return;
|
|
955
|
+
inFlight.current = true;
|
|
956
|
+
setState('saving');
|
|
957
|
+
start(async () => {
|
|
958
|
+
let ok = true;
|
|
959
|
+
// Drain: keep persisting until the newest value is saved (the user may
|
|
960
|
+
// have typed more while a round-trip was in flight). A loop, not
|
|
961
|
+
// recursion, so the callback never references itself before declaration.
|
|
962
|
+
while (latest.current !== saved.current) {
|
|
963
|
+
const v = latest.current;
|
|
964
|
+
try {
|
|
965
|
+
const res = await saverRef.current(v);
|
|
966
|
+
if (!res.ok) {
|
|
967
|
+
ok = false;
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
saved.current = v;
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
ok = false;
|
|
974
|
+
break;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
inFlight.current = false;
|
|
978
|
+
if (ok) {
|
|
979
|
+
setState('saved');
|
|
980
|
+
setTimeout(() => setState((s) => (s === 'saved' ? 'idle' : s)), 2000);
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
setState('error');
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
}, []);
|
|
987
|
+
return { state, save };
|
|
988
|
+
}
|
|
989
|
+
function InlineSaveDot({ state }) {
|
|
990
|
+
if (state === 'idle')
|
|
991
|
+
return null;
|
|
992
|
+
return (_jsx("span", { className: state === 'saving'
|
|
993
|
+
? 'text-[11px] font-medium text-fg-muted'
|
|
994
|
+
: state === 'saved'
|
|
995
|
+
? 'text-[11px] font-medium text-success'
|
|
996
|
+
: 'text-[11px] font-medium text-danger', children: _jsx(GeneratedValue, { value: state === 'saving' ? (_jsx(GeneratedText, { id: "m_106811f2aac664" })) : state === 'saved' ? (_jsx(GeneratedText, { id: "m_0a3bcf685192f1" })) : (_jsx(GeneratedText, { id: "m_13b78c61dbb517" })) }) }));
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Inline-mode field wrapper. Reuses the exact `FieldInput` control but
|
|
1000
|
+
* autosaves THIS field on its own: debounce ~800ms after a change + save on
|
|
1001
|
+
* blur. `table` fields persist their whole array; render-only element types
|
|
1002
|
+
* never save. The per-field status dot mirrors live-field.tsx.
|
|
1003
|
+
*/
|
|
1004
|
+
function InlineFieldRow({ field, value, onChange, onSetFieldValue, error, people, evalCtx, loading, readOnly, saveField, saveArray, }) {
|
|
1005
|
+
const locale = useLocale();
|
|
1006
|
+
const formT = useTranslations('Forms');
|
|
1007
|
+
const helpText = localizeText(field.helpText, locale, '');
|
|
1008
|
+
const nonSaving = INLINE_NON_SAVING_TYPES.has(field.type);
|
|
1009
|
+
const isTable = field.type === 'table';
|
|
1010
|
+
const { state, save } = useFieldAutosave(useCallback((v) => (isTable ? saveArray(field.id, v) : saveField(field, v)), [isTable, field, saveField, saveArray]));
|
|
1011
|
+
const timer = useRef(null);
|
|
1012
|
+
function commit(v) {
|
|
1013
|
+
if (timer.current) {
|
|
1014
|
+
clearTimeout(timer.current);
|
|
1015
|
+
timer.current = null;
|
|
1016
|
+
}
|
|
1017
|
+
if (readOnly || nonSaving)
|
|
1018
|
+
return;
|
|
1019
|
+
save(v);
|
|
1020
|
+
}
|
|
1021
|
+
function handleChange(v) {
|
|
1022
|
+
onChange(v);
|
|
1023
|
+
if (readOnly || nonSaving)
|
|
1024
|
+
return;
|
|
1025
|
+
if (timer.current)
|
|
1026
|
+
clearTimeout(timer.current);
|
|
1027
|
+
timer.current = setTimeout(() => commit(v), 800);
|
|
1028
|
+
}
|
|
1029
|
+
return (_jsxs("div", { className: "space-y-1", onBlur: () => commit(value), children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs(Label, { children: [_jsx(GeneratedValue, { value: localizeText(field.label, locale, field.id) }), _jsx(GeneratedValue, { value: field.required || field.validation?.required ? (_jsx("span", { className: "text-danger", children: " *" })) : null }), _jsx(GeneratedValue, { value: loading ? (_jsx("span", { className: "ml-2 text-[10px] font-normal text-fg-muted", children: _jsx(GeneratedValue, { value: formT('lookingUp') }) })) : null })] }), _jsx(GeneratedValue, { value: nonSaving ? null : _jsx(InlineSaveDot, { state: state }) })] }), _jsx(GeneratedValue, { value: helpText ? (_jsx("p", { className: "text-xs text-fg-muted", children: _jsx(GeneratedValue, { value: helpText }) })) : null }), _jsx(FieldInput, { field: field, value: value, onChange: handleChange, people: people, evalCtx: evalCtx, onSetFieldValue: onSetFieldValue }), _jsx(GeneratedValue, { value: error ? (_jsx("p", { className: "text-xs text-danger", children: _jsx(GeneratedValue, { value: error }) })) : null })] }));
|
|
1030
|
+
}
|
|
1031
|
+
function FieldInput({ field, value, onChange, people, evalCtx, onSetFieldValue, }) {
|
|
1032
|
+
const tGenerated = useGeneratedTranslations();
|
|
1033
|
+
const locale = useLocale();
|
|
1034
|
+
// Formula fields are render-only: recompute the value on every render via
|
|
1035
|
+
// the evaluator and pass through to the display input. When the formula
|
|
1036
|
+
// resolves to null (e.g. an `entity_attr` whose picker is empty) we show
|
|
1037
|
+
// the optional `field.config.defaultDisplay` placeholder or an em-dash.
|
|
1038
|
+
if (field.type === 'formula' && field.formula) {
|
|
1039
|
+
const computed = evaluateFormulaTree(field.formula, evalCtx);
|
|
1040
|
+
const fallback = field.config?.defaultDisplay ?? '—';
|
|
1041
|
+
const display = computed === null || computed === undefined || computed === '' ? fallback : String(computed);
|
|
1042
|
+
return (_jsx(Input, { value: display, disabled: true, className: "bg-bg-subtle font-mono text-sm", title: tGenerated('m_030778daefe8eb') }));
|
|
1043
|
+
}
|
|
1044
|
+
switch (field.type) {
|
|
1045
|
+
case 'text':
|
|
1046
|
+
case 'email':
|
|
1047
|
+
case 'phone':
|
|
1048
|
+
case 'url':
|
|
1049
|
+
return (_jsx(Input, { value: value ?? '', onChange: (e) => onChange(e.target.value), type: field.type === 'email'
|
|
1050
|
+
? 'email'
|
|
1051
|
+
: field.type === 'phone'
|
|
1052
|
+
? 'tel'
|
|
1053
|
+
: field.type === 'url'
|
|
1054
|
+
? 'url'
|
|
1055
|
+
: 'text', inputMode: field.type === 'email'
|
|
1056
|
+
? 'email'
|
|
1057
|
+
: field.type === 'phone'
|
|
1058
|
+
? 'tel'
|
|
1059
|
+
: field.type === 'url'
|
|
1060
|
+
? 'url'
|
|
1061
|
+
: 'text', autoComplete: field.type === 'email'
|
|
1062
|
+
? 'email'
|
|
1063
|
+
: field.type === 'phone'
|
|
1064
|
+
? 'tel'
|
|
1065
|
+
: field.type === 'url'
|
|
1066
|
+
? 'url'
|
|
1067
|
+
: 'off', enterKeyHint: "next" }));
|
|
1068
|
+
case 'long_text':
|
|
1069
|
+
return (_jsx(Textarea, { rows: 3, value: value ?? '', onChange: (e) => onChange(e.target.value) }));
|
|
1070
|
+
case 'number':
|
|
1071
|
+
return _jsx(NumberStepper, { field: field, value: value, onChange: onChange });
|
|
1072
|
+
case 'rating':
|
|
1073
|
+
return _jsx(RatingButtons, { field: field, value: value, onChange: onChange });
|
|
1074
|
+
case 'slider': {
|
|
1075
|
+
const c = (field.config ?? {});
|
|
1076
|
+
const min = c.min ?? 0;
|
|
1077
|
+
const max = c.max ?? 10;
|
|
1078
|
+
const step = c.step ?? 1;
|
|
1079
|
+
const v = typeof value === 'number' ? value : min;
|
|
1080
|
+
return (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("input", { type: "range", min: min, max: max, step: step, value: v, onChange: (e) => onChange(Number(e.target.value)), className: "h-2 flex-1 cursor-pointer appearance-none rounded-full bg-bg-subtle accent-primary" }), _jsxs("span", { className: "w-12 text-right text-sm font-semibold text-fg tabular-nums", children: [_jsx(GeneratedValue, { value: v }), _jsx(GeneratedValue, { value: c.unit ? ` ${c.unit}` : '' })] })] }));
|
|
1081
|
+
}
|
|
1082
|
+
case 'gps': {
|
|
1083
|
+
const loc = value;
|
|
1084
|
+
return (_jsxs("div", { className: "space-y-1", children: [_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => {
|
|
1085
|
+
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
|
1086
|
+
toast.error(tGenerated('m_0027988a09362c'));
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
navigator.geolocation.getCurrentPosition((p) => onChange({
|
|
1090
|
+
lat: p.coords.latitude,
|
|
1091
|
+
lng: p.coords.longitude,
|
|
1092
|
+
accuracy: p.coords.accuracy,
|
|
1093
|
+
capturedAt: new Date().toISOString(),
|
|
1094
|
+
}), () => toast.error(tGenerated('m_1a66e46e2f8f21')), { enableHighAccuracy: true, timeout: 10_000 });
|
|
1095
|
+
}, children: [_jsx(MapPin, { size: 14 }), ' ', _jsx(GeneratedValue, { value: loc ? (_jsx(GeneratedText, { id: "m_0bcab596e5288c" })) : (_jsx(GeneratedText, { id: "m_07cd022cc38d60" })) })] }), _jsx(GeneratedValue, { value: loc ? (_jsxs("p", { className: "text-xs text-fg-muted", children: [_jsx(GeneratedValue, { value: loc.lat.toFixed(5) }), ",", ' ', _jsx(GeneratedValue, { value: loc.lng.toFixed(5) }), _jsx(GeneratedValue, { value: loc.accuracy ? (_jsx(GeneratedText, { id: "m_170bba2afbd664", values: { value0: Math.round(loc.accuracy) } })) : ('') })] })) : null })] }));
|
|
1096
|
+
}
|
|
1097
|
+
case 'matrix': {
|
|
1098
|
+
const c = (field.config ?? {});
|
|
1099
|
+
const rows = c.rows ?? [];
|
|
1100
|
+
const scale = c.scale ?? [];
|
|
1101
|
+
const v = value ?? {};
|
|
1102
|
+
if (rows.length === 0 || scale.length === 0) {
|
|
1103
|
+
return (_jsx("p", { className: "text-xs text-fg-muted", children: _jsx(GeneratedText, { id: "m_08ff35b4e882af" }) }));
|
|
1104
|
+
}
|
|
1105
|
+
return (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full border-collapse text-sm", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "p-1.5" }), _jsx(GeneratedValue, { value: scale.map((s) => (_jsx("th", { className: "p-1.5 text-center text-xs font-medium text-fg-muted", children: _jsx(GeneratedValue, { value: s.label }) }, s.value))) })] }) }), _jsx("tbody", { children: _jsx(GeneratedValue, { value: rows.map((r) => (_jsxs("tr", { className: "border-t border-border", children: [_jsx("td", { className: "py-1.5 pr-2 text-fg", children: _jsx(GeneratedValue, { value: r.label }) }), _jsx(GeneratedValue, { value: scale.map((s) => (_jsx("td", { className: "p-1.5 text-center", children: _jsx("input", { type: "radio", name: `${field.id}_${r.key}`, checked: v[r.key] === s.value, onChange: () => onChange({ ...v, [r.key]: s.value }) }) }, s.value))) })] }, r.key))) }) })] }) }));
|
|
1106
|
+
}
|
|
1107
|
+
case 'risk_matrix': {
|
|
1108
|
+
const current = value && typeof value === 'object' && !Array.isArray(value)
|
|
1109
|
+
? value
|
|
1110
|
+
: undefined;
|
|
1111
|
+
return (_jsx(RiskMatrixField, { label: tGenerated('m_0a8b09bc1dafbb'), likelihoodName: `${field.id}.likelihood`, severityName: `${field.id}.severity`, defaultLikelihood: typeof current?.likelihood === 'number' ? current.likelihood : undefined, defaultSeverity: typeof current?.severity === 'number' ? current.severity : undefined, onChange: ({ likelihood, severity, score, label }) => onChange(likelihood === null || severity === null || score === null
|
|
1112
|
+
? {}
|
|
1113
|
+
: { likelihood, severity, score, label: label ?? '' }) }));
|
|
1114
|
+
}
|
|
1115
|
+
case 'formula':
|
|
1116
|
+
// No formula configured — fall back to a read-only blank.
|
|
1117
|
+
return _jsx(Input, { disabled: true, placeholder: tGenerated('m_1989eb71707ad3') });
|
|
1118
|
+
case 'date':
|
|
1119
|
+
return (_jsx(Input, { type: "date", value: value ?? '', onChange: (e) => onChange(e.target.value) }));
|
|
1120
|
+
case 'datetime':
|
|
1121
|
+
return (_jsx(Input, { type: "datetime-local", value: value ?? '', onChange: (e) => onChange(e.target.value) }));
|
|
1122
|
+
case 'time':
|
|
1123
|
+
return (_jsx(Input, { type: "time", value: value ?? '', onChange: (e) => onChange(e.target.value) }));
|
|
1124
|
+
case 'select': {
|
|
1125
|
+
const opts = field.validation?.options ?? [];
|
|
1126
|
+
return (_jsxs(Select, { value: value ?? '', onChange: (e) => onChange(e.target.value), children: [_jsx("option", { value: "", children: "\u2014" }), opts.map((o) => (_jsx("option", { value: o.value, children: localizeText(o.label, locale, o.value) }, o.value)))] }));
|
|
1127
|
+
}
|
|
1128
|
+
case 'radio': {
|
|
1129
|
+
// Big tappable chips — far easier than a dropdown with gloves on.
|
|
1130
|
+
const opts = field.validation?.options ?? [];
|
|
1131
|
+
const cur = value ?? '';
|
|
1132
|
+
return (_jsx("div", { className: "grid grid-cols-1 gap-2 sm:grid-cols-2", children: _jsx(GeneratedValue, { value: opts.map((o) => {
|
|
1133
|
+
const sel = cur === o.value;
|
|
1134
|
+
return (_jsxs("button", { type: "button", "aria-pressed": sel, onClick: () => onChange(sel ? '' : o.value), className: `ff-chip flex min-h-[48px] items-center gap-2.5 rounded-md border px-4 text-left text-sm font-medium ${sel
|
|
1135
|
+
? 'border-primary bg-primary-subtle text-primary'
|
|
1136
|
+
: 'border-border bg-surface text-fg hover:bg-bg-subtle'}`, children: [_jsx("span", { className: `flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${sel ? 'border-primary' : 'border-border'}`, children: _jsx(GeneratedValue, { value: sel ? _jsx("span", { className: "h-2.5 w-2.5 rounded-full bg-primary" }) : null }) }), _jsx(GeneratedValue, { value: localizeText(o.label, locale, o.value) })] }, o.value));
|
|
1137
|
+
}) }) }));
|
|
1138
|
+
}
|
|
1139
|
+
case 'multi_select':
|
|
1140
|
+
case 'checkbox_group': {
|
|
1141
|
+
const opts = field.validation?.options ?? [];
|
|
1142
|
+
const arr = Array.isArray(value) ? value : [];
|
|
1143
|
+
return (_jsx("div", { className: "space-y-2", children: _jsx(GeneratedValue, { value: opts.map((o) => {
|
|
1144
|
+
const sel = arr.includes(o.value);
|
|
1145
|
+
return (_jsxs("button", { type: "button", "aria-pressed": sel, onClick: () => onChange(sel ? arr.filter((v) => v !== o.value) : [...arr, o.value]), className: `ff-chip flex min-h-[48px] w-full items-center gap-3 rounded-md border px-4 text-left text-sm font-medium ${sel
|
|
1146
|
+
? 'border-primary bg-primary-subtle text-primary'
|
|
1147
|
+
: 'border-border bg-surface text-fg hover:bg-bg-subtle'}`, children: [_jsx("span", { className: `flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 ${sel
|
|
1148
|
+
? 'border-primary bg-primary text-primary-fg'
|
|
1149
|
+
: 'border-border'}`, children: _jsx(GeneratedValue, { value: sel ? _jsx(Check, { size: 13 }) : null }) }), _jsx(GeneratedValue, { value: localizeText(o.label, locale, o.value) })] }, o.value));
|
|
1150
|
+
}) }) }));
|
|
1151
|
+
}
|
|
1152
|
+
case 'pass_fail_na':
|
|
1153
|
+
return (_jsx("div", { className: "grid grid-cols-3 gap-2", children: _jsx(GeneratedValue, { value: [
|
|
1154
|
+
{ v: 'pass', label: 'Pass' },
|
|
1155
|
+
{ v: 'fail', label: 'Fail' },
|
|
1156
|
+
{ v: 'n_a', label: 'N/A' },
|
|
1157
|
+
].map(({ v, label }) => {
|
|
1158
|
+
const sel = value === v;
|
|
1159
|
+
const tone = sel
|
|
1160
|
+
? v === 'pass'
|
|
1161
|
+
? 'border-success bg-success-subtle text-success'
|
|
1162
|
+
: v === 'fail'
|
|
1163
|
+
? 'border-danger bg-danger-subtle text-danger'
|
|
1164
|
+
: 'border-border bg-bg-subtle text-fg'
|
|
1165
|
+
: 'border-border bg-surface text-fg hover:bg-bg-subtle';
|
|
1166
|
+
return (_jsxs("button", { type: "button", "aria-pressed": sel, onClick: () => onChange(sel ? '' : v), className: `ff-chip flex min-h-[48px] flex-col items-center justify-center gap-0.5 rounded-md border text-sm font-semibold ${tone}`, children: [_jsx(GeneratedValue, { value: v === 'pass' ? _jsx(Check, { size: 18 }) : v === 'fail' ? _jsx(X, { size: 18 }) : null }), _jsx(GeneratedValue, { value: label })] }, v));
|
|
1167
|
+
}) }) }));
|
|
1168
|
+
case 'yes_no_comment': {
|
|
1169
|
+
const v = value ?? {};
|
|
1170
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx("div", { className: "grid grid-cols-2 gap-2", children: _jsx(GeneratedValue, { value: [
|
|
1171
|
+
{ opt: 'yes', label: 'Yes' },
|
|
1172
|
+
{ opt: 'no', label: 'No' },
|
|
1173
|
+
].map(({ opt, label }) => {
|
|
1174
|
+
const sel = v.answer === opt;
|
|
1175
|
+
const tone = sel
|
|
1176
|
+
? opt === 'yes'
|
|
1177
|
+
? 'border-success bg-success-subtle text-success'
|
|
1178
|
+
: 'border-danger bg-danger-subtle text-danger'
|
|
1179
|
+
: 'border-border bg-surface text-fg hover:bg-bg-subtle';
|
|
1180
|
+
return (_jsxs("button", { type: "button", "aria-pressed": sel, onClick: () => onChange({ ...v, answer: opt }), className: `ff-chip flex min-h-[48px] items-center justify-center gap-2 rounded-md border text-sm font-semibold ${tone}`, children: [_jsx(GeneratedValue, { value: opt === 'yes' ? _jsx(Check, { size: 18 }) : _jsx(X, { size: 18 }) }), _jsx(GeneratedValue, { value: label })] }, opt));
|
|
1181
|
+
}) }) }), _jsx(GeneratedValue, { value: v.answer === 'no' ? (_jsx(Textarea, { rows: 2, placeholder: tGenerated('m_03a7728b77e19a'), value: v.comment ?? '', onChange: (e) => onChange({ ...v, comment: e.target.value }) })) : null })] }));
|
|
1182
|
+
}
|
|
1183
|
+
case 'traffic_light':
|
|
1184
|
+
return (_jsx("div", { className: "grid grid-cols-3 gap-2", children: _jsx(GeneratedValue, { value: [
|
|
1185
|
+
{
|
|
1186
|
+
v: 'green',
|
|
1187
|
+
label: 'Green',
|
|
1188
|
+
dot: 'bg-success',
|
|
1189
|
+
sel: 'border-success bg-success-subtle',
|
|
1190
|
+
},
|
|
1191
|
+
{
|
|
1192
|
+
v: 'yellow',
|
|
1193
|
+
label: 'Yellow',
|
|
1194
|
+
dot: 'bg-warning',
|
|
1195
|
+
sel: 'border-warning bg-warning-subtle',
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
v: 'red',
|
|
1199
|
+
label: 'Red',
|
|
1200
|
+
dot: 'bg-danger',
|
|
1201
|
+
sel: 'border-danger bg-danger-subtle',
|
|
1202
|
+
},
|
|
1203
|
+
].map((opt) => {
|
|
1204
|
+
const sel = value === opt.v;
|
|
1205
|
+
return (_jsxs("button", { type: "button", "aria-pressed": sel, onClick: () => onChange(sel ? '' : opt.v), className: `ff-chip flex min-h-[48px] flex-col items-center justify-center gap-1 rounded-md border text-sm font-medium ${sel
|
|
1206
|
+
? `${opt.sel} text-fg text-fg`
|
|
1207
|
+
: 'border-border bg-surface text-fg hover:bg-bg-subtle'}`, children: [_jsx("span", { className: `inline-block h-4 w-4 rounded-full ${opt.dot}` }), _jsx(GeneratedValue, { value: opt.label })] }, opt.v));
|
|
1208
|
+
}) }) }));
|
|
1209
|
+
case 'person_picker':
|
|
1210
|
+
return (_jsx(SearchSelect, { value: value ?? '', onChange: (v) => onChange(v), options: people.map((p) => ({
|
|
1211
|
+
value: p.id,
|
|
1212
|
+
label: `${p.lastName}, ${p.firstName}`,
|
|
1213
|
+
hint: p.employeeNo ?? undefined,
|
|
1214
|
+
})), placeholder: tGenerated('m_0be39d3a196b5b'), searchPlaceholder: tGenerated('m_0b842b664b4f3b'), sheetTitle: "Select a person", clearable: true, emptyLabel: "\u2014" }));
|
|
1215
|
+
case 'multi_person_picker': {
|
|
1216
|
+
const arr = Array.isArray(value) ? value : [];
|
|
1217
|
+
const byId = new Map(people.map((p) => [p.id, p]));
|
|
1218
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(GeneratedValue, { value: arr.length > 0 ? (_jsx("div", { className: "flex flex-wrap gap-1.5", children: _jsx(GeneratedValue, { value: arr.map((id) => {
|
|
1219
|
+
const p = byId.get(id);
|
|
1220
|
+
const label = p ? `${p.lastName}, ${p.firstName}` : id.slice(0, 8);
|
|
1221
|
+
return (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full border border-primary bg-primary-subtle py-0.5 pr-1 pl-2.5 text-xs text-primary", children: [_jsx(GeneratedValue, { value: label }), _jsx("button", { type: "button", onClick: () => onChange(arr.filter((v) => v !== id)), "aria-label": tGenerated('m_101f98a70352fa', { value0: label }), className: "rounded-full p-0.5 text-primary hover:bg-primary-subtle hover:text-primary", children: _jsx(X, { size: 12 }) })] }, id));
|
|
1222
|
+
}) }) })) : null }), _jsx(SearchSelect, { value: "", onChange: (id) => {
|
|
1223
|
+
if (id && !arr.includes(id))
|
|
1224
|
+
onChange([...arr, id]);
|
|
1225
|
+
}, options: people
|
|
1226
|
+
.filter((p) => !arr.includes(p.id))
|
|
1227
|
+
.map((p) => ({
|
|
1228
|
+
value: p.id,
|
|
1229
|
+
label: `${p.lastName}, ${p.firstName}`,
|
|
1230
|
+
hint: p.employeeNo ?? undefined,
|
|
1231
|
+
})), placeholder: tGenerated('m_0b3f2e42d2d097'), searchPlaceholder: tGenerated('m_0b842b664b4f3b'), sheetTitle: "Add a person" })] }));
|
|
1232
|
+
}
|
|
1233
|
+
case 'customer_picker':
|
|
1234
|
+
return _jsx(OrgUnitPickerInput, { level: "customer", value: value, onChange: onChange });
|
|
1235
|
+
case 'project_picker':
|
|
1236
|
+
return _jsx(OrgUnitPickerInput, { level: "project", value: value, onChange: onChange });
|
|
1237
|
+
case 'site_picker':
|
|
1238
|
+
return _jsx(OrgUnitPickerInput, { level: "site", value: value, onChange: onChange });
|
|
1239
|
+
case 'area_picker':
|
|
1240
|
+
return _jsx(OrgUnitPickerInput, { level: "area", value: value, onChange: onChange });
|
|
1241
|
+
case 'signature':
|
|
1242
|
+
return _jsx(SignatureField, { value: value ?? null, onChange: onChange });
|
|
1243
|
+
case 'sketch':
|
|
1244
|
+
return _jsx(SketchField, { value: value, onChange: onChange });
|
|
1245
|
+
case 'photo':
|
|
1246
|
+
case 'photo_upload':
|
|
1247
|
+
return (_jsx(FileUpload, { variant: "photo", value: Array.isArray(value) ? value : [], onChange: (files) => onChange(files) }));
|
|
1248
|
+
case 'photo_ai':
|
|
1249
|
+
return _jsx(PhotoAiInput, { value: value, onChange: onChange });
|
|
1250
|
+
case 'photo_annotated':
|
|
1251
|
+
return _jsx(PhotoAnnotateInput, { value: value, onChange: onChange });
|
|
1252
|
+
case 'file':
|
|
1253
|
+
return (_jsx(FileUpload, { variant: "file", value: Array.isArray(value) ? value : [], onChange: (files) => onChange(files) }));
|
|
1254
|
+
case 'video':
|
|
1255
|
+
return (_jsx(FileUpload, { variant: "video", value: Array.isArray(value) ? value : [], onChange: (files) => onChange(files) }));
|
|
1256
|
+
case 'audio':
|
|
1257
|
+
return (_jsx(FileUpload, { variant: "audio", value: Array.isArray(value) ? value : [], onChange: (files) => onChange(files) }));
|
|
1258
|
+
case 'heading':
|
|
1259
|
+
return (_jsx("h3", { className: "text-base font-semibold text-fg", children: _jsx(GeneratedValue, { value: localizeText(field.label, locale, field.id) }) }));
|
|
1260
|
+
case 'paragraph':
|
|
1261
|
+
return (_jsx("p", { className: "text-sm text-fg", children: _jsx(GeneratedValue, { value: localizeText(field.helpText ?? field.label, locale, field.id) }) }));
|
|
1262
|
+
case 'divider':
|
|
1263
|
+
return _jsx("hr", { className: "border-border" });
|
|
1264
|
+
case 'typed_attestation':
|
|
1265
|
+
return _jsx(TypedAttestationField, { field: field, value: value, onChange: onChange });
|
|
1266
|
+
case 'table':
|
|
1267
|
+
return _jsx(TableField, { field: field, value: value, onChange: onChange });
|
|
1268
|
+
case 'lookup':
|
|
1269
|
+
return (_jsx(LookupInput, { field: field, value: value, onChange: onChange, evalCtx: evalCtx, onSetFieldValue: onSetFieldValue }));
|
|
1270
|
+
case 'data_table':
|
|
1271
|
+
return _jsx(DataTableInput, { field: field, value: value, onChange: onChange, evalCtx: evalCtx });
|
|
1272
|
+
case 'metric':
|
|
1273
|
+
return _jsx(MetricBlock, { field: field, evalCtx: evalCtx });
|
|
1274
|
+
case 'qr_scanner':
|
|
1275
|
+
return _jsx(QrScannerInput, { value: value, onChange: onChange });
|
|
1276
|
+
case 'ranking':
|
|
1277
|
+
return _jsx(RankingInput, { field: field, value: value, onChange: onChange });
|
|
1278
|
+
case 'rich_text':
|
|
1279
|
+
return _jsx(RichTextInput, { value: value, onChange: onChange });
|
|
1280
|
+
case 'address':
|
|
1281
|
+
return _jsx(AddressInput, { value: value, onChange: onChange });
|
|
1282
|
+
default:
|
|
1283
|
+
return _jsx(Input, { value: value ?? '', onChange: (e) => onChange(e.target.value) });
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
// --- Number stepper + rating (big touch targets) ---------------------------
|
|
1287
|
+
// Numeric entry with −/+ steppers either side of a centred field. The keypad
|
|
1288
|
+
// is forced via inputMode='decimal' (we avoid type='number' so custom styling
|
|
1289
|
+
// and the steppers behave consistently across iOS Safari).
|
|
1290
|
+
function NumberStepper({ field, value, onChange, }) {
|
|
1291
|
+
const tGenerated = useGeneratedTranslations();
|
|
1292
|
+
const c = (field.config ?? {});
|
|
1293
|
+
const step = c.step ?? 1;
|
|
1294
|
+
const num = typeof value === 'number' ? value : value === '' || value == null ? null : Number(value);
|
|
1295
|
+
const clamp = (n) => {
|
|
1296
|
+
let v = n;
|
|
1297
|
+
if (c.min !== undefined)
|
|
1298
|
+
v = Math.max(c.min, v);
|
|
1299
|
+
if (c.max !== undefined)
|
|
1300
|
+
v = Math.min(c.max, v);
|
|
1301
|
+
return Number(v.toFixed(6));
|
|
1302
|
+
};
|
|
1303
|
+
const bump = (dir) => onChange(clamp((num ?? 0) + dir * step));
|
|
1304
|
+
const btn = 'ff-chip flex w-14 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-fg hover:bg-bg-subtle active:scale-95 disabled:opacity-40';
|
|
1305
|
+
return (_jsxs("div", { className: "flex items-stretch gap-2", children: [_jsx("button", { type: "button", className: btn, onClick: () => bump(-1), "aria-label": tGenerated('m_1bbb2531ada8ce'), children: _jsx(Minus, { size: 18 }) }), _jsxs("div", { className: "relative flex-1", children: [_jsx(Input, { type: "text", inputMode: "decimal", enterKeyHint: "next", className: "h-full text-center text-lg font-semibold", value: num ?? '', onChange: (e) => {
|
|
1306
|
+
const raw = e.target.value.replace(/[^0-9.-]/g, '');
|
|
1307
|
+
if (raw === '' || raw === '-' || raw === '.')
|
|
1308
|
+
return onChange(raw === '' ? '' : raw);
|
|
1309
|
+
const n = Number(raw);
|
|
1310
|
+
onChange(Number.isNaN(n) ? '' : n);
|
|
1311
|
+
} }), _jsx(GeneratedValue, { value: c.unit ? (_jsx("span", { className: "pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-sm text-fg-muted", children: _jsx(GeneratedValue, { value: c.unit }) })) : null })] }), _jsx("button", { type: "button", className: btn, onClick: () => bump(1), "aria-label": tGenerated('m_12a3f895c3506c'), children: _jsx(Plus, { size: 18 }) })] }));
|
|
1312
|
+
}
|
|
1313
|
+
// 1..max rating as big tappable buttons. Tap again to clear.
|
|
1314
|
+
function RatingButtons({ field, value, onChange, }) {
|
|
1315
|
+
const c = (field.config ?? {});
|
|
1316
|
+
const max = c.max ?? 5;
|
|
1317
|
+
const cur = typeof value === 'number' ? value : null;
|
|
1318
|
+
return (_jsx("div", { className: "flex flex-wrap gap-2", children: _jsx(GeneratedValue, { value: Array.from({ length: max }, (_, i) => i + 1).map((n) => {
|
|
1319
|
+
const sel = cur === n;
|
|
1320
|
+
return (_jsx("button", { type: "button", "aria-pressed": sel, onClick: () => onChange(sel ? '' : n), className: `ff-chip flex h-12 min-w-12 flex-1 items-center justify-center rounded-md border text-lg font-semibold ${sel
|
|
1321
|
+
? 'border-primary bg-primary text-primary-fg'
|
|
1322
|
+
: 'border-border bg-surface text-fg hover:bg-bg-subtle'}`, children: _jsx(GeneratedValue, { value: n }) }, n));
|
|
1323
|
+
}) }) }));
|
|
1324
|
+
}
|
|
1325
|
+
// --- Table field ------------------------------------------------------------
|
|
1326
|
+
function TableField({ field, value, onChange, }) {
|
|
1327
|
+
const tGenerated = useGeneratedTranslations();
|
|
1328
|
+
const config = (field.config ?? {});
|
|
1329
|
+
const columns = (config.columns ?? []);
|
|
1330
|
+
const rowMode = config.rowMode === 'fixed' ? 'fixed' : 'addable';
|
|
1331
|
+
const fixedRows = config.rows ?? [];
|
|
1332
|
+
const minRows = config.minRows ?? 0;
|
|
1333
|
+
const maxRows = config.maxRows;
|
|
1334
|
+
const stored = Array.isArray(value) ? value : [];
|
|
1335
|
+
// In fixed mode the row set is defined by the template; pad stored values so
|
|
1336
|
+
// every predefined row renders.
|
|
1337
|
+
const rows = rowMode === 'fixed' ? fixedRows.map((_, i) => stored[i] ?? {}) : stored;
|
|
1338
|
+
function setCell(i, key, v) {
|
|
1339
|
+
onChange(rows.map((r, idx) => (idx === i ? { ...r, [key]: v } : r)));
|
|
1340
|
+
}
|
|
1341
|
+
function addRow() {
|
|
1342
|
+
if (maxRows != null && rows.length >= maxRows)
|
|
1343
|
+
return;
|
|
1344
|
+
onChange([...rows, {}]);
|
|
1345
|
+
}
|
|
1346
|
+
function removeRow(i) {
|
|
1347
|
+
if (rows.length <= minRows)
|
|
1348
|
+
return;
|
|
1349
|
+
onChange(rows.filter((_, idx) => idx !== i));
|
|
1350
|
+
}
|
|
1351
|
+
if (columns.length === 0) {
|
|
1352
|
+
return (_jsx("p", { className: "rounded-md border border-dashed border-border px-3 py-2 text-xs text-fg-muted", children: _jsx(GeneratedText, { id: "m_0e96f06e9f8960" }) }));
|
|
1353
|
+
}
|
|
1354
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "hidden overflow-x-auto rounded-md border border-border sm:block", children: [_jsxs("table", { className: "w-full border-collapse text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "bg-bg-subtle", children: [_jsx(GeneratedValue, { value: rowMode === 'fixed' ? (_jsx("th", { className: "border-b border-border px-2 py-1.5 text-left text-xs font-semibold text-fg" })) : null }), _jsx(GeneratedValue, { value: columns.map((c) => (_jsx("th", { className: "border-b border-border px-2 py-1.5 text-left text-xs font-semibold text-fg", children: _jsx(GeneratedValue, { value: c.label || c.key }) }, c.key))) }), _jsx(GeneratedValue, { value: rowMode === 'addable' ? (_jsx("th", { className: "w-8 border-b border-border" })) : null })] }) }), _jsx("tbody", { children: _jsx(GeneratedValue, { value: rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columns.length + 1, className: "px-2 py-3 text-center text-xs text-fg-muted", children: _jsx(GeneratedText, { id: "m_1cee12b0954a84" }) }) })) : (rows.map((row, i) => (_jsxs("tr", { className: "border-b border-border last:border-b-0", children: [_jsx(GeneratedValue, { value: rowMode === 'fixed' ? (_jsx("td", { className: "px-2 py-1 text-xs font-medium whitespace-nowrap text-fg", children: _jsx(GeneratedValue, { value: fixedRows[i]?.label ?? (_jsx(GeneratedText, { id: "m_031b307596badd", values: { value0: i + 1 } })) }) })) : null }), _jsx(GeneratedValue, { value: columns.map((c) => (_jsx("td", { className: "px-1.5 py-1 align-top", children: _jsx(TableCell, { column: c, value: row[c.key], onChange: (v) => setCell(i, c.key, v) }) }, c.key))) }), _jsx(GeneratedValue, { value: rowMode === 'addable' ? (_jsx("td", { className: "px-1 py-1 text-center align-middle", children: _jsx("button", { type: "button", onClick: () => removeRow(i), disabled: rows.length <= minRows, title: tGenerated('m_12b310a027b08a'), className: "rounded p-1 text-fg-muted hover:text-danger disabled:cursor-not-allowed disabled:opacity-30", children: _jsx(Trash2, { size: 14 }) }) })) : null })] }, i)))) }) })] }), _jsx(GeneratedValue, { value: rowMode === 'addable' ? (_jsx("div", { className: "border-t border-border p-1.5", children: _jsxs("button", { type: "button", onClick: addRow, disabled: maxRows != null && rows.length >= maxRows, className: "flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary-subtle disabled:cursor-not-allowed disabled:opacity-40", children: [_jsx(Plus, { size: 13 }), " ", _jsx(GeneratedText, { id: "m_1eabd71bbc0199" })] }) })) : null })] }), _jsxs("div", { className: "space-y-3 sm:hidden", children: [_jsx(GeneratedValue, { value: rows.length === 0 ? (_jsx("p", { className: "text-sm text-fg-muted", children: _jsx(GeneratedText, { id: "m_119e08753a396f" }) })) : (rows.map((row, i) => (_jsxs("div", { className: "rounded-lg border border-border bg-bg-subtle/60 p-3", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between", children: [_jsx("span", { className: "text-xs font-semibold tracking-wide text-fg-muted uppercase", children: _jsx(GeneratedValue, { value: rowMode === 'fixed' ? ((fixedRows[i]?.label ?? (_jsx(GeneratedText, { id: "m_031b307596badd", values: { value0: i + 1 } })))) : (_jsx(GeneratedText, { id: "m_031b307596badd", values: { value0: i + 1 } })) }) }), _jsx(GeneratedValue, { value: rowMode === 'addable' ? (_jsx("button", { type: "button", onClick: () => removeRow(i), disabled: rows.length <= minRows, title: tGenerated('m_12b310a027b08a'), className: "ff-chip flex h-9 w-9 items-center justify-center rounded-md text-fg-muted hover:bg-danger-subtle hover:text-danger disabled:opacity-30", children: _jsx(Trash2, { size: 16 }) })) : null })] }), _jsx("div", { className: "space-y-2.5", children: _jsx(GeneratedValue, { value: columns.map((c) => (_jsxs("div", { className: "space-y-1", children: [_jsx("label", { className: "text-xs font-medium text-fg-muted", children: _jsx(GeneratedValue, { value: c.label || c.key }) }), _jsx(TableCell, { column: c, value: row[c.key], onChange: (v) => setCell(i, c.key, v), mobile: true })] }, c.key))) }) })] }, i)))) }), _jsx(GeneratedValue, { value: rowMode === 'addable' ? (_jsxs("button", { type: "button", onClick: addRow, disabled: maxRows != null && rows.length >= maxRows, className: "ff-chip flex min-h-[48px] w-full items-center justify-center gap-2 rounded-md border border-dashed border-border text-sm font-medium text-primary hover:bg-primary-subtle disabled:cursor-not-allowed disabled:opacity-40", children: [_jsx(Plus, { size: 16 }), " ", _jsx(GeneratedText, { id: "m_1eabd71bbc0199" })] })) : null })] })] }));
|
|
1355
|
+
}
|
|
1356
|
+
function TableCell({ column, value, onChange, mobile, }) {
|
|
1357
|
+
const sizeCls = mobile ? '' : 'h-8 text-sm';
|
|
1358
|
+
switch (column.type) {
|
|
1359
|
+
case 'number':
|
|
1360
|
+
return (_jsx(Input, { type: "text", inputMode: "decimal", className: sizeCls, value: value ?? '', onChange: (e) => {
|
|
1361
|
+
const raw = e.target.value.replace(/[^0-9.-]/g, '');
|
|
1362
|
+
onChange(raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw));
|
|
1363
|
+
} }));
|
|
1364
|
+
case 'date':
|
|
1365
|
+
return (_jsx(Input, { type: "date", className: sizeCls, value: value ?? '', onChange: (e) => onChange(e.target.value) }));
|
|
1366
|
+
case 'checkbox':
|
|
1367
|
+
return (_jsx("div", { className: `flex items-center ${mobile ? 'h-10' : 'h-8 justify-center'}`, children: _jsx("input", { type: "checkbox", checked: !!value, onChange: (e) => onChange(e.target.checked), className: "h-5 w-5" }) }));
|
|
1368
|
+
case 'select':
|
|
1369
|
+
return (_jsxs(Select, { className: sizeCls, value: value ?? '', onChange: (e) => onChange(e.target.value), children: [_jsx("option", { value: "", children: "\u2014" }), (column.options ?? []).map((o) => (_jsx("option", { value: o.value, children: o.label || o.value }, o.value)))] }));
|
|
1370
|
+
default:
|
|
1371
|
+
return (_jsx(Input, { className: sizeCls, value: value ?? '', onChange: (e) => onChange(e.target.value) }));
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
// --- Typed attestation ------------------------------------------------------
|
|
1375
|
+
function TypedAttestationField({ field, value, onChange, }) {
|
|
1376
|
+
const tGenerated = useGeneratedTranslations();
|
|
1377
|
+
const locale = useLocale();
|
|
1378
|
+
const v = (value ?? {});
|
|
1379
|
+
const statement = field.config?.statement ??
|
|
1380
|
+
(localizeText(field.helpText, locale, '') ||
|
|
1381
|
+
'I attest that the information above is true and accurate.');
|
|
1382
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(Input, { placeholder: tGenerated('m_099ccdb4b80d5d'), value: v.name ?? '', onChange: (e) => onChange({ ...v, name: e.target.value }) }), _jsxs("label", { className: "flex items-start gap-2 text-sm text-fg", children: [_jsx("input", { type: "checkbox", className: "mt-0.5", checked: !!v.agreed, onChange: (e) => onChange({ ...v, agreed: e.target.checked }) }), _jsx("span", { children: _jsx(GeneratedValue, { value: statement }) })] })] }));
|
|
1383
|
+
}
|
|
1384
|
+
// --- Field-level validation helper -----------------------------------------
|
|
1385
|
+
//
|
|
1386
|
+
// Mirrors the per-field rules from the forms-core validator so we can
|
|
1387
|
+
// show inline errors on Next-button click without re-running the full
|
|
1388
|
+
// schema-wide validator.
|
|
1389
|
+
// --- Data-bound element runtimes -------------------------------------------
|
|
1390
|
+
function labelForRow(row, labelCol) {
|
|
1391
|
+
if (labelCol && row[labelCol] != null && row[labelCol] !== '')
|
|
1392
|
+
return String(row[labelCol]);
|
|
1393
|
+
// Fall back to the first non-meta column with a value.
|
|
1394
|
+
for (const k of Object.keys(row)) {
|
|
1395
|
+
if (!k.startsWith('__') && row[k] != null && row[k] !== '')
|
|
1396
|
+
return String(row[k]);
|
|
1397
|
+
}
|
|
1398
|
+
return String(row.__rowId ?? '—');
|
|
1399
|
+
}
|
|
1400
|
+
const ORG_PICKER_COPY = {
|
|
1401
|
+
customer: { noun: 'customer', article: 'a' },
|
|
1402
|
+
project: { noun: 'project', article: 'a' },
|
|
1403
|
+
site: { noun: 'site', article: 'a' },
|
|
1404
|
+
area: { noun: 'area', article: 'an' },
|
|
1405
|
+
};
|
|
1406
|
+
function OrgUnitPickerInput({ level, value, onChange, }) {
|
|
1407
|
+
const { adapter } = useProductionFormRuntime();
|
|
1408
|
+
const tGeneratedValue = useGeneratedValueTranslations();
|
|
1409
|
+
const tGenerated = useGeneratedTranslations();
|
|
1410
|
+
const contextCache = useContext(OrgUnitOptionsCacheContext);
|
|
1411
|
+
// Defensive per-instance fallback for a render outside FormRenderer's
|
|
1412
|
+
// provider. Lazy state (not a ref) so reading it during render is legal.
|
|
1413
|
+
const [fallbackCache] = useState(() => ({}));
|
|
1414
|
+
const cache = contextCache ?? fallbackCache;
|
|
1415
|
+
const [options, setOptions] = useState(() => cache[level] ?? []);
|
|
1416
|
+
const [loading, setLoading] = useState(() => cache[level] === undefined);
|
|
1417
|
+
useEffect(() => {
|
|
1418
|
+
// Cache hit → initial state already correct; no fetch / no setState needed.
|
|
1419
|
+
if (cache[level] !== undefined)
|
|
1420
|
+
return;
|
|
1421
|
+
let alive = true;
|
|
1422
|
+
adapter.listHierarchyOptions(level)
|
|
1423
|
+
.then((rows) => {
|
|
1424
|
+
cache[level] = rows;
|
|
1425
|
+
if (alive) {
|
|
1426
|
+
setOptions(rows);
|
|
1427
|
+
setLoading(false);
|
|
1428
|
+
}
|
|
1429
|
+
})
|
|
1430
|
+
.catch(() => {
|
|
1431
|
+
if (alive)
|
|
1432
|
+
setLoading(false);
|
|
1433
|
+
});
|
|
1434
|
+
return () => {
|
|
1435
|
+
alive = false;
|
|
1436
|
+
};
|
|
1437
|
+
// `cache` is a stable per-mount object (context / lazy state) — safe dep.
|
|
1438
|
+
}, [adapter, level, cache]);
|
|
1439
|
+
const { noun, article } = ORG_PICKER_COPY[level] ?? { noun: level, article: 'a' };
|
|
1440
|
+
return (_jsx(SearchSelect, { value: value ?? '', onChange: (v) => onChange(v), options: options.map((o) => ({
|
|
1441
|
+
value: o.id,
|
|
1442
|
+
label: o.name,
|
|
1443
|
+
hint: o.code ?? undefined,
|
|
1444
|
+
})), placeholder: tGeneratedValue(loading
|
|
1445
|
+
? tGenerated('m_0e65697ec32c03')
|
|
1446
|
+
: tGenerated('m_1d3baabf618cbd', { value0: article, value1: noun })), searchPlaceholder: tGenerated('m_13a874065f07f8', { value0: noun }), sheetTitle: `Select ${article} ${noun}`, clearable: true, emptyLabel: "\u2014" }));
|
|
1447
|
+
}
|
|
1448
|
+
// A data-bound dropdown. Fetches rows from its source (optionally cascaded by a
|
|
1449
|
+
// parent field's value), and on selection can auto-fill sibling fields.
|
|
1450
|
+
function LookupInput({ field, value, onChange, evalCtx, onSetFieldValue, }) {
|
|
1451
|
+
const { adapter } = useProductionFormRuntime();
|
|
1452
|
+
const tGeneratedValue = useGeneratedValueTranslations();
|
|
1453
|
+
const tGenerated = useGeneratedTranslations();
|
|
1454
|
+
const b = field.binding;
|
|
1455
|
+
const parentVal = b?.filterByField ? evalCtx.values[b.filterByField] : undefined;
|
|
1456
|
+
const hasCascade = !!b?.filterByField && !!b?.filterColumn;
|
|
1457
|
+
const sourceKey = b?.sourceKey;
|
|
1458
|
+
const where = b?.where;
|
|
1459
|
+
const filterColumn = hasCascade ? b?.filterColumn : undefined;
|
|
1460
|
+
const filterValue = hasCascade ? parentVal : undefined;
|
|
1461
|
+
const pageSize = b?.limit ?? 50;
|
|
1462
|
+
const valueColumn = b?.valueColumn || '__rowId';
|
|
1463
|
+
const selectedValue = value == null ? '' : String(value);
|
|
1464
|
+
const waitingParent = hasCascade && (parentVal == null || parentVal === '');
|
|
1465
|
+
const [search, setSearch] = useState('');
|
|
1466
|
+
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
1467
|
+
const requestKey = JSON.stringify([
|
|
1468
|
+
sourceKey,
|
|
1469
|
+
where ?? null,
|
|
1470
|
+
filterColumn,
|
|
1471
|
+
filterValue,
|
|
1472
|
+
pageSize,
|
|
1473
|
+
valueColumn,
|
|
1474
|
+
selectedValue,
|
|
1475
|
+
debouncedSearch,
|
|
1476
|
+
]);
|
|
1477
|
+
const [queryResult, setQueryResult] = useState({
|
|
1478
|
+
key: '',
|
|
1479
|
+
result: emptyDataQueryResult(1, pageSize),
|
|
1480
|
+
error: false,
|
|
1481
|
+
});
|
|
1482
|
+
const waitingForDebounce = search.trim() !== debouncedSearch;
|
|
1483
|
+
const loading = !!sourceKey && !waitingParent && (waitingForDebounce || queryResult.key !== requestKey);
|
|
1484
|
+
const result = queryResult.key === requestKey ? queryResult.result : emptyDataQueryResult(1, pageSize);
|
|
1485
|
+
const error = queryResult.key === requestKey && queryResult.error;
|
|
1486
|
+
useEffect(() => {
|
|
1487
|
+
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 250);
|
|
1488
|
+
return () => window.clearTimeout(timer);
|
|
1489
|
+
}, [search]);
|
|
1490
|
+
useEffect(() => {
|
|
1491
|
+
if (!sourceKey || waitingParent)
|
|
1492
|
+
return;
|
|
1493
|
+
let alive = true;
|
|
1494
|
+
adapter.queryData({
|
|
1495
|
+
sourceKey,
|
|
1496
|
+
where,
|
|
1497
|
+
filterColumn,
|
|
1498
|
+
filterValue,
|
|
1499
|
+
search: debouncedSearch,
|
|
1500
|
+
pageSize,
|
|
1501
|
+
valueColumn,
|
|
1502
|
+
selectedValue,
|
|
1503
|
+
})
|
|
1504
|
+
.then((res) => {
|
|
1505
|
+
if (alive)
|
|
1506
|
+
setQueryResult({ key: requestKey, result: res, error: false });
|
|
1507
|
+
})
|
|
1508
|
+
.catch(() => {
|
|
1509
|
+
if (alive) {
|
|
1510
|
+
setQueryResult({
|
|
1511
|
+
key: requestKey,
|
|
1512
|
+
result: emptyDataQueryResult(1, pageSize),
|
|
1513
|
+
error: true,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
return () => {
|
|
1518
|
+
alive = false;
|
|
1519
|
+
};
|
|
1520
|
+
}, [
|
|
1521
|
+
debouncedSearch,
|
|
1522
|
+
filterColumn,
|
|
1523
|
+
filterValue,
|
|
1524
|
+
pageSize,
|
|
1525
|
+
requestKey,
|
|
1526
|
+
selectedValue,
|
|
1527
|
+
sourceKey,
|
|
1528
|
+
valueColumn,
|
|
1529
|
+
waitingParent,
|
|
1530
|
+
where,
|
|
1531
|
+
]);
|
|
1532
|
+
if (!sourceKey) {
|
|
1533
|
+
return (_jsx(Select, { disabled: true, children: _jsx("option", { children: 'Configure a data source…' }) }));
|
|
1534
|
+
}
|
|
1535
|
+
const rowByValue = new Map();
|
|
1536
|
+
if (result.selectedRow) {
|
|
1537
|
+
rowByValue.set(String(result.selectedRow[valueColumn] ?? ''), result.selectedRow);
|
|
1538
|
+
}
|
|
1539
|
+
if (!waitingForDebounce) {
|
|
1540
|
+
for (const row of result.rows) {
|
|
1541
|
+
const rowValue = String(row[valueColumn] ?? '');
|
|
1542
|
+
if (rowValue && !rowByValue.has(rowValue))
|
|
1543
|
+
rowByValue.set(rowValue, row);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const rows = [...rowByValue.values()];
|
|
1547
|
+
const pick = (rowVal) => {
|
|
1548
|
+
onChange(rowVal);
|
|
1549
|
+
if (onSetFieldValue && b.autofill?.length) {
|
|
1550
|
+
const row = rowByValue.get(rowVal);
|
|
1551
|
+
if (row) {
|
|
1552
|
+
for (const m of b.autofill)
|
|
1553
|
+
onSetFieldValue(m.targetFieldId, row[m.column] ?? null);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
};
|
|
1557
|
+
return (_jsx(SearchSelect, { value: selectedValue, onChange: pick, options: rows.map((row) => ({
|
|
1558
|
+
value: String(row[valueColumn] ?? ''),
|
|
1559
|
+
label: labelForRow(row, b.labelColumn),
|
|
1560
|
+
})), placeholder: tGeneratedValue(waitingParent ? tGenerated('m_071d6ce8114c46') : tGenerated('m_1129f239fbb89a')), searchPlaceholder: tGenerated('m_1d213acef86dbc'), sheetTitle: "Select a record", clearable: true, emptyLabel: "\u2014", disabled: waitingParent, searchable: true, remote: true, loading: loading, statusMessage: error
|
|
1561
|
+
? 'Could not load records. Change the search to retry.'
|
|
1562
|
+
: result.total > result.rows.length
|
|
1563
|
+
? 'More results exist. Refine your search.'
|
|
1564
|
+
: undefined, statusTone: error ? 'error' : 'muted', onSearchChange: (next) => setSearch(next.slice(0, 100)) }));
|
|
1565
|
+
}
|
|
1566
|
+
function emptyDataQueryResult(page, pageSize) {
|
|
1567
|
+
return { columns: [], rows: [], total: 0, page, pageSize, selectedRow: null };
|
|
1568
|
+
}
|
|
1569
|
+
// A data-bound table — displays (and optionally lets the user select) rows from
|
|
1570
|
+
// a source. Selection stores the chosen rows' ids in the response value.
|
|
1571
|
+
function DataTableInput({ field, value, onChange, evalCtx, }) {
|
|
1572
|
+
const { adapter } = useProductionFormRuntime();
|
|
1573
|
+
const tGeneratedValue = useGeneratedValueTranslations();
|
|
1574
|
+
const tGenerated = useGeneratedTranslations();
|
|
1575
|
+
const locale = useLocale();
|
|
1576
|
+
const formT = useTranslations('Forms');
|
|
1577
|
+
const b = field.binding;
|
|
1578
|
+
const parentVal = b?.filterByField ? evalCtx.values[b.filterByField] : undefined;
|
|
1579
|
+
const hasCascade = !!b?.filterByField && !!b?.filterColumn;
|
|
1580
|
+
const sourceKey = b?.sourceKey;
|
|
1581
|
+
const where = b?.where;
|
|
1582
|
+
const filterColumn = hasCascade ? b?.filterColumn : undefined;
|
|
1583
|
+
const filterValue = hasCascade ? parentVal : undefined;
|
|
1584
|
+
const pageSize = b?.limit ?? 25;
|
|
1585
|
+
const waitingParent = hasCascade && (parentVal == null || parentVal === '');
|
|
1586
|
+
const [search, setSearch] = useState('');
|
|
1587
|
+
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
1588
|
+
const contextKey = JSON.stringify([sourceKey, where ?? null, filterColumn, filterValue]);
|
|
1589
|
+
const [pageState, setPageState] = useState({ contextKey, page: 1 });
|
|
1590
|
+
const page = pageState.contextKey === contextKey ? pageState.page : 1;
|
|
1591
|
+
const setCurrentPage = useCallback((next) => {
|
|
1592
|
+
setPageState((current) => {
|
|
1593
|
+
const currentPage = current.contextKey === contextKey ? current.page : 1;
|
|
1594
|
+
return {
|
|
1595
|
+
contextKey,
|
|
1596
|
+
page: typeof next === 'function' ? next(currentPage) : next,
|
|
1597
|
+
};
|
|
1598
|
+
});
|
|
1599
|
+
}, [contextKey]);
|
|
1600
|
+
const requestKey = JSON.stringify([contextKey, debouncedSearch, page, pageSize]);
|
|
1601
|
+
const [queryResult, setQueryResult] = useState({ key: '', result: emptyDataQueryResult(1, pageSize), error: false });
|
|
1602
|
+
const waitingForDebounce = search.trim() !== debouncedSearch;
|
|
1603
|
+
const loading = !!sourceKey && !waitingParent && (waitingForDebounce || queryResult.key !== requestKey);
|
|
1604
|
+
const res = queryResult.key === requestKey ? queryResult.result : emptyDataQueryResult(page, pageSize);
|
|
1605
|
+
const error = queryResult.key === requestKey && queryResult.error;
|
|
1606
|
+
useEffect(() => {
|
|
1607
|
+
const timer = window.setTimeout(() => {
|
|
1608
|
+
setDebouncedSearch(search.trim());
|
|
1609
|
+
setCurrentPage(1);
|
|
1610
|
+
}, 250);
|
|
1611
|
+
return () => window.clearTimeout(timer);
|
|
1612
|
+
}, [search, setCurrentPage]);
|
|
1613
|
+
useEffect(() => {
|
|
1614
|
+
if (!sourceKey || waitingParent)
|
|
1615
|
+
return;
|
|
1616
|
+
let alive = true;
|
|
1617
|
+
adapter.queryData({
|
|
1618
|
+
sourceKey,
|
|
1619
|
+
where,
|
|
1620
|
+
filterColumn,
|
|
1621
|
+
filterValue,
|
|
1622
|
+
search: debouncedSearch,
|
|
1623
|
+
page,
|
|
1624
|
+
pageSize,
|
|
1625
|
+
})
|
|
1626
|
+
.then((r) => {
|
|
1627
|
+
if (!alive)
|
|
1628
|
+
return;
|
|
1629
|
+
const lastPage = Math.max(1, Math.ceil(r.total / r.pageSize));
|
|
1630
|
+
if (page > lastPage)
|
|
1631
|
+
setCurrentPage(lastPage);
|
|
1632
|
+
setQueryResult({ key: requestKey, result: r, error: false });
|
|
1633
|
+
})
|
|
1634
|
+
.catch(() => {
|
|
1635
|
+
if (alive) {
|
|
1636
|
+
setQueryResult({
|
|
1637
|
+
key: requestKey,
|
|
1638
|
+
result: emptyDataQueryResult(page, pageSize),
|
|
1639
|
+
error: true,
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
});
|
|
1643
|
+
return () => {
|
|
1644
|
+
alive = false;
|
|
1645
|
+
};
|
|
1646
|
+
}, [
|
|
1647
|
+
debouncedSearch,
|
|
1648
|
+
filterColumn,
|
|
1649
|
+
filterValue,
|
|
1650
|
+
page,
|
|
1651
|
+
pageSize,
|
|
1652
|
+
requestKey,
|
|
1653
|
+
setCurrentPage,
|
|
1654
|
+
sourceKey,
|
|
1655
|
+
waitingParent,
|
|
1656
|
+
where,
|
|
1657
|
+
]);
|
|
1658
|
+
if (!sourceKey)
|
|
1659
|
+
return (_jsx("p", { className: "text-xs text-fg-muted", children: _jsx(GeneratedText, { id: "m_08824e0636c702" }) }));
|
|
1660
|
+
const selectable = b.selectable ?? 'none';
|
|
1661
|
+
const showCols = b.columns?.length
|
|
1662
|
+
? res.columns.filter((c) => b.columns.includes(c.key))
|
|
1663
|
+
: res.columns.filter((c) => !c.key.startsWith('__'));
|
|
1664
|
+
const selected = Array.isArray(value) ? value : [];
|
|
1665
|
+
const toggle = (rowId) => {
|
|
1666
|
+
if (selectable === 'single')
|
|
1667
|
+
onChange([rowId]);
|
|
1668
|
+
else
|
|
1669
|
+
onChange(selected.includes(rowId) ? selected.filter((x) => x !== rowId) : [...selected, rowId]);
|
|
1670
|
+
};
|
|
1671
|
+
if (waitingParent) {
|
|
1672
|
+
return (_jsx("p", { className: "text-xs text-fg-muted", children: _jsx(GeneratedText, { id: "m_1c34ceb10770ab" }) }));
|
|
1673
|
+
}
|
|
1674
|
+
const pageCount = Math.max(1, Math.ceil(res.total / pageSize));
|
|
1675
|
+
const from = res.total === 0 ? 0 : (page - 1) * pageSize + 1;
|
|
1676
|
+
const to = Math.min(res.total, page * pageSize);
|
|
1677
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "relative max-w-sm", children: [_jsx(Search, { size: 15, className: "pointer-events-none absolute top-2.5 left-2.5 text-fg-muted" }), _jsx(Input, { type: "search", value: search, onChange: (event) => setSearch(event.target.value.slice(0, 100)), placeholder: tGenerated('m_1d213acef86dbc'), "aria-label": tGeneratedValue(formT('searchRecords', {
|
|
1678
|
+
field: localizeText(field.label, locale, 'data table'),
|
|
1679
|
+
})), className: "h-9 pr-3 pl-8" })] }), _jsxs("div", { className: "overflow-x-auto rounded border border-border", children: [_jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border bg-bg-subtle text-left text-xs text-fg-muted", children: [_jsx(GeneratedValue, { value: selectable !== 'none' ? _jsx("th", { className: "w-8 px-2 py-1.5" }) : null }), _jsx(GeneratedValue, { value: showCols.map((c) => (_jsx("th", { className: "px-2 py-1.5 font-medium", children: _jsx(GeneratedValue, { value: c.label }) }, c.key))) })] }) }), _jsx("tbody", { children: _jsx(GeneratedValue, { value: res.rows.map((r, i) => {
|
|
1680
|
+
const rowId = String(r.__rowId ?? i);
|
|
1681
|
+
const isSel = selected.includes(rowId);
|
|
1682
|
+
return (_jsxs("tr", { className: `border-b border-border ${isSel ? 'bg-primary-subtle' : ''} ${selectable !== 'none' ? 'cursor-pointer hover:bg-bg-subtle' : ''}`, onClick: selectable !== 'none' ? () => toggle(rowId) : undefined, children: [_jsx(GeneratedValue, { value: selectable !== 'none' ? (_jsx("td", { className: "px-2 py-1.5", children: _jsx("input", { type: selectable === 'single' ? 'radio' : 'checkbox', checked: isSel, "aria-label": tGenerated('m_195a8468f60956', {
|
|
1683
|
+
value0: isSel ? 'Deselect' : 'Select',
|
|
1684
|
+
value1: rowId,
|
|
1685
|
+
}), onClick: (event) => event.stopPropagation(), onChange: () => toggle(rowId) }) })) : null }), _jsx(GeneratedValue, { value: showCols.map((c) => (_jsx("td", { className: "px-2 py-1.5 text-fg", children: _jsx(GeneratedValue, { value: r[c.key] == null || r[c.key] === '' ? '—' : String(r[c.key]) }) }, c.key))) })] }, rowId));
|
|
1686
|
+
}) }) })] }), _jsx(GeneratedValue, { value: loading ? (_jsx("p", { className: "px-3 py-4 text-xs text-fg-muted", children: _jsx(GeneratedText, { id: "m_0e65697ec32c03" }) })) : null }), _jsx(GeneratedValue, { value: !loading && error ? (_jsx("p", { className: "px-3 py-4 text-xs text-danger", children: _jsx(GeneratedText, { id: "m_1fa9ced82f8b16" }) })) : null }), _jsx(GeneratedValue, { value: !loading && !error && res.rows.length === 0 ? (_jsx("p", { className: "px-3 py-4 text-xs text-fg-muted", children: _jsx(GeneratedText, { id: "m_02c65c639b006d" }) })) : null })] }), _jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2 text-xs text-fg-muted", children: [_jsx("span", { children: _jsx(GeneratedValue, { value: res.total === 0 ? (_jsx(GeneratedText, { id: "m_0c726da8b78d42" })) : (_jsx(GeneratedText, { id: "m_1f2236b17eb1ef", values: { value0: from, value1: to, value2: res.total } })) }) }), _jsx(GeneratedValue, { value: pageCount > 1 ? (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", disabled: page <= 1 || loading, onClick: () => setCurrentPage((current) => Math.max(1, current - 1)), className: "rounded border border-border px-2 py-1 disabled:opacity-40", children: _jsx(GeneratedText, { id: "m_0b628e024bdff1" }) }), _jsxs("span", { className: "px-1", children: [_jsx(GeneratedText, { id: "m_1f07a454b7b05b" }), " ", _jsx(GeneratedValue, { value: page }), ' ', _jsx(GeneratedText, { id: "m_00e704d1194796" }), " ", _jsx(GeneratedValue, { value: pageCount })] }), _jsx("button", { type: "button", disabled: page >= pageCount || loading, onClick: () => setCurrentPage((current) => Math.min(pageCount, current + 1)), className: "rounded border border-border px-2 py-1 disabled:opacity-40", children: _jsx(GeneratedText, { id: "m_08b5fa148b2af7" }) })] })) : null })] })] }));
|
|
1687
|
+
}
|
|
1688
|
+
// A display-only KPI / chart — aggregates its source server-side and renders a
|
|
1689
|
+
// single number, a bar/line chart, or a proportion list (pie).
|
|
1690
|
+
function MetricBlock({ field, evalCtx }) {
|
|
1691
|
+
const { adapter } = useProductionFormRuntime();
|
|
1692
|
+
const tGeneratedValue = useGeneratedValueTranslations();
|
|
1693
|
+
const b = field.binding;
|
|
1694
|
+
const parentVal = b?.filterByField ? evalCtx.values[b.filterByField] : undefined;
|
|
1695
|
+
const hasCascade = !!b?.filterByField && !!b?.filterColumn;
|
|
1696
|
+
const [res, setRes] = useState(null);
|
|
1697
|
+
const agg = b?.aggregate;
|
|
1698
|
+
useEffect(() => {
|
|
1699
|
+
if (!b?.sourceKey)
|
|
1700
|
+
return;
|
|
1701
|
+
let alive = true;
|
|
1702
|
+
adapter.aggregateData({
|
|
1703
|
+
sourceKey: b.sourceKey,
|
|
1704
|
+
fn: agg?.fn ?? 'count',
|
|
1705
|
+
column: agg?.column,
|
|
1706
|
+
groupBy: agg?.groupBy,
|
|
1707
|
+
where: b.where,
|
|
1708
|
+
filterColumn: hasCascade ? b.filterColumn : undefined,
|
|
1709
|
+
filterValue: hasCascade ? parentVal : undefined,
|
|
1710
|
+
groupLimit: b.limit ?? (b.display === 'pie' ? 8 : 12),
|
|
1711
|
+
})
|
|
1712
|
+
.then((r) => {
|
|
1713
|
+
if (alive)
|
|
1714
|
+
setRes(r);
|
|
1715
|
+
})
|
|
1716
|
+
.catch(() => {
|
|
1717
|
+
if (alive)
|
|
1718
|
+
setRes(null);
|
|
1719
|
+
});
|
|
1720
|
+
return () => {
|
|
1721
|
+
alive = false;
|
|
1722
|
+
};
|
|
1723
|
+
}, [
|
|
1724
|
+
agg?.column,
|
|
1725
|
+
agg?.fn,
|
|
1726
|
+
agg?.groupBy,
|
|
1727
|
+
b?.filterByField,
|
|
1728
|
+
b?.filterColumn,
|
|
1729
|
+
b?.limit,
|
|
1730
|
+
b?.display,
|
|
1731
|
+
b?.sourceKey,
|
|
1732
|
+
b?.where,
|
|
1733
|
+
hasCascade,
|
|
1734
|
+
parentVal,
|
|
1735
|
+
]);
|
|
1736
|
+
if (!b?.sourceKey)
|
|
1737
|
+
return (_jsx("p", { className: "text-xs text-fg-muted", children: _jsx(GeneratedText, { id: "m_08824e0636c702" }) }));
|
|
1738
|
+
const fmt = (n) => n == null ? '—' : Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
1739
|
+
if (res?.groups && res.groups.length > 0) {
|
|
1740
|
+
const display = b.display ?? 'bar';
|
|
1741
|
+
if (display === 'pie') {
|
|
1742
|
+
const total = res.groups.reduce((a, g) => a + g.value, 0) || 1;
|
|
1743
|
+
return (_jsx("div", { className: "space-y-1.5", children: _jsx(GeneratedValue, { value: res.groups.map((g) => (_jsxs("div", { className: "flex items-center gap-2 text-xs", children: [_jsx("span", { className: "w-28 truncate text-fg", children: _jsx(GeneratedValue, { value: g.key }) }), _jsx("div", { className: "h-2 flex-1 overflow-hidden rounded-full bg-bg-subtle", children: _jsx("div", { className: "h-full rounded-full bg-primary", style: { width: `${(g.value / total) * 100}%` } }) }), _jsx("span", { className: "w-10 text-right text-fg-muted tabular-nums", children: _jsx(GeneratedValue, { value: fmt(g.value) }) })] }, g.key))) }) }));
|
|
1744
|
+
}
|
|
1745
|
+
const max = Math.max(...res.groups.map((g) => g.value), 1);
|
|
1746
|
+
return (_jsx("div", { className: "flex items-end gap-1.5", style: { height: 120 }, children: _jsx(GeneratedValue, { value: res.groups.map((g) => (_jsxs("div", { className: "flex flex-1 flex-col items-center justify-end gap-1", children: [_jsx("div", { className: "w-full rounded-t bg-primary", style: { height: `${Math.max((g.value / max) * 100, 3)}%` }, title: tGeneratedValue(`${g.key}: ${fmt(g.value)}`) }), _jsx("span", { className: "w-full truncate text-center text-[9px] text-fg-muted", children: _jsx(GeneratedValue, { value: g.key }) })] }, g.key))) }) }));
|
|
1747
|
+
}
|
|
1748
|
+
return (_jsxs("div", { className: "inline-flex flex-col rounded-lg border border-border bg-surface px-4 py-3", children: [_jsx("span", { className: "text-3xl font-semibold text-fg tabular-nums", children: _jsx(GeneratedValue, { value: fmt(res?.value ?? null) }) }), _jsxs("span", { className: "mt-0.5 text-[10px] tracking-wide text-fg-muted uppercase", children: [_jsx(GeneratedValue, { value: agg?.fn ?? _jsx(GeneratedText, { id: "m_15f748343d3956" }) }), _jsx(GeneratedValue, { value: agg?.column ? ` · ${agg.column}` : '' }), _jsx(GeneratedValue, { value: res ? _jsx(GeneratedText, { id: "m_0e814cfafd9944", values: { value0: res.total } }) : '' })] })] }));
|
|
1749
|
+
}
|
|
1750
|
+
// --- AI photo analysis element ---------------------------------------------
|
|
1751
|
+
function riskTone(risk) {
|
|
1752
|
+
return risk === 'high'
|
|
1753
|
+
? 'bg-danger-subtle text-danger'
|
|
1754
|
+
: risk === 'medium'
|
|
1755
|
+
? 'bg-warning-subtle text-warning'
|
|
1756
|
+
: risk === 'low'
|
|
1757
|
+
? 'bg-warning-subtle text-warning'
|
|
1758
|
+
: 'bg-success-subtle text-success';
|
|
1759
|
+
}
|
|
1760
|
+
function sevTone(sev) {
|
|
1761
|
+
return sev === 'high' ? 'text-danger' : sev === 'medium' ? 'text-warning' : 'text-warning';
|
|
1762
|
+
}
|
|
1763
|
+
// Photo capture + on-demand AI safety analysis (missing PPE / hazards). Stores a
|
|
1764
|
+
// compound value { attachments, analysis?, analyzedAt? }. Editing the photos
|
|
1765
|
+
// clears a stale analysis.
|
|
1766
|
+
function PhotoAiInput({ value, onChange }) {
|
|
1767
|
+
const { adapter } = useProductionFormRuntime();
|
|
1768
|
+
const v = (value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
|
1769
|
+
const attachments = Array.isArray(v.attachments) ? v.attachments : [];
|
|
1770
|
+
const [analyzing, setAnalyzing] = useState(false);
|
|
1771
|
+
const [err, setErr] = useState(null);
|
|
1772
|
+
const a = v.analysis;
|
|
1773
|
+
const setFiles = (files) => {
|
|
1774
|
+
const unchanged = attachmentIdsEqual(attachments, files);
|
|
1775
|
+
onChange({
|
|
1776
|
+
attachments: files,
|
|
1777
|
+
analysis: unchanged ? v.analysis : undefined,
|
|
1778
|
+
analyzedAt: unchanged ? v.analyzedAt : undefined,
|
|
1779
|
+
});
|
|
1780
|
+
};
|
|
1781
|
+
const analyze = () => {
|
|
1782
|
+
if (attachments.length === 0 || analyzing)
|
|
1783
|
+
return;
|
|
1784
|
+
setErr(null);
|
|
1785
|
+
setAnalyzing(true);
|
|
1786
|
+
const analyzePhotos = adapter.analyzePhotos;
|
|
1787
|
+
if (!analyzePhotos) {
|
|
1788
|
+
setErr('Photo analysis is not configured for this form runtime.');
|
|
1789
|
+
setAnalyzing(false);
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
analyzePhotos({ attachmentIds: attachments.map((x) => x.attachmentId) })
|
|
1793
|
+
.then((res) => {
|
|
1794
|
+
if (res.ok)
|
|
1795
|
+
onChange({ attachments, analysis: res.analysis, analyzedAt: new Date().toISOString() });
|
|
1796
|
+
else
|
|
1797
|
+
setErr(res.error);
|
|
1798
|
+
})
|
|
1799
|
+
.catch(() => setErr('Analysis failed'))
|
|
1800
|
+
.finally(() => setAnalyzing(false));
|
|
1801
|
+
};
|
|
1802
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(FileUpload, { variant: "photo", value: attachments, onChange: setFiles }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: attachments.length === 0 || analyzing, onClick: analyze, children: [_jsx(Sparkles, { size: 14 }), _jsx(GeneratedValue, { value: ' ' }), _jsx(GeneratedValue, { value: analyzing ? (_jsx(GeneratedText, { id: "m_03e83706cf8b10" })) : a ? (_jsx(GeneratedText, { id: "m_0535300396f0de" })) : (_jsx(GeneratedText, { id: "m_0fec93e95858fc" })) })] }), _jsx(GeneratedValue, { value: a ? (_jsx("span", { className: `rounded-full px-2 py-0.5 text-xs font-medium ${riskTone(a.overallRisk)}`, children: _jsx(GeneratedValue, { value: a.overallRisk === 'none' ? (_jsx(GeneratedText, { id: "m_01711e3b6ef4b1" })) : (_jsx(GeneratedText, { id: "m_114202332342ab", values: { value0: a.overallRisk } })) }) })) : null })] }), _jsx(GeneratedValue, { value: err ? (_jsx("p", { className: "text-xs text-danger", children: _jsx(GeneratedValue, { value: err }) })) : null }), _jsx(GeneratedValue, { value: a ? (_jsxs("div", { className: "space-y-2 rounded-lg border border-border bg-bg-subtle/60 p-3 text-sm", children: [_jsx(GeneratedValue, { value: a.summary ? (_jsx("p", { className: "text-fg", children: _jsx(GeneratedValue, { value: a.summary }) })) : null }), _jsx(GeneratedValue, { value: a.ppe.length > 0 ? (_jsxs("div", { children: [_jsxs("div", { className: "mb-1 flex items-center gap-1 text-xs font-semibold tracking-wide text-fg-muted uppercase", children: [_jsx(ShieldCheck, { size: 12 }), " ", _jsx(GeneratedText, { id: "m_18391e161b9ed6" })] }), _jsx("ul", { className: "space-y-0.5", children: _jsx(GeneratedValue, { value: a.ppe.map((p, i) => (_jsxs("li", { className: "flex items-start gap-1.5", children: [_jsx("span", { className: p.status === 'present' ? 'text-success' : 'text-danger', children: _jsx(GeneratedValue, { value: p.status === 'present' ? '✓' : '✗' }) }), _jsxs("span", { className: "text-fg", children: [_jsx("strong", { className: "capitalize", children: _jsx(GeneratedValue, { value: p.item }) }), _jsxs("span", { className: "text-fg-muted", children: [_jsx(GeneratedValue, { value: ' ' }), "\u2014 ", _jsx(GeneratedValue, { value: p.status }), _jsx(GeneratedValue, { value: p.detail ? `: ${p.detail}` : '' })] })] })] }, i))) }) })] })) : null }), _jsx(GeneratedValue, { value: a.hazards.length > 0 ? (_jsxs("div", { children: [_jsxs("div", { className: "mb-1 flex items-center gap-1 text-xs font-semibold tracking-wide text-fg-muted uppercase", children: [_jsx(AlertTriangle, { size: 12 }), " ", _jsx(GeneratedText, { id: "m_168fba897c5202" })] }), _jsx("ul", { className: "space-y-0.5", children: _jsx(GeneratedValue, { value: a.hazards.map((h, i) => (_jsxs("li", { className: "flex items-start gap-1.5", children: [_jsx(AlertTriangle, { size: 13, className: `mt-0.5 shrink-0 ${sevTone(h.severity)}` }), _jsxs("span", { className: "text-fg", children: [_jsx("strong", { className: "capitalize", children: _jsx(GeneratedValue, { value: h.type }) }), _jsx(GeneratedValue, { value: ' ' }), _jsxs("span", { className: `text-xs uppercase ${sevTone(h.severity)}`, children: ["(", _jsx(GeneratedValue, { value: h.severity }), ")"] }), _jsxs("span", { className: "text-fg-muted", children: [' ', "\u2014 ", _jsx(GeneratedValue, { value: h.detail })] })] })] }, i))) }) })] })) : null }), _jsx(GeneratedValue, { value: a.ppe.length === 0 && a.hazards.length === 0 ? (_jsx("p", { className: "text-xs text-success", children: _jsx(GeneratedText, { id: "m_04f68a7a34cfe6" }) })) : null }), _jsx("p", { className: "text-[10px] text-fg-muted", children: _jsx(GeneratedText, { id: "m_05f4e3346f9aec" }) })] })) : null })] }));
|
|
1803
|
+
}
|
|
1804
|
+
function PhotoAnnotateInput({ value, onChange, }) {
|
|
1805
|
+
const tGenerated = useGeneratedTranslations();
|
|
1806
|
+
const v = (value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
|
1807
|
+
const attachments = Array.isArray(v.attachments) ? v.attachments : [];
|
|
1808
|
+
const markers = Array.isArray(v.markers) ? v.markers : [];
|
|
1809
|
+
const imgWrapRef = useRef(null);
|
|
1810
|
+
const photo = attachments[0];
|
|
1811
|
+
const setFiles = (files) => {
|
|
1812
|
+
const next = singlePrimaryPhoto(files);
|
|
1813
|
+
const samePrimary = attachments[0]?.attachmentId === next[0]?.attachmentId;
|
|
1814
|
+
onChange({ attachments: next, markers: samePrimary ? markers : [] });
|
|
1815
|
+
};
|
|
1816
|
+
const newId = () => globalThis.crypto?.randomUUID?.() ??
|
|
1817
|
+
`m_${markers.length}_${markers.reduce((a, m) => a + m.x, 0)}`;
|
|
1818
|
+
const addMarker = (e) => {
|
|
1819
|
+
const rect = imgWrapRef.current?.getBoundingClientRect();
|
|
1820
|
+
if (!rect || rect.width === 0)
|
|
1821
|
+
return;
|
|
1822
|
+
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
1823
|
+
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
|
1824
|
+
onChange({ attachments, markers: [...markers, { id: newId(), x, y, label: '' }] });
|
|
1825
|
+
};
|
|
1826
|
+
const setLabel = (id, label) => onChange({ attachments, markers: markers.map((m) => (m.id === id ? { ...m, label } : m)) });
|
|
1827
|
+
const removeMarker = (id) => onChange({ attachments, markers: markers.filter((m) => m.id !== id) });
|
|
1828
|
+
if (attachments.length === 0 || !photo) {
|
|
1829
|
+
return _jsx(FileUpload, { variant: "photo", value: attachments, onChange: setFiles, multiple: false });
|
|
1830
|
+
}
|
|
1831
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { ref: imgWrapRef, onClick: addMarker, className: "relative inline-block max-w-full cursor-crosshair select-none", children: [_jsx(RawImage, { src: photo.url, alt: tGenerated('m_0fe28938af7e45'), optimizationReason: "authenticated", draggable: false, className: "max-h-80 max-w-full rounded border border-border" }), _jsx(GeneratedValue, { value: markers.map((m, i) => (_jsx("span", { className: "absolute flex h-6 w-6 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-danger text-xs font-bold text-danger-fg ring-2 ring-surface", style: { left: `${m.x * 100}%`, top: `${m.y * 100}%` }, children: _jsx(GeneratedValue, { value: i + 1 }) }, m.id))) })] }), _jsx("p", { className: "text-[11px] text-fg-muted", children: _jsx(GeneratedText, { id: "m_02961df16c552c" }) }), _jsx(GeneratedValue, { value: markers.length > 0 ? (_jsx("ul", { className: "space-y-1", children: _jsx(GeneratedValue, { value: markers.map((m, i) => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-danger text-[10px] font-bold text-danger-fg", children: _jsx(GeneratedValue, { value: i + 1 }) }), _jsx(Input, { value: m.label, placeholder: tGenerated('m_16fb1210fc864b', { value0: i + 1 }), onChange: (e) => setLabel(m.id, e.target.value), className: "h-8 flex-1" }), _jsx("button", { type: "button", onClick: () => removeMarker(m.id), className: "rounded p-1 text-fg-muted hover:bg-danger-subtle hover:text-danger", title: tGenerated('m_0079038e85c06d'), children: _jsx(Trash2, { size: 14 }) })] }, m.id))) }) })) : null }), _jsx("button", { type: "button", onClick: () => setFiles([]), className: "text-xs text-fg-muted hover:underline", children: _jsx(GeneratedText, { id: "m_0b4d2076c64fbb" }) })] }));
|
|
1832
|
+
}
|
|
1833
|
+
// --- QR / barcode scanner --------------------------------------------------
|
|
1834
|
+
function QrScannerInput({ value, onChange }) {
|
|
1835
|
+
const tGenerated = useGeneratedTranslations();
|
|
1836
|
+
const [scanning, setScanning] = useState(false);
|
|
1837
|
+
const [err, setErr] = useState(null);
|
|
1838
|
+
const videoRef = useRef(null);
|
|
1839
|
+
const streamRef = useRef(null);
|
|
1840
|
+
const intervalRef = useRef(null);
|
|
1841
|
+
const stop = useCallback(() => {
|
|
1842
|
+
if (intervalRef.current) {
|
|
1843
|
+
window.clearInterval(intervalRef.current);
|
|
1844
|
+
intervalRef.current = null;
|
|
1845
|
+
}
|
|
1846
|
+
streamRef.current?.getTracks().forEach((t) => t.stop());
|
|
1847
|
+
streamRef.current = null;
|
|
1848
|
+
setScanning(false);
|
|
1849
|
+
}, []);
|
|
1850
|
+
useEffect(() => () => stop(), [stop]);
|
|
1851
|
+
const start = async () => {
|
|
1852
|
+
setErr(null);
|
|
1853
|
+
const Detector = window.BarcodeDetector;
|
|
1854
|
+
if (!Detector) {
|
|
1855
|
+
setErr('Scanning isn’t supported on this device — type the code instead.');
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
try {
|
|
1859
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
1860
|
+
video: { facingMode: 'environment' },
|
|
1861
|
+
});
|
|
1862
|
+
streamRef.current = stream;
|
|
1863
|
+
setScanning(true);
|
|
1864
|
+
const v = videoRef.current;
|
|
1865
|
+
if (v) {
|
|
1866
|
+
v.srcObject = stream;
|
|
1867
|
+
await v.play();
|
|
1868
|
+
const detector = new Detector();
|
|
1869
|
+
intervalRef.current = window.setInterval(async () => {
|
|
1870
|
+
if (!streamRef.current || !videoRef.current)
|
|
1871
|
+
return;
|
|
1872
|
+
try {
|
|
1873
|
+
const codes = await detector.detect(videoRef.current);
|
|
1874
|
+
const first = codes?.[0];
|
|
1875
|
+
if (first?.rawValue) {
|
|
1876
|
+
onChange(first.rawValue);
|
|
1877
|
+
stop();
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
catch {
|
|
1881
|
+
/* transient detect errors are fine */
|
|
1882
|
+
}
|
|
1883
|
+
}, 350);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
catch {
|
|
1887
|
+
setErr('Couldn’t access the camera — type the code instead.');
|
|
1888
|
+
setScanning(false);
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { value: value ?? '', placeholder: tGenerated('m_0a43fb83c91fdf'), onChange: (e) => onChange(e.target.value), className: "flex-1" }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: scanning ? stop : start, children: [_jsx(ScanLine, { size: 14 }), ' ', _jsx(GeneratedValue, { value: scanning ? (_jsx(GeneratedText, { id: "m_0889ad146e26ca" })) : (_jsx(GeneratedText, { id: "m_198b8dba5a829c" })) })] })] }), _jsx(GeneratedValue, { value: scanning ? (_jsx("video", { ref: videoRef, muted: true, playsInline: true, className: "w-full max-w-xs rounded border border-border" })) : null }), _jsx(GeneratedValue, { value: err ? (_jsx("p", { className: "text-xs text-warning", children: _jsx(GeneratedValue, { value: err }) })) : null })] }));
|
|
1892
|
+
}
|
|
1893
|
+
// --- Ranking (reorder options) ---------------------------------------------
|
|
1894
|
+
function RankingInput({ field, value, onChange, }) {
|
|
1895
|
+
const tGenerated = useGeneratedTranslations();
|
|
1896
|
+
const locale = useLocale();
|
|
1897
|
+
const opts = field.validation?.options ?? [];
|
|
1898
|
+
const labelOf = (v) => {
|
|
1899
|
+
const o = opts.find((x) => x.value === v);
|
|
1900
|
+
return o ? localizeText(o.label, locale, o.value) : v;
|
|
1901
|
+
};
|
|
1902
|
+
const current = Array.isArray(value) ? value : [];
|
|
1903
|
+
// Start from the saved order, then append any options not yet ranked + drop
|
|
1904
|
+
// any stale values whose option was removed.
|
|
1905
|
+
const ordered = [
|
|
1906
|
+
...current.filter((v) => opts.some((o) => o.value === v)),
|
|
1907
|
+
...opts.filter((o) => !current.includes(o.value)).map((o) => o.value),
|
|
1908
|
+
];
|
|
1909
|
+
const move = (i, dir) => {
|
|
1910
|
+
const j = i + dir;
|
|
1911
|
+
if (j < 0 || j >= ordered.length)
|
|
1912
|
+
return;
|
|
1913
|
+
const next = [...ordered];
|
|
1914
|
+
const tmp = next[i];
|
|
1915
|
+
next[i] = next[j];
|
|
1916
|
+
next[j] = tmp;
|
|
1917
|
+
onChange(next);
|
|
1918
|
+
};
|
|
1919
|
+
if (opts.length === 0)
|
|
1920
|
+
return (_jsx("p", { className: "text-xs text-fg-muted", children: _jsx(GeneratedText, { id: "m_0ca96ade46371f" }) }));
|
|
1921
|
+
return (_jsx("ol", { className: "space-y-1", children: _jsx(GeneratedValue, { value: ordered.map((v, i) => (_jsxs("li", { className: "flex items-center gap-2 rounded-md border border-border bg-surface px-2 py-1.5 text-sm", children: [_jsx("span", { className: "w-5 text-center font-semibold text-fg-muted", children: _jsx(GeneratedValue, { value: i + 1 }) }), _jsx("span", { className: "flex-1 text-fg", children: _jsx(GeneratedValue, { value: labelOf(v) }) }), _jsx("button", { type: "button", disabled: i === 0, onClick: () => move(i, -1), className: "rounded p-1 text-fg-muted enabled:hover:bg-bg-subtle enabled:hover:text-fg disabled:opacity-30", title: tGenerated('m_1ec1460770eaa0'), children: _jsx(ChevronUp, { size: 14 }) }), _jsx("button", { type: "button", disabled: i === ordered.length - 1, onClick: () => move(i, 1), className: "rounded p-1 text-fg-muted enabled:hover:bg-bg-subtle enabled:hover:text-fg disabled:opacity-30", title: tGenerated('m_14ab8cefda3cf9'), children: _jsx(ChevronDown, { size: 14 }) })] }, v))) }) }));
|
|
1922
|
+
}
|
|
1923
|
+
// --- Rich text (lightweight contentEditable) -------------------------------
|
|
1924
|
+
function RichTextInput({ value, onChange }) {
|
|
1925
|
+
const tGenerated = useGeneratedTranslations();
|
|
1926
|
+
const readOnly = useContext(FillReadOnlyContext);
|
|
1927
|
+
const ref = useRef(null);
|
|
1928
|
+
// Capture the sanitized initial document once. React sees the same HTML prop
|
|
1929
|
+
// on later renders and leaves the user's uncontrolled DOM/caret untouched.
|
|
1930
|
+
const [initialHtml] = useState(() => sanitizeDocumentHtml(typeof value === 'string' ? value : ''));
|
|
1931
|
+
const cleanEditorHtml = () => sanitizeDocumentHtml(ref.current?.innerHTML ?? '');
|
|
1932
|
+
const exec = (cmd, arg) => {
|
|
1933
|
+
if (readOnly)
|
|
1934
|
+
return;
|
|
1935
|
+
document.execCommand(cmd, false, arg);
|
|
1936
|
+
onChange(cleanEditorHtml());
|
|
1937
|
+
ref.current?.focus();
|
|
1938
|
+
};
|
|
1939
|
+
const btn = 'flex h-7 w-7 items-center justify-center rounded text-fg-muted enabled:hover:bg-surface enabled:hover:text-fg disabled:cursor-not-allowed disabled:opacity-40';
|
|
1940
|
+
return (_jsxs("div", { className: "rounded-md border border-border", children: [_jsxs("div", { className: "flex gap-0.5 border-b border-border bg-bg-subtle p-1", children: [_jsx("button", { type: "button", className: btn, title: tGenerated('m_1e62e6d69a0d11'), disabled: readOnly, onClick: () => exec('bold'), children: _jsx(Bold, { size: 13 }) }), _jsx("button", { type: "button", className: btn, title: tGenerated('m_1ee96b6856cb45'), disabled: readOnly, onClick: () => exec('italic'), children: _jsx(Italic, { size: 13 }) }), _jsx("button", { type: "button", className: btn, title: tGenerated('m_1eba1a694e67d0'), disabled: readOnly, onClick: () => exec('insertUnorderedList'), children: _jsx(List, { size: 13 }) }), _jsx("button", { type: "button", className: btn, title: tGenerated('m_197fef09772e0d'), disabled: readOnly, onClick: () => {
|
|
1941
|
+
const url = window.prompt('Link URL');
|
|
1942
|
+
if (!url)
|
|
1943
|
+
return;
|
|
1944
|
+
const safeUrl = normalizeRichTextLinkUrl(url);
|
|
1945
|
+
if (!safeUrl) {
|
|
1946
|
+
toast.error(tGenerated('m_19dc719a9038ec'));
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
exec('createLink', safeUrl);
|
|
1950
|
+
}, children: _jsx(LinkIcon, { size: 13 }) })] }), _jsx("div", { ref: ref, contentEditable: !readOnly, suppressContentEditableWarning: true, dangerouslySetInnerHTML: { __html: initialHtml }, onInput: readOnly ? undefined : () => onChange(cleanEditorHtml()), onPaste: readOnly
|
|
1951
|
+
? undefined
|
|
1952
|
+
: (event) => {
|
|
1953
|
+
event.preventDefault();
|
|
1954
|
+
const html = event.clipboardData.getData('text/html');
|
|
1955
|
+
if (html) {
|
|
1956
|
+
document.execCommand('insertHTML', false, sanitizeDocumentHtml(html));
|
|
1957
|
+
}
|
|
1958
|
+
else {
|
|
1959
|
+
document.execCommand('insertText', false, event.clipboardData.getData('text'));
|
|
1960
|
+
}
|
|
1961
|
+
onChange(cleanEditorHtml());
|
|
1962
|
+
}, onDrop: readOnly
|
|
1963
|
+
? undefined
|
|
1964
|
+
: (event) => {
|
|
1965
|
+
event.preventDefault();
|
|
1966
|
+
document.execCommand('insertText', false, event.dataTransfer.getData('text/plain'));
|
|
1967
|
+
onChange(cleanEditorHtml());
|
|
1968
|
+
}, onBlur: readOnly
|
|
1969
|
+
? undefined
|
|
1970
|
+
: () => {
|
|
1971
|
+
const clean = cleanEditorHtml();
|
|
1972
|
+
if (ref.current && ref.current.innerHTML !== clean)
|
|
1973
|
+
ref.current.innerHTML = clean;
|
|
1974
|
+
onChange(clean);
|
|
1975
|
+
}, className: "app-scroll prose prose-sm max-h-60 min-h-[80px] max-w-none overflow-auto p-2 text-sm focus:outline-none" })] }));
|
|
1976
|
+
}
|
|
1977
|
+
function AddressInput({ value, onChange }) {
|
|
1978
|
+
const tGenerated = useGeneratedTranslations();
|
|
1979
|
+
const v = (value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
|
1980
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
1981
|
+
const [searching, setSearching] = useState(false);
|
|
1982
|
+
const timerRef = useRef(null);
|
|
1983
|
+
const set = (patch) => onChange({ ...v, ...patch });
|
|
1984
|
+
const onQuery = (q) => {
|
|
1985
|
+
set({ query: q });
|
|
1986
|
+
if (timerRef.current)
|
|
1987
|
+
window.clearTimeout(timerRef.current);
|
|
1988
|
+
if (q.trim().length < 4) {
|
|
1989
|
+
setSuggestions([]);
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
timerRef.current = window.setTimeout(async () => {
|
|
1993
|
+
try {
|
|
1994
|
+
setSearching(true);
|
|
1995
|
+
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=5&q=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' } });
|
|
1996
|
+
setSuggestions(res.ok ? (await res.json()) : []);
|
|
1997
|
+
}
|
|
1998
|
+
catch {
|
|
1999
|
+
setSuggestions([]);
|
|
2000
|
+
}
|
|
2001
|
+
finally {
|
|
2002
|
+
setSearching(false);
|
|
2003
|
+
}
|
|
2004
|
+
}, 500);
|
|
2005
|
+
};
|
|
2006
|
+
const choose = (s) => {
|
|
2007
|
+
const a = s.address ?? {};
|
|
2008
|
+
onChange({
|
|
2009
|
+
query: s.display_name,
|
|
2010
|
+
line1: [a.house_number, a.road].filter(Boolean).join(' ') || s.display_name,
|
|
2011
|
+
city: a.city || a.town || a.village || a.hamlet || '',
|
|
2012
|
+
region: a.state || a.province || a.county || '',
|
|
2013
|
+
postal: a.postcode || '',
|
|
2014
|
+
country: a.country || '',
|
|
2015
|
+
lat: Number(s.lat),
|
|
2016
|
+
lng: Number(s.lon),
|
|
2017
|
+
});
|
|
2018
|
+
setSuggestions([]);
|
|
2019
|
+
};
|
|
2020
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "relative", children: [_jsx(Input, { value: v.query ?? '', placeholder: tGenerated('m_178de0c94da150'), onChange: (e) => onQuery(e.target.value) }), _jsx(GeneratedValue, { value: searching ? (_jsx("span", { className: "absolute top-2.5 right-2 text-xs text-fg-muted", children: "\u2026" })) : null }), _jsx(GeneratedValue, { value: suggestions.length > 0 ? (_jsx("ul", { className: "absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-md border border-border bg-surface shadow-lg", children: _jsx(GeneratedValue, { value: suggestions.map((s, i) => (_jsx("li", { children: _jsx("button", { type: "button", onClick: () => choose(s), className: "block w-full px-2 py-1.5 text-left text-xs text-fg hover:bg-bg-subtle", children: _jsx(GeneratedValue, { value: s.display_name }) }) }, i))) }) })) : null })] }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(Input, { value: v.line1 ?? '', placeholder: tGenerated('m_13c9eb2e75e0da'), onChange: (e) => set({ line1: e.target.value }), className: "col-span-2" }), _jsx(Input, { value: v.city ?? '', placeholder: tGenerated('m_0f8706f757eeb9'), onChange: (e) => set({ city: e.target.value }) }), _jsx(Input, { value: v.region ?? '', placeholder: tGenerated('m_09f3fc442fedaf'), onChange: (e) => set({ region: e.target.value }) }), _jsx(Input, { value: v.postal ?? '', placeholder: tGenerated('m_19e342351d140c'), onChange: (e) => set({ postal: e.target.value }) }), _jsx(Input, { value: v.country ?? '', placeholder: tGenerated('m_1bcca98c4d6c29'), onChange: (e) => set({ country: e.target.value }) })] })] }));
|
|
2021
|
+
}
|
|
2022
|
+
// --- Signature -------------------------------------------------------------
|
|
2023
|
+
//
|
|
2024
|
+
// The signature pad is supplied by the shared UI package. The runtime stores
|
|
2025
|
+
// an {attachmentId, url}
|
|
2026
|
+
// pair on the field value (the response viewer reads the url for display,
|
|
2027
|
+
// the PDF renderer reads the attachmentId for embed).
|
|
2028
|
+
function SignatureField({ value, onChange, }) {
|
|
2029
|
+
const { adapter } = useProductionFormRuntime();
|
|
2030
|
+
const readOnly = useContext(FillReadOnlyContext);
|
|
2031
|
+
const stored = value ?? null;
|
|
2032
|
+
async function persist(dataUrl) {
|
|
2033
|
+
if (!dataUrl) {
|
|
2034
|
+
onChange(null);
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
const file = dataUrlToFile(dataUrl, `signature-${Date.now()}.png`);
|
|
2038
|
+
if (!adapter.requestUpload || !adapter.finalizeUpload)
|
|
2039
|
+
return;
|
|
2040
|
+
const req = await adapter.requestUpload({
|
|
2041
|
+
kind: 'signature',
|
|
2042
|
+
filename: file.name,
|
|
2043
|
+
contentType: file.type,
|
|
2044
|
+
sizeBytes: file.size,
|
|
2045
|
+
});
|
|
2046
|
+
if (!req.ok) {
|
|
2047
|
+
console.warn('[signature] presign failed', req.error);
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
let finalizeInput;
|
|
2051
|
+
try {
|
|
2052
|
+
finalizeInput = await uploadReservedFile(req, file);
|
|
2053
|
+
}
|
|
2054
|
+
catch (error) {
|
|
2055
|
+
console.warn('[signature] upload failed', error);
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
const fin = await adapter.finalizeUpload(finalizeInput);
|
|
2059
|
+
if (!fin.ok)
|
|
2060
|
+
return;
|
|
2061
|
+
onChange({ attachmentId: fin.attachmentId, url: fin.url });
|
|
2062
|
+
}
|
|
2063
|
+
return (_jsx("div", { children: _jsx(SignaturePad, { value: stored?.url ?? null, onChange: persist, disabled: readOnly }) }));
|
|
2064
|
+
}
|
|
2065
|
+
function SketchField({ value, onChange, }) {
|
|
2066
|
+
const { adapter } = useProductionFormRuntime();
|
|
2067
|
+
const readOnly = useContext(FillReadOnlyContext);
|
|
2068
|
+
const stored = value ?? null;
|
|
2069
|
+
async function persist(dataUrl, scene) {
|
|
2070
|
+
if (!dataUrl) {
|
|
2071
|
+
onChange(null);
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
const file = dataUrlToFile(dataUrl, `sketch-${Date.now()}.png`);
|
|
2075
|
+
if (!adapter.requestUpload || !adapter.finalizeUpload)
|
|
2076
|
+
return;
|
|
2077
|
+
const req = await adapter.requestUpload({
|
|
2078
|
+
kind: 'image',
|
|
2079
|
+
filename: file.name,
|
|
2080
|
+
contentType: file.type,
|
|
2081
|
+
sizeBytes: file.size,
|
|
2082
|
+
});
|
|
2083
|
+
if (!req.ok) {
|
|
2084
|
+
console.warn('[sketch] presign failed', req.error);
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
let finalizeInput;
|
|
2088
|
+
try {
|
|
2089
|
+
finalizeInput = await uploadReservedFile(req, file);
|
|
2090
|
+
}
|
|
2091
|
+
catch (error) {
|
|
2092
|
+
console.warn('[sketch] upload failed', error);
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
const fin = await adapter.finalizeUpload(finalizeInput);
|
|
2096
|
+
if (!fin.ok)
|
|
2097
|
+
return;
|
|
2098
|
+
onChange({ attachmentId: fin.attachmentId, url: fin.url, scene });
|
|
2099
|
+
}
|
|
2100
|
+
return _jsx(SketchPad, { initialScene: stored?.scene ?? null, onChange: persist, readOnly: readOnly });
|
|
2101
|
+
}
|
|
2102
|
+
// --- Save status indicator -------------------------------------------------
|
|
2103
|
+
//
|
|
2104
|
+
// Small chip rendered in the top-right of the wizard header. Mirrors the
|
|
2105
|
+
// 'idle' | 'pending' | 'saved' | 'error' state machine driven by the
|
|
2106
|
+
// autosave hook. Clickable in the error state to manually retry the save.
|
|
2107
|
+
function SaveStatus({ status, lastSavedAt, error, onRetry, }) {
|
|
2108
|
+
const tGeneratedValue = useGeneratedValueTranslations();
|
|
2109
|
+
const tGenerated = useGeneratedTranslations();
|
|
2110
|
+
if (status === 'idle')
|
|
2111
|
+
return null;
|
|
2112
|
+
if (status === 'pending') {
|
|
2113
|
+
return (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-bg-subtle px-2 py-0.5 text-[11px] font-medium text-fg", children: [_jsx(Cloud, { size: 11, className: "animate-pulse" }), _jsx(GeneratedText, { id: "m_106811f2aac664" })] }));
|
|
2114
|
+
}
|
|
2115
|
+
if (status === 'error') {
|
|
2116
|
+
return (_jsxs("button", { type: "button", onClick: onRetry, title: tGeneratedValue(error ?? tGenerated('m_0731204fbd1b17')), className: "inline-flex items-center gap-1 rounded-full bg-danger-subtle px-2 py-0.5 text-[11px] font-medium text-danger ring-1 ring-danger hover:bg-danger-subtle", children: [_jsx(CloudOff, { size: 11 }), _jsx(GeneratedText, { id: "m_0f20f0bc8118a7" })] }));
|
|
2117
|
+
}
|
|
2118
|
+
// 'saved'
|
|
2119
|
+
const label = lastSavedAt ? formatSavedAgo(lastSavedAt) : 'Saved';
|
|
2120
|
+
return (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-success-subtle px-2 py-0.5 text-[11px] font-medium text-success ring-1 ring-success", children: [_jsx(Check, { size: 11 }), _jsx(GeneratedValue, { value: label })] }));
|
|
2121
|
+
}
|
|
2122
|
+
function formatSavedAgo(at) {
|
|
2123
|
+
const seconds = Math.floor((Date.now() - at.getTime()) / 1000);
|
|
2124
|
+
if (seconds < 5)
|
|
2125
|
+
return 'Saved';
|
|
2126
|
+
if (seconds < 60)
|
|
2127
|
+
return `Saved ${seconds}s ago`;
|
|
2128
|
+
const mins = Math.floor(seconds / 60);
|
|
2129
|
+
if (mins < 60)
|
|
2130
|
+
return `Saved ${mins}m ago`;
|
|
2131
|
+
const hours = Math.floor(mins / 60);
|
|
2132
|
+
return `Saved ${hours}h ago`;
|
|
2133
|
+
}
|
|
2134
|
+
//# sourceMappingURL=production-form-renderer.js.map
|