@adcops/autocore-react 3.3.113 → 3.3.116

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.
Files changed (69) hide show
  1. package/dist/components/index.d.ts +10 -2
  2. package/dist/components/index.d.ts.map +1 -1
  3. package/dist/components/index.js +1 -1
  4. package/dist/components/tis/CycleConfigDialog.d.ts +39 -0
  5. package/dist/components/tis/CycleConfigDialog.d.ts.map +1 -0
  6. package/dist/components/tis/CycleConfigDialog.js +1 -0
  7. package/dist/components/tis/CycleConfigForm.d.ts +22 -0
  8. package/dist/components/tis/CycleConfigForm.d.ts.map +1 -0
  9. package/dist/components/tis/CycleConfigForm.js +1 -0
  10. package/dist/components/tis/CycleStatusStrip.d.ts +10 -0
  11. package/dist/components/tis/CycleStatusStrip.d.ts.map +1 -0
  12. package/dist/components/tis/CycleStatusStrip.js +1 -0
  13. package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
  14. package/dist/components/tis/ResultHistoryTable.js +1 -1
  15. package/dist/components/tis/ScienceTable.d.ts +15 -0
  16. package/dist/components/tis/ScienceTable.d.ts.map +1 -1
  17. package/dist/components/tis/ScienceTable.js +1 -1
  18. package/dist/components/tis/TestDataView.d.ts +14 -4
  19. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  20. package/dist/components/tis/TestDataView.js +1 -1
  21. package/dist/components/tis/TestFieldRow.d.ts +151 -0
  22. package/dist/components/tis/TestFieldRow.d.ts.map +1 -0
  23. package/dist/components/tis/TestFieldRow.js +1 -0
  24. package/dist/components/tis/TestSetupForm.d.ts +15 -51
  25. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  26. package/dist/components/tis/TestSetupForm.js +1 -1
  27. package/dist/components/tis/TestSetupView.d.ts.map +1 -1
  28. package/dist/components/tis/TestSetupView.js +1 -1
  29. package/dist/components/tis/TisProvider.d.ts +80 -0
  30. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  31. package/dist/components/tis/TisProvider.js +1 -1
  32. package/dist/components/tis-editor/TisConfigEditor.d.ts.map +1 -1
  33. package/dist/components/tis-editor/TisConfigEditor.js +1 -1
  34. package/dist/components/tis-editor/editor/CycleConfigSection.d.ts +19 -0
  35. package/dist/components/tis-editor/editor/CycleConfigSection.d.ts.map +1 -0
  36. package/dist/components/tis-editor/editor/CycleConfigSection.js +1 -0
  37. package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts.map +1 -1
  38. package/dist/components/tis-editor/editor/FieldArrayEditor.js +1 -1
  39. package/dist/components/tis-editor/editor/MethodFormEditor.d.ts.map +1 -1
  40. package/dist/components/tis-editor/editor/MethodFormEditor.js +1 -1
  41. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts +3 -0
  42. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
  43. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
  44. package/dist/components/tis-editor/editor/ViewsEditor.d.ts.map +1 -1
  45. package/dist/components/tis-editor/editor/ViewsEditor.js +1 -1
  46. package/dist/components/tis-editor/types.d.ts +28 -1
  47. package/dist/components/tis-editor/types.d.ts.map +1 -1
  48. package/dist/components/tis-editor/validation.d.ts.map +1 -1
  49. package/dist/components/tis-editor/validation.js +1 -1
  50. package/package.json +110 -110
  51. package/src/components/index.ts +14 -0
  52. package/src/components/tis/CycleConfigDialog.tsx +173 -0
  53. package/src/components/tis/CycleConfigForm.tsx +192 -0
  54. package/src/components/tis/CycleStatusStrip.tsx +84 -0
  55. package/src/components/tis/ResultHistoryTable.tsx +141 -1
  56. package/src/components/tis/ScienceTable.tsx +71 -12
  57. package/src/components/tis/TestDataView.tsx +82 -7
  58. package/src/components/tis/TestFieldRow.tsx +297 -0
  59. package/src/components/tis/TestSetupForm.tsx +52 -212
  60. package/src/components/tis/TestSetupView.tsx +60 -3
  61. package/src/components/tis/TisProvider.tsx +191 -0
  62. package/src/components/tis-editor/TisConfigEditor.tsx +1 -0
  63. package/src/components/tis-editor/editor/CycleConfigSection.tsx +93 -0
  64. package/src/components/tis-editor/editor/FieldArrayEditor.tsx +10 -0
  65. package/src/components/tis-editor/editor/MethodFormEditor.tsx +10 -4
  66. package/src/components/tis-editor/editor/TestFieldDialog.tsx +22 -2
  67. package/src/components/tis-editor/editor/ViewsEditor.tsx +3 -0
  68. package/src/components/tis-editor/types.ts +34 -1
  69. package/src/components/tis-editor/validation.ts +20 -2
