@visns-studio/visns-components 6.3.5 → 6.3.6

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 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.5",
94
+ "version": "6.3.6",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -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
  );
@@ -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
- const groupIds = Array.isArray(rule.fields)
1435
- ? rule.fields
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 (groupIds.length > 0) {
1460
+ if (state) {
1439
1461
  const labelFor = (fieldId) =>
1440
1462
  fields.find((f) => f.id === fieldId)
1441
1463
  ?.label || fieldId;
1442
- const groupLabels = groupIds
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
- groupIds.forEach((fieldId) => {
1469
+ state.ids.forEach((fieldId) => {
1463
1470
  _inputClass[fieldId] =
1464
1471
  styles.inputError;
1465
1472
  });
1466
1473
 
1467
- if (incomplete) {
1474
+ if (state.emptyIds.length > 0) {
1468
1475
  validation += `${groupLabels} are required.<br/>`;
1469
1476
  markGroup();
1470
- } else if (!isNaN(target)) {
1471
- const sum = groupValues.reduce(
1472
- (total, v) => total + Number(v),
1473
- 0
1474
- );
1475
-
1476
- if (sum !== target) {
1477
- validation +=
1478
- (rule.message ||
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
- return (
2962
- <Field
2963
- api={api}
2964
- autocompleteCallback={
2965
- autocompleteSelect
2966
- }
2967
- childDropdownCallback={
2968
- childDropdownCallback
2969
- }
2970
- fetchData={fetchData}
2971
- formData={formData}
2972
- formSettings={formSettings}
2973
- inputClass={inputClass}
2974
- inputValue={renderInputValue(
2975
- item,
2976
- formData
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
- key={
2979
- index +
2980
- '-fields-' +
2981
- (item.hasOwnProperty('key')
2982
- ? item.id + '-' + item.key
2983
- : item.id)
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
- return (
3631
- <Field
3632
- api={api}
3633
- autocompleteCallback={
3634
- autocompleteSelect
3635
- }
3636
- childDropdownCallback={
3637
- childDropdownCallback
3638
- }
3639
- fetchData={fetchData}
3640
- formData={formData}
3641
- formSettings={formSettings}
3642
- inputClass={inputClass}
3643
- inputValue={renderInputValue(
3644
- item,
3645
- formData
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
- key={
3648
- index +
3649
- '-fields-' +
3650
- (item.hasOwnProperty('key')
3651
- ? item.id + '-' + item.key
3652
- : item.id)
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
- <div key={option.id || `option-${index}`} className={styles.checkboxItem}>
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;
@@ -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
+ }