@visns-studio/visns-components 6.3.5 → 6.3.7
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/package.json +1 -1
- package/src/components/DataGrid.jsx +85 -1
- package/src/components/Field.jsx +22 -0
- package/src/components/Form.jsx +280 -155
- package/src/components/MultiCheckbox.jsx +69 -3
- package/src/components/SumEqualsSummary.jsx +147 -0
- package/src/components/columns/ColumnRenderers.jsx +88 -1
- package/src/components/styles/DataGrid.module.scss +48 -0
- package/src/components/styles/MultiCheckbox.module.scss +58 -1
- package/src/components/styles/SumEqualsSummary.module.scss +38 -0
package/package.json
CHANGED
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"react-dom": "^17.0.0 || ^18.0.0"
|
|
92
92
|
},
|
|
93
93
|
"name": "@visns-studio/visns-components",
|
|
94
|
-
"version": "6.3.
|
|
94
|
+
"version": "6.3.7",
|
|
95
95
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
96
96
|
"main": "src/index.js",
|
|
97
97
|
"files": [
|
|
@@ -78,6 +78,16 @@ import {
|
|
|
78
78
|
} from 'lucide-react';
|
|
79
79
|
import styles from './styles/DataGrid.module.scss';
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* `ajaxSetting.rowStyles[].style` tokens → the module class that draws them.
|
|
83
|
+
* Kept as a map rather than `styles['row' + capitalise(token)]` so an unknown
|
|
84
|
+
* token in a config is simply ignored instead of resolving to `undefined` and
|
|
85
|
+
* putting the literal string "undefined" in the row's class list.
|
|
86
|
+
*/
|
|
87
|
+
const ROW_STYLE_CLASSES = {
|
|
88
|
+
complete: styles.rowComplete,
|
|
89
|
+
};
|
|
90
|
+
|
|
81
91
|
import '@visns-studio/visns-datagrid-enterprise/index.css';
|
|
82
92
|
|
|
83
93
|
import CustomFetch from './Fetch';
|
|
@@ -959,6 +969,80 @@ const DataGrid = forwardRef(
|
|
|
959
969
|
[ajaxSetting]
|
|
960
970
|
);
|
|
961
971
|
|
|
972
|
+
/**
|
|
973
|
+
* Opt-in non-colour row states (`ajaxSetting.rowStyles`).
|
|
974
|
+
*
|
|
975
|
+
* rowStyles: [
|
|
976
|
+
* { id: 'jigging_status', value: 1, style: 'complete' }
|
|
977
|
+
* ]
|
|
978
|
+
*
|
|
979
|
+
* Each rule names a FIELD STATE, not a look: `style` is a token the
|
|
980
|
+
* package maps to a class in DataGrid.module.scss, so a project says
|
|
981
|
+
* what a row means and the library decides how that reads. Today the
|
|
982
|
+
* only token is `complete` (line-through plus a muted ink).
|
|
983
|
+
*
|
|
984
|
+
* Deliberately different from `rowColours` in two ways:
|
|
985
|
+
*
|
|
986
|
+
* - EVERY matching rule applies, where rowColours takes the first
|
|
987
|
+
* match only. A row is frequently more than one thing at once (a
|
|
988
|
+
* finished item that is also on a T-Bar), and with first-match the
|
|
989
|
+
* completion state would be swallowed by whichever tint happened to
|
|
990
|
+
* be listed above it.
|
|
991
|
+
* - It carries no colour. That is the point: a pale tint is the first
|
|
992
|
+
* thing to disappear on a plant-floor tablet in daylight, so a state
|
|
993
|
+
* that matters has to be legible without one. Colour, where a rule
|
|
994
|
+
* also sets one, stays the redundant second channel.
|
|
995
|
+
*
|
|
996
|
+
* `operator` follows the same small vocabulary as `readOnlyWhen`:
|
|
997
|
+
* eq (default), neq, notEmpty.
|
|
998
|
+
*/
|
|
999
|
+
const getRowClassName = useCallback(
|
|
1000
|
+
({ data }) => {
|
|
1001
|
+
const rules = ajaxSetting?.rowStyles;
|
|
1002
|
+
|
|
1003
|
+
if (!Array.isArray(rules) || rules.length === 0 || !data) {
|
|
1004
|
+
return '';
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const matches = (rule) => {
|
|
1008
|
+
const value = data[rule.id];
|
|
1009
|
+
|
|
1010
|
+
switch (rule.operator) {
|
|
1011
|
+
case 'neq':
|
|
1012
|
+
return value != rule.value;
|
|
1013
|
+
case 'notEmpty':
|
|
1014
|
+
return Array.isArray(value)
|
|
1015
|
+
? value.length > 0
|
|
1016
|
+
: value != null && value !== '';
|
|
1017
|
+
case 'eq':
|
|
1018
|
+
default:
|
|
1019
|
+
return value == rule.value;
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
return rules
|
|
1024
|
+
.filter(matches)
|
|
1025
|
+
.map((rule) => ROW_STYLE_CLASSES[rule.style])
|
|
1026
|
+
.filter(Boolean)
|
|
1027
|
+
.join(' ');
|
|
1028
|
+
},
|
|
1029
|
+
[ajaxSetting]
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* The grid's own row class plus whatever `rowStyles` adds. Passed as a
|
|
1034
|
+
* function rather than the bare string the grid used to get: the grid
|
|
1035
|
+
* accepts either, and `vs-datagrid--row` (the hover rule) has to stay
|
|
1036
|
+
* on every row regardless.
|
|
1037
|
+
*/
|
|
1038
|
+
const rowClassName = useCallback(
|
|
1039
|
+
(rowProps) =>
|
|
1040
|
+
['vs-datagrid--row', getRowClassName(rowProps)]
|
|
1041
|
+
.filter(Boolean)
|
|
1042
|
+
.join(' '),
|
|
1043
|
+
[getRowClassName]
|
|
1044
|
+
);
|
|
1045
|
+
|
|
962
1046
|
const handleDownload = async (d) => {
|
|
963
1047
|
const toastId = toast.loading('Starting download please wait...');
|
|
964
1048
|
|
|
@@ -5205,7 +5289,7 @@ const DataGrid = forwardRef(
|
|
|
5205
5289
|
pagination
|
|
5206
5290
|
renderRowContextMenu={renderRowContextMenu}
|
|
5207
5291
|
renderColumnContextMenu={renderColumnContextMenu}
|
|
5208
|
-
rowClassName=
|
|
5292
|
+
rowClassName={rowClassName}
|
|
5209
5293
|
rowHeight={isTabletMode ? 52 : null}
|
|
5210
5294
|
rowStyle={getRowStyle}
|
|
5211
5295
|
selected={selected}
|
package/src/components/Field.jsx
CHANGED
|
@@ -139,6 +139,11 @@ function Field({
|
|
|
139
139
|
onDeleteJsonTableRow,
|
|
140
140
|
onChangeToggle,
|
|
141
141
|
onFileDownload,
|
|
142
|
+
// Renders another of the form's fields inside this one (today: inside a
|
|
143
|
+
// `multi-checkbox-ajax` option card, via `settings.optionFields`).
|
|
144
|
+
// Supplied by Form so the nested field keeps the form's own state,
|
|
145
|
+
// handlers and validation — see Form.renderCompanionField.
|
|
146
|
+
renderOptionField,
|
|
142
147
|
setFormData,
|
|
143
148
|
settings,
|
|
144
149
|
style,
|
|
@@ -1161,6 +1166,13 @@ function Field({
|
|
|
1161
1166
|
onValueChange={(values) => {
|
|
1162
1167
|
onChangeNumberFormat(values, settings.id);
|
|
1163
1168
|
}}
|
|
1169
|
+
// Numeric inputs ignored both of these: a
|
|
1170
|
+
// `placeholder` in the config never rendered, and a
|
|
1171
|
+
// read-only field stayed typable. The placeholder is
|
|
1172
|
+
// also how a `sumEquals` group offers its remainder
|
|
1173
|
+
// as a hint (see SumEqualsSummary).
|
|
1174
|
+
placeholder={settings.placeholder}
|
|
1175
|
+
disabled={isFieldReadOnly}
|
|
1164
1176
|
thousandSeparator={
|
|
1165
1177
|
settings.hasOwnProperty('thousandSeperator')
|
|
1166
1178
|
? settings.thousandSeperator
|
|
@@ -2187,6 +2199,16 @@ function Field({
|
|
|
2187
2199
|
// claiming a lock the UI didn't enforce — the same gap
|
|
2188
2200
|
// the radio renderers had.
|
|
2189
2201
|
disabled={isFieldReadOnly}
|
|
2202
|
+
optionFields={settings.optionFields}
|
|
2203
|
+
// A read-only host locks the fields in its cards too
|
|
2204
|
+
renderOptionField={
|
|
2205
|
+
renderOptionField
|
|
2206
|
+
? (fieldId) =>
|
|
2207
|
+
renderOptionField(fieldId, {
|
|
2208
|
+
readOnly: isFieldReadOnly,
|
|
2209
|
+
})
|
|
2210
|
+
: undefined
|
|
2211
|
+
}
|
|
2190
2212
|
style={style}
|
|
2191
2213
|
/>
|
|
2192
2214
|
);
|
package/src/components/Form.jsx
CHANGED
|
@@ -18,6 +18,10 @@ import '@visns-studio/visns-datagrid-enterprise/index.css';
|
|
|
18
18
|
import CustomFetch from './Fetch';
|
|
19
19
|
import Download from './Download';
|
|
20
20
|
import Field from './Field';
|
|
21
|
+
import SumEqualsSummary, {
|
|
22
|
+
computeSumEqualsState,
|
|
23
|
+
sumEqualsHintFor,
|
|
24
|
+
} from './SumEqualsSummary';
|
|
21
25
|
import Table from './DataGrid';
|
|
22
26
|
|
|
23
27
|
import styles from './styles/Form.module.scss';
|
|
@@ -1428,58 +1432,57 @@ function Form({
|
|
|
1428
1432
|
* ordinary save. While visible it also stands in for
|
|
1429
1433
|
* `required` on the whole group: a blank member is as
|
|
1430
1434
|
* wrong as a wrong total.
|
|
1435
|
+
*
|
|
1436
|
+
* Declaring the rule also turns on live feedback
|
|
1437
|
+
* while the operator types, all of it automatic —
|
|
1438
|
+
* no extra config, and nothing a form has to opt
|
|
1439
|
+
* into beyond `sumEquals` itself:
|
|
1440
|
+
*
|
|
1441
|
+
* - an allocation line under the group, reading
|
|
1442
|
+
* "Item Qty 40 · 25 entered · 15 remaining",
|
|
1443
|
+
* "40 of 40 allocated ✓" or "over by 5" (see
|
|
1444
|
+
* SumEqualsSummary);
|
|
1445
|
+
* - the remainder offered as the placeholder of the
|
|
1446
|
+
* group's one empty field — a hint only, never an
|
|
1447
|
+
* auto-filled value.
|
|
1448
|
+
*
|
|
1449
|
+
* `targetLabel` on the rule names the total in that
|
|
1450
|
+
* copy; it otherwise takes the label of the field
|
|
1451
|
+
* `equals` points at.
|
|
1431
1452
|
*/
|
|
1432
1453
|
if (item.sumEquals && item.hide !== true) {
|
|
1433
1454
|
const rule = item.sumEquals;
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1455
|
+
// Same computation the live summary and the
|
|
1456
|
+
// placeholder hint use, so what the operator was
|
|
1457
|
+
// told while typing is exactly what is enforced
|
|
1458
|
+
const state = computeSumEqualsState(rule, formData);
|
|
1437
1459
|
|
|
1438
|
-
if (
|
|
1460
|
+
if (state) {
|
|
1439
1461
|
const labelFor = (fieldId) =>
|
|
1440
1462
|
fields.find((f) => f.id === fieldId)
|
|
1441
1463
|
?.label || fieldId;
|
|
1442
|
-
const groupLabels =
|
|
1464
|
+
const groupLabels = state.ids
|
|
1443
1465
|
.map(labelFor)
|
|
1444
1466
|
.join(' and ');
|
|
1445
|
-
const groupValues = groupIds.map(
|
|
1446
|
-
(fieldId) => formData[fieldId]
|
|
1447
|
-
);
|
|
1448
|
-
const incomplete = groupValues.some(
|
|
1449
|
-
(v) =>
|
|
1450
|
-
v === null ||
|
|
1451
|
-
v === undefined ||
|
|
1452
|
-
v === '' ||
|
|
1453
|
-
isNaN(Number(v))
|
|
1454
|
-
);
|
|
1455
|
-
|
|
1456
|
-
const target =
|
|
1457
|
-
typeof rule.equals === 'number'
|
|
1458
|
-
? rule.equals
|
|
1459
|
-
: Number(formData[rule.equals]);
|
|
1460
1467
|
|
|
1461
1468
|
const markGroup = () =>
|
|
1462
|
-
|
|
1469
|
+
state.ids.forEach((fieldId) => {
|
|
1463
1470
|
_inputClass[fieldId] =
|
|
1464
1471
|
styles.inputError;
|
|
1465
1472
|
});
|
|
1466
1473
|
|
|
1467
|
-
if (
|
|
1474
|
+
if (state.emptyIds.length > 0) {
|
|
1468
1475
|
validation += `${groupLabels} are required.<br/>`;
|
|
1469
1476
|
markGroup();
|
|
1470
|
-
} else if (
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
`${groupLabels} must add up to ${target} - they add up to ${sum}.`) +
|
|
1480
|
-
'<br/>';
|
|
1481
|
-
markGroup();
|
|
1482
|
-
}
|
|
1477
|
+
} else if (
|
|
1478
|
+
state.hasTarget &&
|
|
1479
|
+
state.status !== 'balanced'
|
|
1480
|
+
) {
|
|
1481
|
+
validation +=
|
|
1482
|
+
(rule.message ||
|
|
1483
|
+
`${groupLabels} must add up to ${state.target} - they add up to ${state.sum}.`) +
|
|
1484
|
+
'<br/>';
|
|
1485
|
+
markGroup();
|
|
1483
1486
|
}
|
|
1484
1487
|
}
|
|
1485
1488
|
}
|
|
@@ -2899,6 +2902,193 @@ function Form({
|
|
|
2899
2902
|
});
|
|
2900
2903
|
}, [fetchTrigger]);
|
|
2901
2904
|
|
|
2905
|
+
/**
|
|
2906
|
+
* The `sumEquals` rule a field belongs to, wherever it was declared (the
|
|
2907
|
+
* rule sits on one member and names the whole group).
|
|
2908
|
+
*/
|
|
2909
|
+
const sumEqualsRuleFor = (fieldId) =>
|
|
2910
|
+
formSettings?.fields?.find(
|
|
2911
|
+
(f) =>
|
|
2912
|
+
f.sumEquals &&
|
|
2913
|
+
Array.isArray(f.sumEquals.fields) &&
|
|
2914
|
+
f.sumEquals.fields.includes(fieldId)
|
|
2915
|
+
)?.sumEquals || null;
|
|
2916
|
+
|
|
2917
|
+
/**
|
|
2918
|
+
* Field ids a `multi-checkbox-ajax` host has claimed through
|
|
2919
|
+
* `optionFields`: those render inside the option cards, not as rows of
|
|
2920
|
+
* their own, so the main loop must skip them.
|
|
2921
|
+
*/
|
|
2922
|
+
const optionHostedFieldIds = new Set(
|
|
2923
|
+
(formSettings?.fields || []).flatMap((f) =>
|
|
2924
|
+
Array.isArray(f.optionFields) ? f.optionFields : []
|
|
2925
|
+
)
|
|
2926
|
+
);
|
|
2927
|
+
|
|
2928
|
+
/**
|
|
2929
|
+
* The field the group's allocation line renders under: the last member
|
|
2930
|
+
* that the main loop draws itself, or — when every member is nested in an
|
|
2931
|
+
* option card — the host that draws them.
|
|
2932
|
+
*/
|
|
2933
|
+
const sumEqualsAnchorId = (rule) => {
|
|
2934
|
+
const ids = Array.isArray(rule?.fields) ? rule.fields : [];
|
|
2935
|
+
const ownRows = ids.filter((id) => !optionHostedFieldIds.has(id));
|
|
2936
|
+
|
|
2937
|
+
if (ownRows.length > 0) {
|
|
2938
|
+
return ownRows[ownRows.length - 1];
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
return (
|
|
2942
|
+
(formSettings?.fields || []).find(
|
|
2943
|
+
(f) =>
|
|
2944
|
+
Array.isArray(f.optionFields) &&
|
|
2945
|
+
ids.some((id) => f.optionFields.includes(id))
|
|
2946
|
+
)?.id || null
|
|
2947
|
+
);
|
|
2948
|
+
};
|
|
2949
|
+
|
|
2950
|
+
/**
|
|
2951
|
+
* The allocation line to draw after `item`, or null. Rendered once per
|
|
2952
|
+
* group, under the group's last field (or under the checkbox host whose
|
|
2953
|
+
* cards contain them), and never while that anchor is hidden.
|
|
2954
|
+
*/
|
|
2955
|
+
const renderSumEqualsSummary = (item) => {
|
|
2956
|
+
const rules = (formSettings?.fields || [])
|
|
2957
|
+
.map((f) => f.sumEquals)
|
|
2958
|
+
.filter(Boolean)
|
|
2959
|
+
.filter((rule) => sumEqualsAnchorId(rule) === item.id);
|
|
2960
|
+
|
|
2961
|
+
if (rules.length === 0) {
|
|
2962
|
+
return null;
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
return rules.map((rule, i) => {
|
|
2966
|
+
// A group whose fields are all hidden says nothing: the section
|
|
2967
|
+
// it belongs to has not been revealed yet
|
|
2968
|
+
const anyVisible = (rule.fields || []).some(
|
|
2969
|
+
(id) =>
|
|
2970
|
+
formSettings.fields.find((f) => f.id === id)?.hide !== true
|
|
2971
|
+
);
|
|
2972
|
+
|
|
2973
|
+
if (!anyVisible) {
|
|
2974
|
+
return null;
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
return (
|
|
2978
|
+
<SumEqualsSummary
|
|
2979
|
+
key={`${item.id}-sumequals-${i}`}
|
|
2980
|
+
rule={rule}
|
|
2981
|
+
formData={formData}
|
|
2982
|
+
fields={formSettings.fields}
|
|
2983
|
+
/>
|
|
2984
|
+
);
|
|
2985
|
+
});
|
|
2986
|
+
};
|
|
2987
|
+
|
|
2988
|
+
/**
|
|
2989
|
+
* `item`, with the remainder offered as its placeholder when it is the
|
|
2990
|
+
* one field of its `sumEquals` group still empty. Returns the original
|
|
2991
|
+
* object when there is nothing to add, so React sees no new prop.
|
|
2992
|
+
*/
|
|
2993
|
+
const withSumEqualsHint = (item) => {
|
|
2994
|
+
const rule = sumEqualsRuleFor(item.id);
|
|
2995
|
+
|
|
2996
|
+
if (!rule) {
|
|
2997
|
+
return item;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
const hint = sumEqualsHintFor(item.id, rule, formData);
|
|
3001
|
+
|
|
3002
|
+
if (hint === undefined) {
|
|
3003
|
+
return item;
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
return { ...item, placeholder: hint };
|
|
3007
|
+
};
|
|
3008
|
+
|
|
3009
|
+
/**
|
|
3010
|
+
* One `<Field>`, wired to this form's state and handlers. Single factory
|
|
3011
|
+
* so a field rendered inside an option card (see renderCompanionField)
|
|
3012
|
+
* is wired identically to one rendered on a row of its own.
|
|
3013
|
+
*/
|
|
3014
|
+
const renderFormField = (item, key) => (
|
|
3015
|
+
<Field
|
|
3016
|
+
api={api}
|
|
3017
|
+
autocompleteCallback={autocompleteSelect}
|
|
3018
|
+
childDropdownCallback={childDropdownCallback}
|
|
3019
|
+
fetchData={fetchData}
|
|
3020
|
+
formData={formData}
|
|
3021
|
+
formSettings={formSettings}
|
|
3022
|
+
inputClass={inputClass}
|
|
3023
|
+
inputValue={renderInputValue(item, formData)}
|
|
3024
|
+
key={key}
|
|
3025
|
+
mapbox={mapbox}
|
|
3026
|
+
onChange={handleChange}
|
|
3027
|
+
onChangeCanvas={handleChangeCanvas}
|
|
3028
|
+
onChangeCheckbox={handleChangeCheckbox}
|
|
3029
|
+
onChangeCheckboxManual={handleChangeCheckboxManual}
|
|
3030
|
+
onChangeColour={handleChangeColour}
|
|
3031
|
+
onChangeDate={handleChangeDate}
|
|
3032
|
+
onChangeNumberFormat={handleChangeNumberFormat}
|
|
3033
|
+
onChangeRicheditor={handleChangeRicheditor}
|
|
3034
|
+
onChangeSignature={handleChangeSignature}
|
|
3035
|
+
onChangeSelect={handleChangeSelect}
|
|
3036
|
+
onChangeToggle={handleChangeToggle}
|
|
3037
|
+
onChangeJsonTable={handleChangeJsonTable}
|
|
3038
|
+
onAddJsonTableRow={handleAddJsonTableRow}
|
|
3039
|
+
onDeleteJsonTableRow={handleDeleteJsonTableRow}
|
|
3040
|
+
onFileDownload={handleFileDownload}
|
|
3041
|
+
renderOptionField={renderCompanionField}
|
|
3042
|
+
settings={item}
|
|
3043
|
+
setFormData={setFormData}
|
|
3044
|
+
style={style}
|
|
3045
|
+
uploadProgress={uploadProgress}
|
|
3046
|
+
userProfile={userProfile}
|
|
3047
|
+
/>
|
|
3048
|
+
);
|
|
3049
|
+
|
|
3050
|
+
/**
|
|
3051
|
+
* Render one of this form's fields somewhere other than its own row —
|
|
3052
|
+
* today, inside a `multi-checkbox-ajax` option card (see `optionFields`
|
|
3053
|
+
* in MultiCheckbox). It is the SAME field: same id, same formData entry,
|
|
3054
|
+
* same handlers, same validation and same `sumEquals` membership. Only
|
|
3055
|
+
* the mount point differs, so there is no second source of truth.
|
|
3056
|
+
*
|
|
3057
|
+
* @param {string} fieldId
|
|
3058
|
+
* @param {{readOnly?: boolean}} options host state to inherit
|
|
3059
|
+
*/
|
|
3060
|
+
const renderCompanionField = (fieldId, options = {}) => {
|
|
3061
|
+
const field = formSettings?.fields?.find((f) => f.id === fieldId);
|
|
3062
|
+
|
|
3063
|
+
// A field the `show` rules have not revealed stays hidden wherever it
|
|
3064
|
+
// is mounted — ticking one box must not surface the qty inputs
|
|
3065
|
+
if (!field || field.hide === true) {
|
|
3066
|
+
return null;
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
const settings = {
|
|
3070
|
+
...withSumEqualsHint(field),
|
|
3071
|
+
// The card is the field's context ("Station A"), so the label
|
|
3072
|
+
// inside it can be short. The full label stays the group's
|
|
3073
|
+
// accessible name below.
|
|
3074
|
+
label: field.compactLabel || field.label,
|
|
3075
|
+
// Companion fields fill their card, whatever width they were
|
|
3076
|
+
// given for a row of their own
|
|
3077
|
+
size: 'full',
|
|
3078
|
+
// A read-only host locks what it contains
|
|
3079
|
+
readOnly: field.readOnly === true || options.readOnly === true,
|
|
3080
|
+
// Cards do not nest: a companion never hosts companions of
|
|
3081
|
+
// its own
|
|
3082
|
+
optionFields: undefined,
|
|
3083
|
+
};
|
|
3084
|
+
|
|
3085
|
+
return (
|
|
3086
|
+
<div role="group" aria-label={field.label}>
|
|
3087
|
+
{renderFormField(settings, `companion-${fieldId}`)}
|
|
3088
|
+
</div>
|
|
3089
|
+
);
|
|
3090
|
+
};
|
|
3091
|
+
|
|
2902
3092
|
if (type && type === 'inline') {
|
|
2903
3093
|
return (
|
|
2904
3094
|
<div className={styles.formcontainer}>
|
|
@@ -2957,67 +3147,36 @@ function Form({
|
|
|
2957
3147
|
/>
|
|
2958
3148
|
</div>
|
|
2959
3149
|
);
|
|
2960
|
-
default:
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
3150
|
+
default: {
|
|
3151
|
+
// Drawn inside a checkbox option card
|
|
3152
|
+
// instead of on a row of its own
|
|
3153
|
+
if (optionHostedFieldIds.has(item.id)) {
|
|
3154
|
+
return null;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
const key =
|
|
3158
|
+
index +
|
|
3159
|
+
'-fields-' +
|
|
3160
|
+
(item.hasOwnProperty('key')
|
|
3161
|
+
? item.id + '-' + item.key
|
|
3162
|
+
: item.id);
|
|
3163
|
+
const summary = renderSumEqualsSummary(item);
|
|
3164
|
+
|
|
3165
|
+
return summary ? (
|
|
3166
|
+
<React.Fragment key={`${key}-group`}>
|
|
3167
|
+
{renderFormField(
|
|
3168
|
+
withSumEqualsHint(item),
|
|
3169
|
+
key
|
|
2977
3170
|
)}
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
mapbox={mapbox}
|
|
2986
|
-
onChange={handleChange}
|
|
2987
|
-
onChangeCanvas={handleChangeCanvas}
|
|
2988
|
-
onChangeCheckbox={handleChangeCheckbox}
|
|
2989
|
-
onChangeCheckboxManual={
|
|
2990
|
-
handleChangeCheckboxManual
|
|
2991
|
-
}
|
|
2992
|
-
onChangeDate={handleChangeDate}
|
|
2993
|
-
onChangeNumberFormat={
|
|
2994
|
-
handleChangeNumberFormat
|
|
2995
|
-
}
|
|
2996
|
-
onChangeRicheditor={
|
|
2997
|
-
handleChangeRicheditor
|
|
2998
|
-
}
|
|
2999
|
-
onChangeSelect={handleChangeSelect}
|
|
3000
|
-
onChangeSignature={
|
|
3001
|
-
handleChangeSignature
|
|
3002
|
-
}
|
|
3003
|
-
onChangeToggle={handleChangeToggle}
|
|
3004
|
-
onChangeJsonTable={
|
|
3005
|
-
handleChangeJsonTable
|
|
3006
|
-
}
|
|
3007
|
-
onAddJsonTableRow={
|
|
3008
|
-
handleAddJsonTableRow
|
|
3009
|
-
}
|
|
3010
|
-
onDeleteJsonTableRow={
|
|
3011
|
-
handleDeleteJsonTableRow
|
|
3012
|
-
}
|
|
3013
|
-
onFileDownload={handleFileDownload}
|
|
3014
|
-
settings={item}
|
|
3015
|
-
setFormData={setFormData}
|
|
3016
|
-
style={style}
|
|
3017
|
-
uploadProgress={uploadProgress}
|
|
3018
|
-
userProfile={userProfile}
|
|
3019
|
-
/>
|
|
3171
|
+
{summary}
|
|
3172
|
+
</React.Fragment>
|
|
3173
|
+
) : (
|
|
3174
|
+
renderFormField(
|
|
3175
|
+
withSumEqualsHint(item),
|
|
3176
|
+
key
|
|
3177
|
+
)
|
|
3020
3178
|
);
|
|
3179
|
+
}
|
|
3021
3180
|
}
|
|
3022
3181
|
})}
|
|
3023
3182
|
<div className={`${styles.formItem} ${styles.fwItem}`}>
|
|
@@ -3627,68 +3786,34 @@ function Form({
|
|
|
3627
3786
|
return null;
|
|
3628
3787
|
}
|
|
3629
3788
|
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3789
|
+
// Drawn inside a checkbox option card
|
|
3790
|
+
// instead of on a row of its own
|
|
3791
|
+
if (optionHostedFieldIds.has(item.id)) {
|
|
3792
|
+
return null;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
const key =
|
|
3796
|
+
index +
|
|
3797
|
+
'-fields-' +
|
|
3798
|
+
(item.hasOwnProperty('key')
|
|
3799
|
+
? item.id + '-' + item.key
|
|
3800
|
+
: item.id);
|
|
3801
|
+
const summary =
|
|
3802
|
+
renderSumEqualsSummary(item);
|
|
3803
|
+
|
|
3804
|
+
return summary ? (
|
|
3805
|
+
<React.Fragment key={`${key}-group`}>
|
|
3806
|
+
{renderFormField(
|
|
3807
|
+
withSumEqualsHint(item),
|
|
3808
|
+
key
|
|
3646
3809
|
)}
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
mapbox={mapbox}
|
|
3655
|
-
onChange={handleChange}
|
|
3656
|
-
onChangeCanvas={handleChangeCanvas}
|
|
3657
|
-
onChangeCheckbox={
|
|
3658
|
-
handleChangeCheckbox
|
|
3659
|
-
}
|
|
3660
|
-
onChangeCheckboxManual={
|
|
3661
|
-
handleChangeCheckboxManual
|
|
3662
|
-
}
|
|
3663
|
-
onChangeColour={handleChangeColour}
|
|
3664
|
-
onChangeDate={handleChangeDate}
|
|
3665
|
-
onChangeNumberFormat={
|
|
3666
|
-
handleChangeNumberFormat
|
|
3667
|
-
}
|
|
3668
|
-
onChangeRicheditor={
|
|
3669
|
-
handleChangeRicheditor
|
|
3670
|
-
}
|
|
3671
|
-
onChangeSignature={
|
|
3672
|
-
handleChangeSignature
|
|
3673
|
-
}
|
|
3674
|
-
onChangeSelect={handleChangeSelect}
|
|
3675
|
-
onChangeToggle={handleChangeToggle}
|
|
3676
|
-
onChangeJsonTable={
|
|
3677
|
-
handleChangeJsonTable
|
|
3678
|
-
}
|
|
3679
|
-
onAddJsonTableRow={
|
|
3680
|
-
handleAddJsonTableRow
|
|
3681
|
-
}
|
|
3682
|
-
onDeleteJsonTableRow={
|
|
3683
|
-
handleDeleteJsonTableRow
|
|
3684
|
-
}
|
|
3685
|
-
onFileDownload={handleFileDownload}
|
|
3686
|
-
settings={item}
|
|
3687
|
-
setFormData={setFormData}
|
|
3688
|
-
style={style}
|
|
3689
|
-
uploadProgress={uploadProgress}
|
|
3690
|
-
userProfile={userProfile}
|
|
3691
|
-
/>
|
|
3810
|
+
{summary}
|
|
3811
|
+
</React.Fragment>
|
|
3812
|
+
) : (
|
|
3813
|
+
renderFormField(
|
|
3814
|
+
withSumEqualsHint(item),
|
|
3815
|
+
key
|
|
3816
|
+
)
|
|
3692
3817
|
);
|
|
3693
3818
|
})}
|
|
3694
3819
|
<div
|
|
@@ -21,6 +21,26 @@ function MultiCheckbox({
|
|
|
21
21
|
// gap the radio renderers had). Disabling the boxes also takes the
|
|
22
22
|
// create/edit affordances away — they mutate the option list.
|
|
23
23
|
disabled = false,
|
|
24
|
+
/**
|
|
25
|
+
* Companion form fields to draw INSIDE the option cards, so each option
|
|
26
|
+
* reads as one self-contained unit ("Station A [✓] Qty 25") rather than a
|
|
27
|
+
* detached row of inputs below the group.
|
|
28
|
+
*
|
|
29
|
+
* optionFields: ['split_station_qty_1', 'split_station_qty_2']
|
|
30
|
+
*
|
|
31
|
+
* Positional against the SELECTED options **in the order the options are
|
|
32
|
+
* rendered**, not the order they were clicked — so the mapping is stable
|
|
33
|
+
* however the operator ticks the boxes, and lines up with a backend
|
|
34
|
+
* reading the same list in the same order.
|
|
35
|
+
*
|
|
36
|
+
* The fields are the form's own: `renderOptionField(fieldId)` mounts the
|
|
37
|
+
* real field (same id, same value, same validation, same sumEquals
|
|
38
|
+
* membership), so there is no second source of truth. It returns null for
|
|
39
|
+
* a field the form's `show` rules have not revealed — which is what keeps
|
|
40
|
+
* the quantity inputs hidden until a second box is ticked.
|
|
41
|
+
*/
|
|
42
|
+
optionFields = [],
|
|
43
|
+
renderOptionField,
|
|
24
44
|
}) {
|
|
25
45
|
const [checkboxOptions, setCheckboxOptions] = useState([]);
|
|
26
46
|
const [selectedValues, setSelectedValues] = useState([]);
|
|
@@ -191,6 +211,28 @@ function MultiCheckbox({
|
|
|
191
211
|
|
|
192
212
|
const maxColumns = settings.maxColumns || 3;
|
|
193
213
|
|
|
214
|
+
/**
|
|
215
|
+
* The companion field id for an option: its position among the SELECTED
|
|
216
|
+
* options, counted in render order (see the `optionFields` docs above).
|
|
217
|
+
* Null when the option is unselected, unmapped, or nothing was supplied
|
|
218
|
+
* to render it with.
|
|
219
|
+
*/
|
|
220
|
+
const selectedInRenderOrder = filteredOptions.filter((option) =>
|
|
221
|
+
isSelected(option.id)
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const optionFieldIdFor = (option) => {
|
|
225
|
+
if (!renderOptionField || optionFields.length === 0) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const position = selectedInRenderOrder.findIndex(
|
|
230
|
+
(selected) => String(selected.id) === String(option.id)
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return position >= 0 ? optionFields[position] ?? null : null;
|
|
234
|
+
};
|
|
235
|
+
|
|
194
236
|
return (
|
|
195
237
|
<div className={`${styles.multiCheckbox} ${className || ''}`} style={style}>
|
|
196
238
|
|
|
@@ -257,8 +299,19 @@ function MultiCheckbox({
|
|
|
257
299
|
gridTemplateColumns: `repeat(${maxColumns}, 1fr)`
|
|
258
300
|
}}
|
|
259
301
|
>
|
|
260
|
-
{filteredOptions.map((option, index) =>
|
|
261
|
-
|
|
302
|
+
{filteredOptions.map((option, index) => {
|
|
303
|
+
const optionFieldId = optionFieldIdFor(option);
|
|
304
|
+
const optionField = optionFieldId
|
|
305
|
+
? renderOptionField(optionFieldId, option)
|
|
306
|
+
: null;
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<div
|
|
310
|
+
key={option.id || `option-${index}`}
|
|
311
|
+
className={`${styles.checkboxItem} ${
|
|
312
|
+
optionField ? styles.hasOptionField : ''
|
|
313
|
+
}`}
|
|
314
|
+
>
|
|
262
315
|
{isEditing === option.id ? (
|
|
263
316
|
<div className={styles.editForm}>
|
|
264
317
|
<input
|
|
@@ -322,8 +375,21 @@ function MultiCheckbox({
|
|
|
322
375
|
)}
|
|
323
376
|
</label>
|
|
324
377
|
)}
|
|
378
|
+
{/*
|
|
379
|
+
* Outside the <label> on purpose: an input nested in
|
|
380
|
+
* a label toggles that label's checkbox, so typing a
|
|
381
|
+
* quantity would untick the station it belongs to.
|
|
382
|
+
* Sitting here also gives the natural tab order —
|
|
383
|
+
* checkbox, its own quantity, then the next card.
|
|
384
|
+
*/}
|
|
385
|
+
{optionField && (
|
|
386
|
+
<div className={styles.optionField}>
|
|
387
|
+
{optionField}
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
325
390
|
</div>
|
|
326
|
-
|
|
391
|
+
);
|
|
392
|
+
})}
|
|
327
393
|
</div>
|
|
328
394
|
|
|
329
395
|
{/* No options message */}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import styles from './styles/SumEqualsSummary.module.scss';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Live feedback for a `sumEquals` field group — see the rule's documentation
|
|
6
|
+
* in Form.jsx.
|
|
7
|
+
*
|
|
8
|
+
* The rule declares a set of numeric fields that must add up to a target:
|
|
9
|
+
*
|
|
10
|
+
* sumEquals: { fields: ['qty_a', 'qty_b'], equals: 'qty_total' }
|
|
11
|
+
*
|
|
12
|
+
* At submit time Form validates it. Everything here is the progressive half:
|
|
13
|
+
* while the operator types, the group says what the target is, how much is
|
|
14
|
+
* already allocated and how much is left, and the one empty field offers the
|
|
15
|
+
* remainder as a placeholder. None of it replaces the validation — a form
|
|
16
|
+
* that never renders the summary still refuses a wrong total.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const isBlank = (value) =>
|
|
20
|
+
value === null ||
|
|
21
|
+
value === undefined ||
|
|
22
|
+
value === '' ||
|
|
23
|
+
isNaN(Number(value));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The group's running state, or null when the rule names no fields.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} rule the field's `sumEquals` declaration
|
|
29
|
+
* @param {object} formData current form values
|
|
30
|
+
* @returns {{
|
|
31
|
+
* ids: string[], target: number, hasTarget: boolean, sum: number,
|
|
32
|
+
* remaining: number|null, entered: number, emptyIds: string[],
|
|
33
|
+
* status: 'under'|'balanced'|'over'|'unknown'
|
|
34
|
+
* }|null}
|
|
35
|
+
*/
|
|
36
|
+
export const computeSumEqualsState = (rule, formData = {}) => {
|
|
37
|
+
const ids = Array.isArray(rule?.fields) ? rule.fields : [];
|
|
38
|
+
|
|
39
|
+
if (ids.length === 0) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const target =
|
|
44
|
+
typeof rule.equals === 'number'
|
|
45
|
+
? rule.equals
|
|
46
|
+
: Number(formData[rule.equals]);
|
|
47
|
+
const hasTarget = !isNaN(target);
|
|
48
|
+
|
|
49
|
+
const emptyIds = ids.filter((id) => isBlank(formData[id]));
|
|
50
|
+
const sum = ids.reduce(
|
|
51
|
+
(total, id) => (isBlank(formData[id]) ? total : total + Number(formData[id])),
|
|
52
|
+
0
|
|
53
|
+
);
|
|
54
|
+
const remaining = hasTarget ? target - sum : null;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
ids,
|
|
58
|
+
target,
|
|
59
|
+
hasTarget,
|
|
60
|
+
sum,
|
|
61
|
+
remaining,
|
|
62
|
+
entered: ids.length - emptyIds.length,
|
|
63
|
+
emptyIds,
|
|
64
|
+
status: !hasTarget
|
|
65
|
+
? 'unknown'
|
|
66
|
+
: remaining > 0
|
|
67
|
+
? 'under'
|
|
68
|
+
: remaining < 0
|
|
69
|
+
? 'over'
|
|
70
|
+
: 'balanced',
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The remainder to offer as `fieldId`'s placeholder, or undefined.
|
|
76
|
+
*
|
|
77
|
+
* Only when that field is the ONLY empty one in its group and something is
|
|
78
|
+
* actually left to allocate — a placeholder on two empty fields would be a
|
|
79
|
+
* guess, and one showing "0" or a negative would be wrong. Deliberately a
|
|
80
|
+
* placeholder and never a value: the operator types the number themselves.
|
|
81
|
+
*/
|
|
82
|
+
export const sumEqualsHintFor = (fieldId, rule, formData = {}) => {
|
|
83
|
+
const state = computeSumEqualsState(rule, formData);
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
!state ||
|
|
87
|
+
!state.hasTarget ||
|
|
88
|
+
state.emptyIds.length !== 1 ||
|
|
89
|
+
state.emptyIds[0] !== fieldId ||
|
|
90
|
+
state.remaining <= 0
|
|
91
|
+
) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return String(state.remaining);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One line under a `sumEquals` group:
|
|
100
|
+
*
|
|
101
|
+
* under "Item Qty 40 · 25 entered · 15 remaining" (muted)
|
|
102
|
+
* balanced "40 of 40 allocated ✓" (success)
|
|
103
|
+
* over "Item Qty 40 · 45 entered · over by 5" (error)
|
|
104
|
+
*
|
|
105
|
+
* The height is reserved in CSS so moving between the three never shifts the
|
|
106
|
+
* form. `targetLabel` names the total in the copy: the rule's own
|
|
107
|
+
* `targetLabel`, else the label of the field `equals` points at, else
|
|
108
|
+
* "Total".
|
|
109
|
+
*/
|
|
110
|
+
const SumEqualsSummary = ({ rule, formData, fields = [] }) => {
|
|
111
|
+
const state = computeSumEqualsState(rule, formData);
|
|
112
|
+
|
|
113
|
+
if (!state) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const targetLabel =
|
|
118
|
+
rule.targetLabel ||
|
|
119
|
+
fields.find((f) => f.id === rule.equals)?.label ||
|
|
120
|
+
'Total';
|
|
121
|
+
|
|
122
|
+
let text = ' ';
|
|
123
|
+
let tone = styles.neutral;
|
|
124
|
+
|
|
125
|
+
if (state.hasTarget) {
|
|
126
|
+
if (state.status === 'balanced') {
|
|
127
|
+
text = `${state.sum} of ${state.target} allocated ✓`;
|
|
128
|
+
tone = styles.balanced;
|
|
129
|
+
} else if (state.status === 'over') {
|
|
130
|
+
text = `${targetLabel} ${state.target} · ${state.sum} entered · over by ${Math.abs(
|
|
131
|
+
state.remaining
|
|
132
|
+
)}`;
|
|
133
|
+
tone = styles.over;
|
|
134
|
+
} else {
|
|
135
|
+
text = `${targetLabel} ${state.target} · ${state.sum} entered · ${state.remaining} remaining`;
|
|
136
|
+
tone = styles.neutral;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className={`${styles.summary} ${tone}`} aria-live="polite">
|
|
142
|
+
{text}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export default SumEqualsSummary;
|
|
@@ -40,6 +40,55 @@ const buildToggleUrl = (urlTemplate, stageItem, rowData) => {
|
|
|
40
40
|
return url;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Evaluate an opt-in stageConfig rule ({key, operator, value}) against one
|
|
45
|
+
* entry of a stage list. Absent rule -> false, so every setting that uses this
|
|
46
|
+
* is inert until a config asks for it.
|
|
47
|
+
*
|
|
48
|
+
* Operators: eq (default), neq, gte, gt, lte, lt, in. The comparison operators
|
|
49
|
+
* coerce to Number, because the values these rules match on arrive as strings
|
|
50
|
+
* from some payloads and as numbers from others, and a stage ordinal compared
|
|
51
|
+
* as a string sorts "10" below "2".
|
|
52
|
+
*/
|
|
53
|
+
const matchesStageRule = (rule, stageItem) => {
|
|
54
|
+
if (!rule || !rule.key || !stageItem) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const value = stageItem[rule.key];
|
|
59
|
+
|
|
60
|
+
if (value === null || value === undefined) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (rule.operator === 'in') {
|
|
65
|
+
return (
|
|
66
|
+
Array.isArray(rule.value) &&
|
|
67
|
+
rule.value.some((candidate) => candidate == value)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const left = Number(value);
|
|
72
|
+
const right = Number(rule.value);
|
|
73
|
+
const comparable = !Number.isNaN(left) && !Number.isNaN(right);
|
|
74
|
+
|
|
75
|
+
switch (rule.operator) {
|
|
76
|
+
case 'neq':
|
|
77
|
+
return value != rule.value;
|
|
78
|
+
case 'gte':
|
|
79
|
+
return comparable && left >= right;
|
|
80
|
+
case 'gt':
|
|
81
|
+
return comparable && left > right;
|
|
82
|
+
case 'lte':
|
|
83
|
+
return comparable && left <= right;
|
|
84
|
+
case 'lt':
|
|
85
|
+
return comparable && left < right;
|
|
86
|
+
case 'eq':
|
|
87
|
+
default:
|
|
88
|
+
return value == rule.value;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
43
92
|
const getStageStatus = (stageItem, toggleConfig) => {
|
|
44
93
|
if (!toggleConfig || !toggleConfig.statusField) return null;
|
|
45
94
|
|
|
@@ -2895,6 +2944,30 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
|
|
|
2895
2944
|
});
|
|
2896
2945
|
}
|
|
2897
2946
|
|
|
2947
|
+
// `completeWhen` (opt-in): the rule that says this
|
|
2948
|
+
// entry is DONE as far as the grid showing it is
|
|
2949
|
+
// concerned, drawn as a rule through the label plus a
|
|
2950
|
+
// step back in weight.
|
|
2951
|
+
//
|
|
2952
|
+
// It lives in the view config rather than in the data
|
|
2953
|
+
// because "complete" is a statement about the VIEWER,
|
|
2954
|
+
// not about the entry: the same stage list is served
|
|
2955
|
+
// unchanged to every grid, and a member sitting at
|
|
2956
|
+
// Despatch is finished business to the Despatch list
|
|
2957
|
+
// while still being work in progress to Pickling. The
|
|
2958
|
+
// grid is the only party that knows which it is.
|
|
2959
|
+
//
|
|
2960
|
+
// Not a colour, on purpose: the pill's fill already
|
|
2961
|
+
// encodes the stage, and a second hue on a tablet in
|
|
2962
|
+
// daylight is the signal least likely to survive the
|
|
2963
|
+
// reading conditions. The rule through the label is
|
|
2964
|
+
// legible in glare and in greyscale; the fill stays as
|
|
2965
|
+
// it was, so nothing is lost by adding this.
|
|
2966
|
+
const isStageComplete = matchesStageRule(
|
|
2967
|
+
column.stageConfig.completeWhen,
|
|
2968
|
+
stageItem
|
|
2969
|
+
);
|
|
2970
|
+
|
|
2898
2971
|
// Apply visual feedback for inactive stages
|
|
2899
2972
|
const stageStyle = {
|
|
2900
2973
|
width: useFullLabel ? 'auto' : '20px',
|
|
@@ -2911,7 +2984,21 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
|
|
|
2911
2984
|
fontSize: '11px',
|
|
2912
2985
|
fontWeight: 'bold',
|
|
2913
2986
|
cursor: isToggleable ? 'pointer' : 'default',
|
|
2914
|
-
|
|
2987
|
+
...(isStageComplete
|
|
2988
|
+
? {
|
|
2989
|
+
textDecoration: 'line-through',
|
|
2990
|
+
textDecorationThickness:
|
|
2991
|
+
'var(--grid-stage-complete-thickness, 2px)',
|
|
2992
|
+
}
|
|
2993
|
+
: {}),
|
|
2994
|
+
// An inactive toggle already mutes to 0.5; a
|
|
2995
|
+
// complete entry steps back a little less, so the
|
|
2996
|
+
// two states stay tellable apart when both apply
|
|
2997
|
+
opacity: !isStageActive
|
|
2998
|
+
? 0.5
|
|
2999
|
+
: isStageComplete
|
|
3000
|
+
? 'var(--grid-stage-complete-opacity, 0.65)'
|
|
3001
|
+
: 1,
|
|
2915
3002
|
transition: 'opacity 0.2s ease, transform 0.1s ease',
|
|
2916
3003
|
border: isToggleable ? '1px solid rgba(255,255,255,0.3)' : 'none',
|
|
2917
3004
|
};
|
|
@@ -1354,3 +1354,51 @@
|
|
|
1354
1354
|
}
|
|
1355
1355
|
|
|
1356
1356
|
|
|
1357
|
+
|
|
1358
|
+
/* =========================================================================
|
|
1359
|
+
Row states (`ajaxSetting.rowStyles`)
|
|
1360
|
+
|
|
1361
|
+
A row tint is the first thing to go on a plant-floor tablet: pale fills
|
|
1362
|
+
are chosen to sit under black text, which is exactly what makes them
|
|
1363
|
+
vanish under glare or a low-angle view. The rows that carry these states
|
|
1364
|
+
are the ones a crew scans a list FOR ("which of these headers is already
|
|
1365
|
+
done?"), so the state has to survive the conditions the list is read in.
|
|
1366
|
+
|
|
1367
|
+
The treatment is therefore a rule through the text plus a step back in
|
|
1368
|
+
ink weight — two signals, neither of them hue. Any `rowColours` tint on
|
|
1369
|
+
the same row is untouched and becomes the redundant third.
|
|
1370
|
+
|
|
1371
|
+
Strike and ink are applied to the CELLS, not the row: the grid lays its
|
|
1372
|
+
cells out absolutely, so `text-decoration` set on the row element has no
|
|
1373
|
+
in-flow descendants to inherit it and would draw nothing.
|
|
1374
|
+
|
|
1375
|
+
Density hooks, per the `--nav-*` / `--grid-legend-*` convention above —
|
|
1376
|
+
every declaration is `var(--hook, <the value it always had>)`, so an unset
|
|
1377
|
+
hook is a no-op and a project retunes from `:root`:
|
|
1378
|
+
|
|
1379
|
+
--grid-row-complete-ink struck-through text colour.
|
|
1380
|
+
default: var(--muted-color, #6b7688)
|
|
1381
|
+
--grid-row-complete-thickness weight of the rule through the text.
|
|
1382
|
+
default: 2px — heavier than the browser
|
|
1383
|
+
default, which is hairline at this size.
|
|
1384
|
+
--grid-row-complete-opacity applies to non-text cell content (status
|
|
1385
|
+
pills, icons), which a rule cannot cross.
|
|
1386
|
+
default: 0.7
|
|
1387
|
+
========================================================================= */
|
|
1388
|
+
|
|
1389
|
+
.rowComplete {
|
|
1390
|
+
:global(.InovuaReactDataGrid__cell) {
|
|
1391
|
+
color: var(--grid-row-complete-ink, var(--muted-color, #6b7688));
|
|
1392
|
+
text-decoration: line-through;
|
|
1393
|
+
text-decoration-thickness: var(--grid-row-complete-thickness, 2px);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// Cell content that is drawn rather than set — the action buttons, the
|
|
1397
|
+
// stage pills, the boolean ticks — takes the muting alone, since a rule
|
|
1398
|
+
// through the text cannot cross it.
|
|
1399
|
+
:global(.InovuaReactDataGrid__cell) svg,
|
|
1400
|
+
:global(.InovuaReactDataGrid__cell) img,
|
|
1401
|
+
:global(.InovuaReactDataGrid__cell) button {
|
|
1402
|
+
opacity: var(--grid-row-complete-opacity, 0.7);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
@@ -99,8 +99,65 @@
|
|
|
99
99
|
display: grid;
|
|
100
100
|
gap: 8px;
|
|
101
101
|
margin-bottom: 12px;
|
|
102
|
-
|
|
102
|
+
/*
|
|
103
|
+
* Cards carrying a companion field grow taller than bare ones.
|
|
104
|
+
* Stretching keeps every card in a row the same height, so two
|
|
105
|
+
* stations stay aligned while one of them is being filled in.
|
|
106
|
+
*/
|
|
107
|
+
align-items: stretch;
|
|
108
|
+
|
|
103
109
|
.checkboxItem {
|
|
110
|
+
/*
|
|
111
|
+
* A card is one unit: the option's checkbox, and — when
|
|
112
|
+
* `optionFields` maps one — its companion input underneath.
|
|
113
|
+
*/
|
|
114
|
+
display: flex;
|
|
115
|
+
flex-direction: column;
|
|
116
|
+
|
|
117
|
+
/*
|
|
118
|
+
* With a companion inside, the card takes the frame and the label
|
|
119
|
+
* sheds its own, so the two read as one control instead of a box
|
|
120
|
+
* inside a box.
|
|
121
|
+
*/
|
|
122
|
+
&.hasOptionField {
|
|
123
|
+
border: 1px solid var(--border-color, #e2e8f0);
|
|
124
|
+
border-radius: 6px;
|
|
125
|
+
background: var(--alternate-color, #fafbfc);
|
|
126
|
+
overflow: hidden;
|
|
127
|
+
|
|
128
|
+
> .checkboxLabel {
|
|
129
|
+
border: 0;
|
|
130
|
+
border-radius: 0;
|
|
131
|
+
background: transparent;
|
|
132
|
+
|
|
133
|
+
&:hover {
|
|
134
|
+
transform: none;
|
|
135
|
+
box-shadow: none;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.optionField {
|
|
141
|
+
padding: 0 12px 10px;
|
|
142
|
+
|
|
143
|
+
/*
|
|
144
|
+
* The companion is an ordinary form field that fills the card
|
|
145
|
+
* instead of a row. Its own error styling (`.inputError`, a
|
|
146
|
+
* red border on the input) is untouched, so a wrong quantity
|
|
147
|
+
* still reads inside the card.
|
|
148
|
+
*/
|
|
149
|
+
> div {
|
|
150
|
+
width: 100%;
|
|
151
|
+
margin: 0;
|
|
152
|
+
padding: 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
label {
|
|
156
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
157
|
+
color: var(--muted-color, #6e7276);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
104
161
|
.checkboxLabel {
|
|
105
162
|
display: flex;
|
|
106
163
|
align-items: center;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Live allocation line under a `sumEquals` field group.
|
|
3
|
+
*
|
|
4
|
+
* The height is fixed rather than content-driven: the line moves between
|
|
5
|
+
* "remaining", "allocated" and "over by" on every keystroke, and a form that
|
|
6
|
+
* grew and shrank a row underneath the inputs would push the fields around
|
|
7
|
+
* while the operator is typing into them.
|
|
8
|
+
*/
|
|
9
|
+
.summary {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
min-height: 1.25rem;
|
|
13
|
+
line-height: 1.25rem;
|
|
14
|
+
margin-top: var(--spacing-xs, 0.25rem);
|
|
15
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
16
|
+
font-weight: var(--font-weight-medium, 500);
|
|
17
|
+
white-space: nowrap;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
text-overflow: ellipsis;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Still to allocate — informational, not a problem yet. */
|
|
23
|
+
.neutral {
|
|
24
|
+
color: var(--muted-color, #6e7276);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Adds up: the same green the package uses for success elsewhere. */
|
|
28
|
+
.balanced {
|
|
29
|
+
color: var(--success-color, #28a745);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* Over the target. Matches the red `.inputError` puts on the inputs
|
|
34
|
+
* themselves, so the line and the fields read as one error.
|
|
35
|
+
*/
|
|
36
|
+
.over {
|
|
37
|
+
color: var(--danger-color, #dc3545);
|
|
38
|
+
}
|