@@ -0,0 +1,192 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * <CycleConfigForm> — the Cycle tab. Operator-entered annotation that is
5
+ * recorded onto each cycle row: "which of the six positions is this?"
6
+ *
7
+ * Renders the selected method's `cycle_config_fields` with the same field
8
+ * rendering, unit handling and validation rules as Test Configuration (see
9
+ * TestFieldRow.tsx). What differs is the commit path and the lifecycle:
10
+ *
11
+ * - Every edit round-trips through `tis.set_cycle_config` immediately.
12
+ * There is no local draft: the value the operator sees must be the
13
+ * value the server would stamp on the next row, because that is what
14
+ * the `tis.cycle_ready` gate is computed from.
15
+ * - While a cycle is running its annotation is LATCHED. The fields stay
16
+ * visible but read-only, with a note saying edits apply to the next
17
+ * cycle — an edit that silently relabelled the row being measured
18
+ * would be worse than not being able to type at all.
19
+ * - Under `cycle` scope the values clear after each recorded row
20
+ * (`autoclear`), the gate re-arms, and the operator annotates again.
21
+ */
22
+
23
+ import React, { useContext, useState } from 'react';
24
+ import { Button } from 'primereact/button';
25
+ import { Dialog } from 'primereact/dialog';
26
+ import { Message } from 'primereact/message';
27
+ import { useTis } from './TisProvider';
28
+ import { TestFieldRow, labelOf, type TestFieldDef } from './TestFieldRow';
29
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
30
+
31
+ export interface CycleConfigFormProps {
32
+ /** Extra class names applied to the root element. */
33
+ className?: string;
34
+ /** Inline style merged onto the root element. */
35
+ style?: React.CSSProperties;
36
+ /** Hide the explanatory header (used inside the prompt dialog, where
37
+ * the dialog title already says what this is). */
38
+ compact?: boolean;
39
+ /** Called after each accepted edit — the dialog uses it to know the
40
+ * operator has engaged with the form. */
41
+ onValueCommitted?: (field: TestFieldDef, raw: any) => void;
42
+ }
43
+
44
+ /**
45
+ * One-line summary of what the next recorded row will be labelled with.
46
+ * Shared by the Cycle tab header and <CycleStatusStrip> so the two can't
47
+ * describe the same state differently.
48
+ */
49
+ export const cycleConfigSummary = (
50
+ fields: TestFieldDef[],
51
+ values: Record<string, any>,
52
+ ): string => {
53
+ if (fields.length === 0) return '';
54
+ return fields
55
+ .map(f => {
56
+ const v = values?.[f.name];
57
+ const shown = v === undefined || v === null || v === '' ? '—' : String(v);
58
+ return `${f.label ?? f.name}: ${shown}`;
59
+ })
60
+ .join(' ');
61
+ };
62
+
63
+ export const CycleConfigForm: React.FC<CycleConfigFormProps> = ({
64
+ className, style, compact, onValueCommitted,
65
+ }) => {
66
+ const tis = useTis();
67
+ const { write } = useContext(EventEmitterContext);
68
+ const cfg = tis.cycleConfig;
69
+ const fields = tis.cycleConfigFields;
70
+ const [infoField, setInfoField] = useState<TestFieldDef | null>(null);
71
+
72
+ // A latch exists exactly while a cycle is running against these values.
73
+ const locked = cfg.latched !== null;
74
+
75
+ const commit = async (field: TestFieldDef, raw: any) => {
76
+ await tis.setCycleConfigValues({ [field.name]: raw ?? null });
77
+ // A `source`-bound annotation also drives the machine (e.g. the
78
+ // position the fixture should move to), so mirror it to GM the way
79
+ // Test Configuration does. Storage on both sides stays RAW.
80
+ if (field.source) {
81
+ try { await write(field.source, raw); }
82
+ catch (e) { console.error('[CycleConfigForm] failed to write source:', e); }
83
+ }
84
+ onValueCommitted?.(field, raw);
85
+ };
86
+
87
+ if (fields.length === 0) {
88
+ return (
89
+ <div className={className} style={{ padding: '1.25rem', ...style }}>
90
+ <h3 className="ac-form-section">No Cycle Configuration</h3>
91
+ <p style={{ color: 'var(--text-secondary-color)', marginTop: '0.5rem' }}>
92
+ This test method records no operator-entered columns. Cycle rows
93
+ are written entirely from measured values.
94
+ </p>
95
+ </div>
96
+ );
97
+ }
98
+
99
+ const gridStyle: React.CSSProperties = {
100
+ padding: '1.25rem',
101
+ gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
102
+ };
103
+
104
+ return (
105
+ <div className={className} style={style}>
106
+ {!compact && (
107
+ <div style={{ padding: '1.25rem 1.25rem 0' }}>
108
+ <h3 className="ac-form-section" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
109
+ Cycle Configuration
110
+ {/* Status is stated in full by the messages below;
111
+ this is the at-a-glance marker beside the title. */}
112
+ <i
113
+ className={cfg.ready ? 'pi pi-check-circle' : 'pi pi-exclamation-circle'}
114
+ style={{ color: cfg.ready ? 'var(--green-500)' : 'var(--red-500)' }}
115
+ aria-label={cfg.ready ? 'Cycle Configuration complete' : 'Cycle Configuration incomplete'}
116
+ />
117
+ </h3>
118
+ <p style={{ color: 'var(--text-secondary-color)', margin: '0.25rem 0 0' }}>
119
+ {cfg.scope === 'cycle'
120
+ ? 'Recorded on the next cycle. Entered again for every cycle.'
121
+ : 'Recorded on every cycle of this run.'}
122
+ </p>
123
+ </div>
124
+ )}
125
+
126
+ {locked && (
127
+ <div style={{ padding: '0.75rem 1.25rem 0' }}>
128
+ <Message
129
+ severity="info"
130
+ text="A cycle is running with these values. Edits apply to the next cycle."
131
+ />
132
+ </div>
133
+ )}
134
+
135
+ {!cfg.ready && !locked && (
136
+ <div style={{ padding: '0.75rem 1.25rem 0' }}>
137
+ <Message
138
+ severity="warn"
139
+ text={
140
+ 'Required: ' +
141
+ cfg.missing
142
+ .map(n => fields.find(f => f.name === n))
143
+ .map((f, i) => (f ? labelOf(f) : cfg.missing[i]))
144
+ .join(', ') +
145
+ '. The machine will not start until these are filled in.'
146
+ }
147
+ />
148
+ </div>
149
+ )}
150
+
151
+ <div className="ac-form-grid" style={gridStyle}>
152
+ {fields.map(field => (
153
+ <TestFieldRow
154
+ key={field.name}
155
+ field={field}
156
+ // While latched, show what the running cycle is
157
+ // actually recording rather than the pending entry.
158
+ value={locked ? cfg.latched?.[field.name] : cfg.values?.[field.name]}
159
+ disabled={locked}
160
+ onChange={(raw) => { void commit(field, raw); }}
161
+ onInfo={(f) => setInfoField(f)}
162
+ />
163
+ ))}
164
+ </div>
165
+
166
+ {!compact && (
167
+ <div style={{ padding: '0 1.25rem 1.25rem' }}>
168
+ <Button
169
+ label="Clear"
170
+ icon="pi pi-eraser"
171
+ text
172
+ disabled={locked}
173
+ onClick={() => { void tis.clearCycleConfig(); }}
174
+ tooltip="Clear every entry, including fields that don't autoclear"
175
+ tooltipOptions={{ position: 'top' }}
176
+ />
177
+ </div>
178
+ )}
179
+
180
+ <Dialog
181
+ header={infoField ? labelOf(infoField) : ''}
182
+ visible={infoField !== null}
183
+ onHide={() => setInfoField(null)}
184
+ style={{ width: '32rem', maxWidth: '90vw' }}
185
+ modal
186
+ dismissableMask
187
+ >
188
+ <p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{infoField?.description}</p>
189
+ </Dialog>
190
+ </div>
191
+ );
192
+ };
@@ -0,0 +1,84 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * <CycleStatusStrip> — the Cycle Configuration state, rendered where the
5
+ * operator is already looking: next to the Start button.
6
+ *
7
+ * The Cycle tab is three panes away from the run screen. An operator
8
+ * about to press Start is looking at the lower-right corner of the
9
+ * footer, not at a tab they may never open, so the annotation that is
10
+ * about to be written onto the row has to be visible from there. This is
11
+ * the same idea as the specimen number Bluehill keeps beside its run
12
+ * control.
13
+ *
14
+ * Drop it into the footer beside Start:
15
+ *
16
+ * <div className="footer">
17
+ * <CycleStatusStrip />
18
+ * <Button label="Start" onClick={...} />
19
+ * </div>
20
+ *
21
+ * Clicking it opens the entry form in a dialog, so the operator can fill
22
+ * the fields in without leaving the run screen.
23
+ */
24
+
25
+ import React, { useState } from 'react';
26
+ import { Button } from 'primereact/button';
27
+ import { Dialog } from 'primereact/dialog';
28
+ import { useTis } from './TisProvider';
29
+ import { CycleConfigForm, cycleConfigSummary } from './CycleConfigForm';
30
+
31
+ export interface CycleStatusStripProps {
32
+ className?: string;
33
+ style?: React.CSSProperties;
34
+ /** Render nothing when the method declares no annotation columns.
35
+ * Default true — an empty chip beside Start is just noise. */
36
+ hideWhenUnused?: boolean;
37
+ }
38
+
39
+ export const CycleStatusStrip: React.FC<CycleStatusStripProps> = ({
40
+ className, style, hideWhenUnused = true,
41
+ }) => {
42
+ const tis = useTis();
43
+ const [open, setOpen] = useState(false);
44
+ const cfg = tis.cycleConfig;
45
+ const fields = tis.cycleConfigFields;
46
+
47
+ if (fields.length === 0 && hideWhenUnused) return null;
48
+
49
+ // Latched values are what the running cycle will actually record;
50
+ // pending values are what the next one will get.
51
+ const shown = cfg.latched ?? cfg.values;
52
+ const summary = cycleConfigSummary(fields, shown);
53
+ const incomplete = !cfg.ready;
54
+
55
+ return (
56
+ <>
57
+ <Button
58
+ className={className}
59
+ style={style}
60
+ text
61
+ severity={incomplete ? 'danger' : undefined}
62
+ icon={incomplete ? 'pi pi-exclamation-triangle' : 'pi pi-tag'}
63
+ // Not disabled and not silent: the operator can always open
64
+ // it, and when something is missing the chip says so in
65
+ // words rather than making them hunt for a red field.
66
+ label={incomplete
67
+ ? `${summary} — entry required`
68
+ : summary}
69
+ aria-label="Cycle Configuration"
70
+ onClick={() => setOpen(true)}
71
+ />
72
+ <Dialog
73
+ header="Cycle Configuration"
74
+ visible={open}
75
+ onHide={() => setOpen(false)}
76
+ style={{ width: '34rem', maxWidth: '95vw' }}
77
+ modal
78
+ dismissableMask
79
+ >
80
+ <CycleConfigForm />
81
+ </Dialog>
82
+ </>
83
+ );
84
+ };
@@ -1,8 +1,10 @@
1
- import React, { useState, useEffect, useContext } from 'react';
1
+ import React, { useState, useEffect, useContext, useRef } from 'react';
2
2
  import { DataTable } from 'primereact/datatable';
3
3
  import { Column } from 'primereact/column';
4
4
  import { Button } from 'primereact/button';
5
5
  import { Badge } from 'primereact/badge';
6
+ import { Menu } from 'primereact/menu';
7
+ import { confirmDialog } from 'primereact/confirmdialog';
6
8
  import { EventEmitterContext } from '../../core/EventEmitterContext';
7
9
  import { MessageType } from '../../hub/CommandMessage';
8
10
  import { useTis } from './TisProvider';
@@ -75,6 +77,11 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
75
77
  const [downloading, setDownloading] = useState<InFlight | null>(null);
76
78
  const [projectBusy, setProjectBusy] = useState<ProjectDownloadKind | null>(null);
77
79
  const [excludingRunId, setExcludingRunId] = useState<string | null>(null);
80
+ // Per-row lifecycle menu (complete / abandon / reopen). One Menu instance is
81
+ // reused; `menuRow` is the row it was opened for.
82
+ const rowMenuRef = useRef<Menu>(null);
83
+ const [menuRow, setMenuRow] = useState<any>(null);
84
+ const [lifecycleBusy, setLifecycleBusy] = useState<string | null>(null);
78
85
  const { invoke } = useContext(EventEmitterContext);
79
86
 
80
87
  const loadTests = async () => {
@@ -176,6 +183,112 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
176
183
  // kept on disk (not deleted) but dropped from avg/min/max/stddev/count
177
184
  // results for their sample group. The server recomputes + rebroadcasts
178
185
  // the sample summary on success.
186
+ /**
187
+ * Run-lifecycle action on one row: complete, abandon, or reopen.
188
+ *
189
+ * All three are RECORD operations — they change what the stored run says
190
+ * about itself, never what the machine is doing. Reopening makes a run the
191
+ * active one so the control program's next cycle appends to it; it does not
192
+ * command motion, and the machine still decides when to run. That
193
+ * distinction is why this is safe to expose in a browser: a click here can
194
+ * retarget where data lands, but cannot start a machine.
195
+ *
196
+ * `tis.finish_test` / `reset_test` / `resume_test` all take an explicit
197
+ * `run_id`, so the row's own run is addressed rather than "whatever is
198
+ * newest".
199
+ */
200
+ const handleLifecycle = async (
201
+ rowData: any,
202
+ action: 'complete' | 'abandon' | 'reopen',
203
+ ) => {
204
+ const runId = rowData?.run_id;
205
+ const rowMethodId = rowData?.method_id ?? methodId;
206
+ if (!runId || !rowMethodId || !projectId) return;
207
+ const topic =
208
+ action === 'complete' ? 'tis.finish_test'
209
+ : action === 'abandon' ? 'tis.reset_test'
210
+ : 'tis.resume_test';
211
+ setLifecycleBusy(runId);
212
+ try {
213
+ const resp: any = await invoke(topic as any, MessageType.Request, {
214
+ project_id: projectId, method_id: rowMethodId, run_id: runId,
215
+ } as any);
216
+ if (!resp?.success) {
217
+ // The most common rejection is reopening while another test is
218
+ // already open for this project+method — the server refuses so
219
+ // two runs can't both be "active".
220
+ alert(`Could not ${action} ${runId}` +
221
+ (resp?.error_message ? `:\n\n${resp.error_message}` : ''));
222
+ return;
223
+ }
224
+ await loadTests();
225
+ } catch (err) {
226
+ console.error(`Failed to ${action} test`, err);
227
+ alert(`${action} failed: ${err instanceof Error ? err.message : String(err)}`);
228
+ } finally {
229
+ setLifecycleBusy(null);
230
+ }
231
+ };
232
+
233
+ /**
234
+ * Menu items for a row, gated on its lifecycle state:
235
+ * - open (in_progress / paused) → Complete, Abandon
236
+ * - completed but not finalized → Reopen (add more cycles under the same
237
+ * Test ID — the multi-specimen case where a set turned out to be bigger)
238
+ * - finalized (abandoned) → nothing; `finalized` exists precisely to
239
+ * stop a deliberately-abandoned run being resurrected.
240
+ */
241
+ const rowMenuItems = (rowData: any) => {
242
+ const status = rowData?.status;
243
+ const finalized = rowData?.finalized === true;
244
+ const isOpen = status === 'in_progress' || status === 'paused';
245
+ const items: any[] = [];
246
+
247
+ if (isOpen) {
248
+ items.push({
249
+ label: 'Complete Test',
250
+ icon: 'pi pi-check-circle',
251
+ command: () => handleLifecycle(rowData, 'complete'),
252
+ });
253
+ items.push({
254
+ label: 'Abandon Test',
255
+ icon: 'pi pi-ban',
256
+ command: () => confirmDialog({
257
+ header: 'Abandon Test',
258
+ message:
259
+ `Mark ${rowData?.sample_id || rowData?.run_id} as abandoned? ` +
260
+ `Recorded data is kept, but the test can no longer be reopened.`,
261
+ icon: 'pi pi-exclamation-triangle',
262
+ acceptClassName: 'p-button-danger',
263
+ acceptLabel: 'Abandon',
264
+ accept: () => handleLifecycle(rowData, 'abandon'),
265
+ }),
266
+ });
267
+ } else if (status === 'completed' && !finalized) {
268
+ items.push({
269
+ label: 'Reopen Test',
270
+ icon: 'pi pi-replay',
271
+ command: () => confirmDialog({
272
+ header: 'Reopen Test',
273
+ message:
274
+ `Reopen ${rowData?.sample_id || rowData?.run_id} so new cycles are added to ` +
275
+ `it? Cycle numbering continues from where it stopped. This changes where ` +
276
+ `data is recorded — it does not start the machine.`,
277
+ icon: 'pi pi-replay',
278
+ acceptLabel: 'Reopen',
279
+ accept: () => handleLifecycle(rowData, 'reopen'),
280
+ }),
281
+ });
282
+ } else {
283
+ items.push({
284
+ label: finalized ? 'Abandoned — cannot reopen' : 'No actions available',
285
+ icon: 'pi pi-info-circle',
286
+ disabled: true,
287
+ });
288
+ }
289
+ return items;
290
+ };
291
+
179
292
  const handleToggleExclude = async (rowData: any) => {
180
293
  const runId = rowData?.run_id;
181
294
  const rowMethodId = rowData?.method_id ?? methodId;
@@ -440,7 +553,34 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
440
553
  );
441
554
  }}
442
555
  />
556
+ {/* Thin per-row lifecycle menu. Kept narrow and iconic so it
557
+ doesn't compete with the download actions. */}
558
+ <Column
559
+ header=""
560
+ style={{ width: '3rem' }}
561
+ body={(rowData) => (
562
+ <Button
563
+ icon={
564
+ lifecycleBusy === rowData.run_id
565
+ ? 'pi pi-spin pi-spinner'
566
+ : 'pi pi-ellipsis-v'
567
+ }
568
+ text
569
+ rounded
570
+ size="small"
571
+ disabled={lifecycleBusy !== null}
572
+ aria-label="Test actions"
573
+ tooltip="Complete / abandon / reopen this test"
574
+ tooltipOptions={{ position: 'left' }}
575
+ onClick={(e) => {
576
+ setMenuRow(rowData);
577
+ rowMenuRef.current?.toggle(e);
578
+ }}
579
+ />
580
+ )}
581
+ />
443
582
  </DataTable>
583
+ <Menu model={menuRow ? rowMenuItems(menuRow) : []} popup ref={rowMenuRef} />
444
584
  </div>
445
585
  );
446
586
  };
@@ -16,6 +16,7 @@
16
16
  import React from 'react';
17
17
  import { DataTable } from 'primereact/datatable';
18
18
  import { Column } from 'primereact/column';
19
+ import { InputText } from 'primereact/inputtext';
19
20
  import type { ColumnBodyOptions } from 'primereact/column';
20
21
  import './ScienceTable.css';
21
22
 
@@ -32,6 +33,14 @@ export interface ScienceColumn {
32
33
  body?: (row: any) => React.ReactNode;
33
34
  /** Optional per-column style (e.g. minWidth). */
34
35
  style?: React.CSSProperties;
36
+ /**
37
+ * Operator-editable cell. Requires the table's `onCellEdit`. Intended for
38
+ * ANNOTATION columns only (a specimen or lot identifier noted against an
39
+ * automatically-captured row) — never for measured values, which the server
40
+ * refuses to rewrite anyway (`tis.update_cycle` only accepts cycle_fields
41
+ * declared `editable: true`).
42
+ */
43
+ editable?: boolean;
35
44
  }
36
45
 
37
46
  export interface ScienceTableProps {
@@ -49,6 +58,13 @@ export interface ScienceTableProps {
49
58
  scrollHeight?: string;
50
59
  /** Virtual-scroll row height in px. Default 38. */
51
60
  virtualItemSize?: number;
61
+ /**
62
+ * Commit handler for cells in columns marked `editable`. Called with the
63
+ * edited row, the column field and the new value. Without it, `editable`
64
+ * columns stay read-only — there is no local-only edit mode, because a value
65
+ * that looks saved but isn't is worse than one that can't be typed.
66
+ */
67
+ onCellEdit?: (row: any, field: string, value: any) => void | Promise<void>;
52
68
  }
53
69
 
54
70
  const HeaderCell: React.FC<{ column: ScienceColumn }> = ({ column }) => (
@@ -68,8 +84,12 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
68
84
  scrollable = false,
69
85
  scrollHeight,
70
86
  virtualItemSize = 38,
87
+ onCellEdit,
71
88
  }) => {
72
89
  const virtual = scrollable && !!scrollHeight;
90
+ // Cell edit mode is switched on only when a column asks for it AND a commit
91
+ // handler exists, so a plain report table keeps DataTable's read-only path.
92
+ const anyEditable = !!onCellEdit && columns.some((c) => c.editable);
73
93
  return (
74
94
  <div className="ac-science-table-wrap">
75
95
  {title != null && <div className="ac-science-table-title">{title}</div>}
@@ -81,6 +101,7 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
81
101
  scrollHeight={scrollHeight}
82
102
  virtualScrollerOptions={virtual ? { itemSize: virtualItemSize } : undefined}
83
103
  emptyMessage={emptyMessage}
104
+ editMode={anyEditable ? 'cell' : undefined}
84
105
  >
85
106
  {showIndex ? (
86
107
  <Column
@@ -93,18 +114,56 @@ export const ScienceTable: React.FC<ScienceTableProps> = ({
93
114
  body={(_data: any, options: ColumnBodyOptions) => options.rowIndex + 1}
94
115
  />
95
116
  ) : null}
96
- {columns.map((c) => (
97
- <Column
98
- key={c.field}
99
- field={c.field}
100
- header={<HeaderCell column={c} />}
101
- align={c.align ?? 'center'}
102
- alignHeader="center"
103
- style={c.style}
104
- bodyClassName="ac-sci-cell"
105
- body={c.body ? (row: any) => c.body!(row) : undefined}
106
- />
107
- ))}
117
+ {columns.map((c) => {
118
+ const cellEditable = !!onCellEdit && !!c.editable;
119
+ return (
120
+ <Column
121
+ key={c.field}
122
+ field={c.field}
123
+ header={<HeaderCell column={c} />}
124
+ align={c.align ?? 'center'}
125
+ alignHeader="center"
126
+ style={c.style}
127
+ bodyClassName={
128
+ cellEditable ? 'ac-sci-cell ac-sci-cell-editable' : 'ac-sci-cell'
129
+ }
130
+ body={c.body ? (row: any) => c.body!(row) : undefined}
131
+ editor={
132
+ cellEditable
133
+ ? (options: any) => (
134
+ <InputText
135
+ value={options.value ?? ''}
136
+ autoFocus
137
+ style={{ width: '100%' }}
138
+ onChange={(e) => options.editorCallback?.(e.target.value)}
139
+ onKeyDown={(e) => e.stopPropagation()}
140
+ />
141
+ )
142
+ : undefined
143
+ }
144
+ onCellEditComplete={
145
+ cellEditable
146
+ ? (e: any) => {
147
+ // Numeric annotation columns come back
148
+ // as a string from the text editor;
149
+ // coerce so the stored type matches the
150
+ // schema instead of silently turning a
151
+ // number column into strings.
152
+ const prev = e.rowData?.[c.field];
153
+ let next: any = e.newValue;
154
+ if (typeof prev === 'number' && next !== '' && next != null) {
155
+ const n = Number(next);
156
+ if (!Number.isNaN(n)) next = n;
157
+ }
158
+ if (next !== prev) {
159
+ void onCellEdit!(e.rowData, c.field, next);
160
+ }
161
+ }
162
+ : undefined
163
+ }
164
+ />
165
+ );
166
+ })}
108
167
  </DataTable>
109
168
  </div>
110
169
  </div>