@visns-studio/visns-components 6.0.5 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.0.5",
94
+ "version": "6.1.0",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -1305,6 +1305,11 @@ const DataGrid = forwardRef(
1305
1305
 
1306
1306
  const handleSettingClick = (s, d) => {
1307
1307
  switch (s.id) {
1308
+ case 'cloudDownload':
1309
+ // File download: open the URL directly and let the
1310
+ // browser handle the response's content disposition.
1311
+ window.open(`${s.url}/${d[s.key]}`, '_blank');
1312
+ break;
1308
1313
  case 'activate':
1309
1314
  case 'arrowCycle':
1310
1315
  case 'cloudUpload':
@@ -3231,6 +3236,8 @@ const DataGrid = forwardRef(
3231
3236
  return getIconComponent(RefreshCw);
3232
3237
  case 'cloudUpload':
3233
3238
  return getIconComponent(Upload);
3239
+ case 'cloudDownload':
3240
+ return getIconComponent(DownloadIcon);
3234
3241
  case 'oauth2':
3235
3242
  return getIconComponent(Lock);
3236
3243
  case 'clone':
@@ -361,6 +361,7 @@ function Navigation({
361
361
  <ul className={styles.navDropdown}>
362
362
  {n.children.map(
363
363
  (child, childKey) =>
364
+ child.hidden !== true &&
364
365
  child.permission === true && (
365
366
  <li key={`nav-child-${childKey}`}>
366
367
  <Link
@@ -422,11 +423,57 @@ function Navigation({
422
423
  return [];
423
424
  };
424
425
 
426
+ /**
427
+ * Feature switches, if the consuming app publishes any.
428
+ *
429
+ * A platform owner can retire a half-finished feature from the menu
430
+ * without a deploy, so the profile response may carry a map of feature
431
+ * ids that are switched off. Apps that publish nothing get `null` and
432
+ * behave exactly as before — this filter is additive, and every unknown
433
+ * id stays visible, so a stale or missing map can never empty a menu.
434
+ */
435
+ const getFeatureFlags = () => {
436
+ const flags = userProfile?.feature_visibility;
437
+
438
+ if (!flags || typeof flags !== 'object') {
439
+ return null;
440
+ }
441
+
442
+ // The map is keyed by app; this component only ever draws the console.
443
+ const appFlags = flags.backend;
444
+
445
+ return appFlags && typeof appFlags === 'object' ? appFlags : null;
446
+ };
447
+
425
448
  useEffect(() => {
426
449
  const permissions = getUserPermissions();
450
+ const featureFlags = getFeatureFlags();
451
+
452
+ // Carried as its own flag rather than folded into `permission`: an
453
+ // item with an empty permissionKey renders whatever its permission
454
+ // says, and this state has to be reversible — switching a feature
455
+ // back on must restore the menu item without a page reload, which
456
+ // dropping it from the list would not.
457
+ const isSwitchedOff = (nav) =>
458
+ featureFlags !== null &&
459
+ Boolean(nav.id) &&
460
+ featureFlags[nav.id] === false;
427
461
 
428
462
  setNavData((prevNavData) => {
429
463
  const updatePermissions = (nav) => {
464
+ const hidden = isSwitchedOff(nav);
465
+
466
+ // Reserved for the platform owner — a privilege that sits
467
+ // above the roles an administrator can hand out, so it is
468
+ // never expressed as a permission key.
469
+ if (nav.superAdminOnly === true) {
470
+ return {
471
+ ...nav,
472
+ hidden,
473
+ permission: Boolean(userProfile?.is_super_admin),
474
+ };
475
+ }
476
+
430
477
  if (nav.children && nav.children.length > 0) {
431
478
  const updatedChildren = nav.children
432
479
  .map(updatePermissions)
@@ -439,12 +486,14 @@ function Navigation({
439
486
  );
440
487
  return {
441
488
  ...nav,
489
+ hidden,
442
490
  permission: matchingPermission,
443
491
  children: updatedChildren,
444
492
  };
445
493
  } else {
446
494
  return {
447
495
  ...nav,
496
+ hidden,
448
497
  permission: childPermission,
449
498
  children: updatedChildren,
450
499
  };
@@ -457,12 +506,14 @@ function Navigation({
457
506
  );
458
507
  return {
459
508
  ...nav,
509
+ hidden,
460
510
  permission: matchingPermission,
461
511
  };
462
512
  }
463
513
 
464
514
  return {
465
515
  ...nav,
516
+ hidden,
466
517
  permission: true,
467
518
  };
468
519
  };
@@ -620,8 +671,9 @@ function Navigation({
620
671
  <nav className={styles.navwrap} ref={navWrapRef}>
621
672
  <ul className={appNavClasses}>
622
673
  {navData.navigations.map((nav, navKey) =>
623
- nav.permission === true ||
624
- nav.permissionKey === '' ? (
674
+ nav.hidden !== true &&
675
+ (nav.permission === true ||
676
+ nav.permissionKey === '') ? (
625
677
  <React.Fragment key={`nav-item-${navKey}`}>
626
678
  {renderNav(nav)}
627
679
  </React.Fragment>
@@ -671,6 +723,7 @@ function Navigation({
671
723
  >
672
724
  <ul>
673
725
  {navData.settings.map((setting, settingKey) =>
726
+ setting.hidden !== true &&
674
727
  setting.permission === true ? (
675
728
  <React.Fragment
676
729
  key={`setting-${settingKey}`}
@@ -711,7 +764,9 @@ function Navigation({
711
764
  <div className={styles.navwrap} ref={navWrapRef}>
712
765
  <ul className={appNavClasses}>
713
766
  {navData.navigations.map((nav, navKey) =>
714
- nav.permission === true || nav.permissionKey === '' ? (
767
+ nav.hidden !== true &&
768
+ (nav.permission === true ||
769
+ nav.permissionKey === '') ? (
715
770
  <React.Fragment key={`nav-item-${navKey}`}>
716
771
  {renderNav(nav)}
717
772
  </React.Fragment>
@@ -729,6 +784,7 @@ function Navigation({
729
784
  >
730
785
  <ul>
731
786
  {navData.settings.map((setting, settingKey) =>
787
+ setting.hidden !== true &&
732
788
  setting.permission === true ? (
733
789
  <React.Fragment key={`setting-${settingKey}`}>
734
790
  {renderSetting(setting)}
@@ -190,8 +190,8 @@ const Login = ({
190
190
  aria-hidden="true"
191
191
  >
192
192
  <div className={styles.brandInk}>
193
- <p className={styles.eyebrow}>Prime Builders</p>
194
- <p className={styles.brandLine}>Prime Projects</p>
193
+ <p className={styles.eyebrow}>Construction Management</p>
194
+ <p className={styles.brandLine}>Prime Builders</p>
195
195
  <p className={styles.brandSub}>
196
196
  Job tracking, inspections, site forms and labour
197
197
  scheduling — in one place.
@@ -575,6 +575,9 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
575
575
  )
576
576
  );
577
577
  const [data, setData] = useState({});
578
+ // Per-widget load state ('loading' | 'ready' | 'failed') keyed by widget.id.
579
+ // Tracked per widget so one slow/failed request only affects its own tile.
580
+ const [widgetStatus, setWidgetStatus] = useState({});
578
581
  const [dropdowns, setDropdowns] = useState({});
579
582
  const [filters, setFilters] = useState({});
580
583
  const [editMode, setEditMode] = useState(false);
@@ -631,8 +634,23 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
631
634
  updateDashboardSetting(dashboardSetting);
632
635
  }, [dashboardSetting]);
633
636
 
637
+ // A widget only fetches when it has both a url and a method — anything else
638
+ // never loads, so it must never be marked 'loading'.
639
+ const widgetFetches = (widget) => Boolean(widget.api?.url && widget.api?.method);
640
+
634
641
  const fetchData = async (appliedFilters = {}) => {
635
642
  try {
643
+ const loadingStatus = dashboardSetting.widgets.reduce(
644
+ (acc, widget) => {
645
+ acc[widget.id] = widgetFetches(widget)
646
+ ? 'loading'
647
+ : 'ready';
648
+ return acc;
649
+ },
650
+ {}
651
+ );
652
+ setWidgetStatus((prev) => ({ ...prev, ...loadingStatus }));
653
+
636
654
  const promises = dashboardSetting.widgets.map(async (widget) => {
637
655
  const widgetFilters = appliedFilters[widget.id] || {};
638
656
 
@@ -714,9 +732,40 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
714
732
  },
715
733
  {}
716
734
  );
735
+ // Resolve each widget's status from the same settled results: a
736
+ // rejected promise or an API-error payload is a failure, anything
737
+ // else has finished loading.
738
+ const nextStatus = dashboardSetting.widgets.reduce(
739
+ (acc, widget, index) => {
740
+ if (!widgetFetches(widget)) {
741
+ acc[widget.id] = 'ready';
742
+ return acc;
743
+ }
744
+
745
+ const result = results[index];
746
+ acc[widget.id] =
747
+ result?.status === 'rejected' ||
748
+ result?.value?.data?.value
749
+ ? 'failed'
750
+ : 'ready';
751
+ return acc;
752
+ },
753
+ {}
754
+ );
755
+
717
756
  setData(fetchedData);
757
+ setWidgetStatus((prev) => ({ ...prev, ...nextStatus }));
718
758
  } catch (error) {
719
759
  console.error('Error fetching dashboard data:', error);
760
+ // Never leave a widget stuck on a skeleton — a hard failure of the
761
+ // whole fetch marks everything still in flight as failed.
762
+ setWidgetStatus((prev) =>
763
+ Object.keys(prev).reduce((acc, widgetId) => {
764
+ acc[widgetId] =
765
+ prev[widgetId] === 'loading' ? 'failed' : prev[widgetId];
766
+ return acc;
767
+ }, {})
768
+ );
720
769
  }
721
770
  };
722
771
 
@@ -1085,6 +1134,137 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
1085
1134
  );
1086
1135
  };
1087
1136
 
1137
+ // Placeholder shaped like the widget's real content. Every block reserves
1138
+ // the dimensions the loaded content occupies so nothing shifts on arrival.
1139
+ const renderWidgetSkeleton = (widget) => {
1140
+ const chartHeight = widget.height || '600px';
1141
+ const block = (extraClass, style, key) => (
1142
+ <div
1143
+ key={key}
1144
+ className={`${styles.skeletonBlock} ${extraClass}`}
1145
+ style={style}
1146
+ />
1147
+ );
1148
+
1149
+ const renderBody = () => {
1150
+ switch (widget.type) {
1151
+ case 'counter':
1152
+ return (
1153
+ <>
1154
+ <div className={styles.skeletonCounter}>
1155
+ {block(styles.skeletonCounterValue)}
1156
+ </div>
1157
+ {widget.button && (
1158
+ <div className={styles.skeletonButtonRow}>
1159
+ {block(styles.skeletonButton)}
1160
+ </div>
1161
+ )}
1162
+ </>
1163
+ );
1164
+ case 'pie':
1165
+ return (
1166
+ <div
1167
+ className={styles.skeletonChart}
1168
+ style={{ height: chartHeight }}
1169
+ >
1170
+ {block(styles.skeletonCircle)}
1171
+ </div>
1172
+ );
1173
+ case 'bar':
1174
+ case 'line':
1175
+ return (
1176
+ <div
1177
+ className={`${styles.skeletonChart} ${styles.skeletonChartStrips}`}
1178
+ style={{ height: chartHeight }}
1179
+ >
1180
+ {[70, 45, 88, 58, 34].map((width, index) =>
1181
+ block(
1182
+ styles.skeletonStrip,
1183
+ { width: `${width}%` },
1184
+ `${widget.id}-skeleton-strip-${index}`
1185
+ )
1186
+ )}
1187
+ </div>
1188
+ );
1189
+ case 'table':
1190
+ return (
1191
+ <div
1192
+ className={styles.skeletonTable}
1193
+ style={
1194
+ widget.height
1195
+ ? {
1196
+ maxHeight: widget.height,
1197
+ overflow: 'hidden',
1198
+ }
1199
+ : undefined
1200
+ }
1201
+ >
1202
+ {block(styles.skeletonTableHeader)}
1203
+ {[0, 1, 2, 3, 4].map((index) =>
1204
+ block(
1205
+ styles.skeletonTableRow,
1206
+ undefined,
1207
+ `${widget.id}-skeleton-row-${index}`
1208
+ )
1209
+ )}
1210
+ </div>
1211
+ );
1212
+ case 'list':
1213
+ return (
1214
+ <div className={styles.skeletonList}>
1215
+ {[0, 1, 2, 3].map((index) =>
1216
+ block(
1217
+ styles.skeletonListRow,
1218
+ undefined,
1219
+ `${widget.id}-skeleton-list-${index}`
1220
+ )
1221
+ )}
1222
+ </div>
1223
+ );
1224
+ case 'timeline':
1225
+ return block(styles.skeletonTimeline, {
1226
+ height: widget.height || '500px',
1227
+ });
1228
+ default:
1229
+ return block(styles.skeletonGeneric);
1230
+ }
1231
+ };
1232
+
1233
+ return (
1234
+ <div
1235
+ className={styles.skeleton}
1236
+ role="status"
1237
+ aria-busy="true"
1238
+ aria-label={`Loading ${widget.title || 'widget'}`}
1239
+ >
1240
+ {renderBody()}
1241
+ </div>
1242
+ );
1243
+ };
1244
+
1245
+ // Skeletons show only until a widget has data for the first time: a
1246
+ // background auto-refresh must never blank a value someone is reading.
1247
+ const renderWidgetBody = (widget) => {
1248
+ const status = widgetStatus[widget.id];
1249
+ const hasLoadedOnce = data[widget.id] !== undefined;
1250
+
1251
+ if (!hasLoadedOnce) {
1252
+ if (status === 'loading') {
1253
+ return renderWidgetSkeleton(widget);
1254
+ }
1255
+
1256
+ if (status === 'failed') {
1257
+ return (
1258
+ <div className={styles.noData}>
1259
+ Couldn&apos;t load this widget.
1260
+ </div>
1261
+ );
1262
+ }
1263
+ }
1264
+
1265
+ return renderWidget(widget);
1266
+ };
1267
+
1088
1268
  const renderWidget = (widget) => {
1089
1269
  const widgetData = data[widget.id] || [];
1090
1270
 
@@ -1914,7 +2094,7 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
1914
2094
  )}
1915
2095
  </div>
1916
2096
  {renderFilters(widget)}
1917
- <div>{renderWidget(widget)}</div>
2097
+ <div>{renderWidgetBody(widget)}</div>
1918
2098
  {editMode && (
1919
2099
  <div className={styles.widgetTools}>
1920
2100
  <div className={styles.resizeTools}>
@@ -50,6 +50,16 @@ import Breadcrumb from '../Breadcrumb';
50
50
  import CustomFetch from '../Fetch';
51
51
  import SketchConfig from '../sketch/json/config.json';
52
52
  import ConditionalDisplayEditor from './ConditionalDisplayEditor';
53
+ import {
54
+ OutstandingOptionToggle,
55
+ OutstandingRuleHint,
56
+ applyOutstandingRule,
57
+ deriveOutstandingSelection,
58
+ isOutstandingConfigurable,
59
+ remapOutstandingWhen,
60
+ staleOutstandingIds,
61
+ summariseOutstandingRule,
62
+ } from './OutstandingRuleEditor';
53
63
 
54
64
  import styles from '../styles/GenericFormBuilder.module.scss'; // Import the CSS module
55
65
 
@@ -57,8 +67,19 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
57
67
  const editorRef = useRef(null);
58
68
  const routeParams = useParams();
59
69
 
60
- const { dynamicDropdowns, fetchUrl, fields, parentUrl, formTitle } =
61
- setting;
70
+ const {
71
+ dynamicDropdowns,
72
+ fetchUrl,
73
+ fields,
74
+ parentUrl,
75
+ formTitle,
76
+ /**
77
+ * Opt-in, per-question "outstanding item" rules. Absent for every app
78
+ * that does not model outstanding items, in which case none of the
79
+ * `outstanding_when` handling below does anything at all.
80
+ */
81
+ outstandingItems,
82
+ } = setting;
62
83
 
63
84
  const { dataId } = useParams();
64
85
  const [data, setData] = useState({});
@@ -115,6 +136,17 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
115
136
  key: '',
116
137
  });
117
138
  const [modalFormShow, setModalFormShow] = useState(false);
139
+ /**
140
+ * The open field modal's outstanding-item switch. `touched` is false until
141
+ * the author actually flips one, and until then the displayed state is
142
+ * derived from the field on every render — so a brand-new question picks up
143
+ * the standard rule as soon as its answers exist, rather than being frozen
144
+ * at whatever was true when the modal opened.
145
+ */
146
+ const [outstandingRule, setOutstandingRule] = useState({
147
+ touched: false,
148
+ id: null,
149
+ });
118
150
  const [roles, setRoles] = useState([]);
119
151
  const [hoveredField, setHoveredField] = useState(null);
120
152
  const [activeId, setActiveId] = useState(null);
@@ -148,6 +180,37 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
148
180
  */
149
181
  const dynamicFields = data.field_source === 'dynamic';
150
182
 
183
+ /** Does the open field carry an editable outstanding-item rule at all? */
184
+ const outstandingEnabled = isOutstandingConfigurable(
185
+ outstandingItems,
186
+ dataField.type
187
+ );
188
+
189
+ /**
190
+ * Which answer currently has its "Outstanding" switch on. Always sanitised
191
+ * against the answers the field offers right now, so renaming or deleting
192
+ * an answer can never leave a dangling id to be saved.
193
+ */
194
+ const outstandingSelection = useMemo(() => {
195
+ if (!outstandingEnabled) return null;
196
+
197
+ const candidate = outstandingRule.touched
198
+ ? outstandingRule.id
199
+ : deriveOutstandingSelection(dataField);
200
+
201
+ return (dataField.options || []).some(
202
+ (option) => option && option.id === candidate
203
+ )
204
+ ? candidate
205
+ : null;
206
+ }, [outstandingEnabled, outstandingRule, dataField]);
207
+
208
+ /** Stored ids the field no longer offers — drives the amber warning. */
209
+ const outstandingStaleIds = useMemo(
210
+ () => (outstandingEnabled ? staleOutstandingIds(dataField) : []),
211
+ [outstandingEnabled, dataField]
212
+ );
213
+
151
214
  // Generate compact field info for display in the center top
152
215
  const getFieldInfo = (field) => {
153
216
  const fieldTypeLabel =
@@ -182,6 +245,14 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
182
245
  info.push('Conditional');
183
246
  }
184
247
 
248
+ // Only surfaced for apps that opted into outstanding-item rules.
249
+ if (outstandingItems?.enabled) {
250
+ const outstandingSummary = summariseOutstandingRule(field);
251
+ if (outstandingSummary) {
252
+ info.push(outstandingSummary);
253
+ }
254
+ }
255
+
185
256
  return info.join(' • ');
186
257
  };
187
258
 
@@ -229,6 +300,13 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
229
300
  tooltip += `Show when "${field.conditional_field}" = "${field.conditional_value}"\n`;
230
301
  }
231
302
 
303
+ if (outstandingItems?.enabled) {
304
+ const outstandingSummary = summariseOutstandingRule(field);
305
+ if (outstandingSummary) {
306
+ tooltip += `\nOutstanding rule: ${outstandingSummary}\n`;
307
+ }
308
+ }
309
+
232
310
  // Add other conditional properties if they exist
233
311
  if (field.conditionalOperator) {
234
312
  tooltip += `Operator: ${field.conditionalOperator}\n`;
@@ -1027,7 +1105,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1027
1105
  textareaHeight: 'normal',
1028
1106
  conditional_field: '',
1029
1107
  conditional_value: '',
1108
+ // `outstanding_when` is deliberately NOT seeded here: absent must
1109
+ // stay absent so a field that was never configured never gains the
1110
+ // key just by being opened.
1030
1111
  }));
1112
+ setOutstandingRule({ touched: false, id: null });
1031
1113
  setModalType(() => ({
1032
1114
  type: 'create',
1033
1115
  key: '',
@@ -1047,10 +1129,28 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1047
1129
  const { name, value } = e.target;
1048
1130
  const updatedValue = name === 'id' ? slugify(value) : value;
1049
1131
 
1050
- setDataField((items) => ({
1051
- ...items,
1052
- [name]: updatedValue,
1053
- }));
1132
+ setDataField((items) => {
1133
+ const next = { ...items, [name]: updatedValue };
1134
+
1135
+ // Switching to a type that cannot carry an outstanding rule
1136
+ // would strand the key with no UI left to edit it.
1137
+ if (
1138
+ name === 'type' &&
1139
+ 'outstanding_when' in next &&
1140
+ !isOutstandingConfigurable(outstandingItems, updatedValue)
1141
+ ) {
1142
+ delete next.outstanding_when;
1143
+ }
1144
+
1145
+ return next;
1146
+ });
1147
+
1148
+ if (
1149
+ name === 'type' &&
1150
+ !isOutstandingConfigurable(outstandingItems, updatedValue)
1151
+ ) {
1152
+ setOutstandingRule({ touched: false, id: null });
1153
+ }
1054
1154
 
1055
1155
  if (name === 'label') {
1056
1156
  setDataField((items) => ({
@@ -1080,6 +1180,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1080
1180
  ...detail[key],
1081
1181
  }));
1082
1182
 
1183
+ // Untouched: the switch shows whatever the stored rule — or the
1184
+ // standard rule — implies, recomputed as the author edits.
1185
+ setOutstandingRule({ touched: false, id: null });
1186
+
1083
1187
  setModalType(() => ({
1084
1188
  type: 'update',
1085
1189
  key: key,
@@ -1158,6 +1262,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1158
1262
 
1159
1263
  field.id = formatFieldId(field.id);
1160
1264
 
1265
+ // No-op unless the consuming app enabled outstanding-item rules. Writes
1266
+ // `outstanding_when` only when the switch differs from the standard
1267
+ // rule, so opening and saving an untouched field changes nothing.
1268
+ field = applyOutstandingRule(field, outstandingSelection, outstandingItems);
1269
+
1161
1270
  let errorMessage = validateField(field);
1162
1271
 
1163
1272
  if (errorMessage === '') {
@@ -2378,6 +2487,14 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2378
2487
  </span>
2379
2488
  )}
2380
2489
  </div>
2490
+ {outstandingEnabled ? (
2491
+ <OutstandingRuleHint
2492
+ config={outstandingItems}
2493
+ staleIds={
2494
+ outstandingStaleIds
2495
+ }
2496
+ />
2497
+ ) : null}
2381
2498
  {dataField.options.length >
2382
2499
  0 ? (
2383
2500
  <div
@@ -2415,6 +2532,15 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2415
2532
  onChange={(
2416
2533
  e
2417
2534
  ) => {
2535
+ // Captured before the id is regenerated so any
2536
+ // outstanding rule pointing at it can follow.
2537
+ const previousOptionId =
2538
+ dataField
2539
+ .options[
2540
+ optionKey
2541
+ ]
2542
+ ?.id;
2543
+ const nextOptionId = `${slugify(e.target.value)}-`;
2418
2544
  const newOptions =
2419
2545
  [
2420
2546
  ...dataField.options,
@@ -2426,18 +2552,65 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2426
2552
  newOptions[
2427
2553
  optionKey
2428
2554
  ].id =
2429
- `${slugify(e.target.value)}-`;
2555
+ nextOptionId;
2430
2556
  setDataField(
2431
2557
  (
2432
2558
  items
2433
- ) => ({
2434
- ...items,
2435
- options:
2436
- newOptions,
2437
- })
2559
+ ) =>
2560
+ remapOutstandingWhen(
2561
+ {
2562
+ ...items,
2563
+ options:
2564
+ newOptions,
2565
+ },
2566
+ previousOptionId,
2567
+ nextOptionId
2568
+ )
2569
+ );
2570
+
2571
+ // An unsaved switch has to follow the
2572
+ // rename too, or it would be sanitised
2573
+ // away as a dangling id.
2574
+ setOutstandingRule(
2575
+ (
2576
+ rule
2577
+ ) =>
2578
+ rule.touched &&
2579
+ rule.id ===
2580
+ previousOptionId
2581
+ ? {
2582
+ ...rule,
2583
+ id: nextOptionId,
2584
+ }
2585
+ : rule
2438
2586
  );
2439
2587
  }}
2440
2588
  />
2589
+ {outstandingEnabled ? (
2590
+ <OutstandingOptionToggle
2591
+ optionId={
2592
+ option.id
2593
+ }
2594
+ label={
2595
+ option.label ||
2596
+ option.id
2597
+ }
2598
+ checked={
2599
+ outstandingSelection ===
2600
+ option.id
2601
+ }
2602
+ onToggle={(
2603
+ id
2604
+ ) =>
2605
+ setOutstandingRule(
2606
+ {
2607
+ touched: true,
2608
+ id,
2609
+ }
2610
+ )
2611
+ }
2612
+ />
2613
+ ) : null}
2441
2614
  <button
2442
2615
  className={
2443
2616
  styles.optionDelete
@@ -0,0 +1,313 @@
1
+ import React from 'react';
2
+ import styles from '../styles/GenericFormBuilder.module.scss';
3
+
4
+ /**
5
+ * Per-question "outstanding item" rules.
6
+ *
7
+ * OPT-IN for consuming apps that model outstanding/defect follow-up items.
8
+ * Everything here is inert unless the app's builder config carries
9
+ * `outstandingItems: { enabled: true, ... }`, so no other project is affected.
10
+ *
11
+ * The stored schema is one optional per-field key, `outstanding_when`:
12
+ *
13
+ * key absent the app's own legacy convention applies (see legacyTriggerId)
14
+ * [] this question can never raise an outstanding item
15
+ * ['no-'] outstanding when the answer is this OPTION ID
16
+ *
17
+ * Option ids are stored, never labels, because editing an option label
18
+ * regenerates its id — exactly as `conditional_value` does.
19
+ *
20
+ * The UI is a single "Outstanding" switch on each row of the field's existing
21
+ * options editor, with radio semantics: at most one answer may be marked.
22
+ */
23
+
24
+ /** Field types the editor offers when the config does not name any. */
25
+ export const DEFAULT_OUTSTANDING_TYPES = ['checkbox'];
26
+
27
+ /** Types the legacy (unconfigured) convention applies to. */
28
+ export const LEGACY_OUTSTANDING_TYPES = ['checkbox'];
29
+
30
+ /**
31
+ * The one label whose sense is inverted under the legacy convention: a "Yes"
32
+ * here means there IS something else affecting the build.
33
+ *
34
+ * Mirrors App\Support\OutstandingRule::SPECIAL_LABEL / ::SPECIAL_SIMILARITY.
35
+ */
36
+ export const SPECIAL_LABEL =
37
+ 'Other features affecting the build (provide details and photos)';
38
+ export const SPECIAL_SIMILARITY = 85;
39
+
40
+ // --------------------------------------------------------------- similar_text
41
+
42
+ /**
43
+ * Longest common substring, PHP's php_similar_str() — first-found wins on ties,
44
+ * scanning the first string outermost.
45
+ */
46
+ const similarStr = (s1, s2) => {
47
+ let max = 0;
48
+ let count = 0;
49
+ let pos1 = 0;
50
+ let pos2 = 0;
51
+
52
+ for (let p = 0; p < s1.length; p++) {
53
+ for (let q = 0; q < s2.length; q++) {
54
+ let l = 0;
55
+ while (
56
+ p + l < s1.length &&
57
+ q + l < s2.length &&
58
+ s1[p + l] === s2[q + l]
59
+ ) {
60
+ l++;
61
+ }
62
+ if (l > max) {
63
+ max = l;
64
+ count++;
65
+ pos1 = p;
66
+ pos2 = q;
67
+ }
68
+ }
69
+ }
70
+
71
+ return { pos1, pos2, max, count };
72
+ };
73
+
74
+ /** PHP's php_similar_char(): the common substring, then recurse either side. */
75
+ const similarChar = (s1, s2) => {
76
+ const { pos1, pos2, max, count } = similarStr(s1, s2);
77
+ let sum = max;
78
+
79
+ if (sum) {
80
+ if (pos1 && pos2 && count > 1) {
81
+ sum += similarChar(s1.slice(0, pos1), s2.slice(0, pos2));
82
+ }
83
+ if (pos1 + max < s1.length && pos2 + max < s2.length) {
84
+ sum += similarChar(s1.slice(pos1 + max), s2.slice(pos2 + max));
85
+ }
86
+ }
87
+
88
+ return sum;
89
+ };
90
+
91
+ /**
92
+ * PHP's similar_text($first, $second, $percent) percentage.
93
+ *
94
+ * Verified byte-for-byte against PHP 8.5 over the real label corpus plus 400
95
+ * random strings. The argument order is load-bearing — the function is not
96
+ * symmetric — and must match the backend's call exactly.
97
+ *
98
+ * (PHP compares bytes; this compares UTF-16 code units. Identical for the
99
+ * ASCII labels in use, and only ever used against an ASCII constant.)
100
+ */
101
+ export const similarTextPercent = (first, second) => {
102
+ const s1 = typeof first === 'string' ? first : '';
103
+ const s2 = typeof second === 'string' ? second : '';
104
+ const total = s1.length + s2.length;
105
+
106
+ if (total === 0) return 0;
107
+
108
+ return (similarChar(s1, s2) * 2 * 100) / total;
109
+ };
110
+
111
+ // ---------------------------------------------------------------- the config
112
+
113
+ export const outstandingTypesOf = (config) => {
114
+ const types = config?.types;
115
+ return Array.isArray(types) ? types : DEFAULT_OUTSTANDING_TYPES;
116
+ };
117
+
118
+ /** The single gate: config must enable it AND the type must store one option id. */
119
+ export const isOutstandingConfigurable = (config, fieldType) =>
120
+ !!(config && config.enabled) &&
121
+ outstandingTypesOf(config).includes(fieldType);
122
+
123
+ // ------------------------------------------------------------- the rule state
124
+
125
+ const optionsOf = (field) =>
126
+ (Array.isArray(field?.options) ? field.options : []).filter(Boolean);
127
+
128
+ const storedIds = (field) =>
129
+ Array.isArray(field?.outstanding_when) ? field.outstanding_when : null;
130
+
131
+ /**
132
+ * The legacy trigger for a field: 'yes-' for the special "Other features
133
+ * affecting the build" question, 'no-' for any other checkbox, and null for
134
+ * every other type (the legacy convention never reached them).
135
+ *
136
+ * Mirrors App\Support\OutstandingRule::legacyTrigger().
137
+ */
138
+ export const legacyTriggerId = (field) => {
139
+ if (!LEGACY_OUTSTANDING_TYPES.includes(field?.type)) return null;
140
+
141
+ return similarTextPercent(field?.label, SPECIAL_LABEL) >=
142
+ SPECIAL_SIMILARITY
143
+ ? 'yes-'
144
+ : 'no-';
145
+ };
146
+
147
+ /**
148
+ * What the legacy convention would show as toggled, which is only meaningful
149
+ * when the field actually offers that answer. A checkbox with no 'no-' option
150
+ * (or no options yet) has no legacy toggle, and the backend agrees: its trigger
151
+ * can never match a stored value.
152
+ */
153
+ export const legacyDefaultOptionId = (field) => {
154
+ const trigger = legacyTriggerId(field);
155
+ if (!trigger) return null;
156
+
157
+ return optionsOf(field).some((option) => option.id === trigger)
158
+ ? trigger
159
+ : null;
160
+ };
161
+
162
+ /**
163
+ * Which option should show toggled when the modal opens.
164
+ *
165
+ * Configured → the first stored id the field still offers. (No template today
166
+ * carries more than one; a multi-entry array shows its first and is normalised
167
+ * to a single id on save.) An explicit `[]` shows nothing toggled.
168
+ * Unconfigured → whatever the legacy convention would flag.
169
+ */
170
+ export const deriveOutstandingSelection = (field) => {
171
+ const stored = storedIds(field);
172
+
173
+ if (stored) {
174
+ const options = optionsOf(field);
175
+ return (
176
+ stored.find((id) => options.some((option) => option.id === id)) ??
177
+ null
178
+ );
179
+ }
180
+
181
+ return legacyDefaultOptionId(field);
182
+ };
183
+
184
+ /** Stored ids the field no longer offers — the rename/delete hazard. */
185
+ export const staleOutstandingIds = (field) => {
186
+ const stored = storedIds(field);
187
+ if (!stored) return [];
188
+
189
+ const options = optionsOf(field);
190
+ return stored.filter((id) => !options.some((option) => option.id === id));
191
+ };
192
+
193
+ /**
194
+ * Fold the toggle state back into the field.
195
+ *
196
+ * The key is written only when the outcome differs from what the legacy
197
+ * convention would produce, so a field nobody configured never gains it just
198
+ * because its modal was opened and saved:
199
+ *
200
+ * selection === legacy default → key deleted (including both being null)
201
+ * an answer toggled → ['<id>']
202
+ * nothing toggled, legacy would → [] (an explicit "never")
203
+ *
204
+ * A stale stored id is not toggleable, so saving normalises it to [] (checkbox)
205
+ * or an absent key (dropdown) — both meaning "never flags", which is precisely
206
+ * what an id no answer can produce already meant.
207
+ */
208
+ export const applyOutstandingRule = (field, selectedId, config) => {
209
+ if (!isOutstandingConfigurable(config, field?.type)) return field;
210
+
211
+ const selection = selectedId || null;
212
+ const next = { ...field };
213
+
214
+ if (selection === legacyDefaultOptionId(field)) {
215
+ delete next.outstanding_when;
216
+ } else {
217
+ next.outstanding_when = selection ? [selection] : [];
218
+ }
219
+
220
+ return next;
221
+ };
222
+
223
+ /**
224
+ * One-line summary for field-list chips and tooltips. Returns null when the
225
+ * field carries no rule, so callers can skip falsy results.
226
+ */
227
+ export const summariseOutstandingRule = (field) => {
228
+ const stored = storedIds(field);
229
+ if (!stored) return null;
230
+ if (stored.length === 0) return 'Never outstanding';
231
+
232
+ const options = optionsOf(field);
233
+ const labels = stored.map((id) => {
234
+ const match = options.find((option) => option.id === id);
235
+ return match ? match.label || match.id : `${id} (missing)`;
236
+ });
237
+
238
+ return `Outstanding: ${labels.join(', ')}`;
239
+ };
240
+
241
+ /**
242
+ * Remap a stored id after an option's id was regenerated from an edited label.
243
+ * Returns the field untouched (same reference) when there is nothing to remap.
244
+ */
245
+ export const remapOutstandingWhen = (field, previousId, nextId) => {
246
+ const stored = storedIds(field);
247
+
248
+ if (!stored || previousId === nextId) return field;
249
+ if (!stored.includes(previousId)) return field;
250
+
251
+ return {
252
+ ...field,
253
+ outstanding_when: stored.map((id) => (id === previousId ? nextId : id)),
254
+ };
255
+ };
256
+
257
+ // ------------------------------------------------------------------------ UI
258
+
259
+ /**
260
+ * The per-row switch. Radio semantics live in the caller: `onToggle` receives
261
+ * the id to select, or null when the row was switched off.
262
+ */
263
+ export function OutstandingOptionToggle({ optionId, label, checked, onToggle }) {
264
+ return (
265
+ <label
266
+ className={`${styles.outstandingFlag} ${
267
+ checked ? styles.outstandingFlagOn : ''
268
+ }`}
269
+ title={
270
+ checked
271
+ ? `An answer of "${label}" raises an outstanding item`
272
+ : `Mark "${label}" as the answer that raises an outstanding item`
273
+ }
274
+ >
275
+ <input
276
+ type="checkbox"
277
+ checked={checked}
278
+ onChange={() => onToggle(checked ? null : optionId)}
279
+ />
280
+ <span>Outstanding</span>
281
+ </label>
282
+ );
283
+ }
284
+
285
+ /**
286
+ * The hint shown once above the option rows, plus the amber stale-reference
287
+ * warning. Renders nothing when there is nothing to say.
288
+ */
289
+ export function OutstandingRuleHint({ config, staleIds = [] }) {
290
+ const help = config?.help;
291
+
292
+ if (!help && staleIds.length === 0) return null;
293
+
294
+ return (
295
+ <>
296
+ {help ? (
297
+ <div className={styles.outstandingHint}>{help}</div>
298
+ ) : null}
299
+
300
+ {staleIds.length > 0 ? (
301
+ <div className={styles.outstandingStale}>
302
+ {staleIds.length === 1
303
+ ? 'The answer this question’s outstanding rule refers to no longer exists'
304
+ : 'Answers this question’s outstanding rule refers to no longer exist'}{' '}
305
+ ({staleIds.join(', ')}) — editing an answer’s label gives it
306
+ a new id. Set the switch on the right answer, or leave every
307
+ switch off to mean “never outstanding”. Saving tidies this
308
+ up.
309
+ </div>
310
+ ) : null}
311
+ </>
312
+ );
313
+ }
@@ -814,3 +814,158 @@
814
814
  color: #888;
815
815
  padding: 20px;
816
816
  }
817
+
818
+ /* ---------------------------------------------------------------------------
819
+ Loading skeletons
820
+ Neutral greys only (no brand colours) so every tenant gets a safe default.
821
+ Keyframes are uniquely prefixed to avoid colliding with host app globals.
822
+ --------------------------------------------------------------------------- */
823
+ @keyframes visns-dash-skeleton-sweep {
824
+ 0% {
825
+ background-position: 150% 0;
826
+ }
827
+ 100% {
828
+ background-position: -150% 0;
829
+ }
830
+ }
831
+
832
+ .skeleton {
833
+ width: 100%;
834
+ box-sizing: border-box;
835
+ }
836
+
837
+ .skeletonBlock {
838
+ display: block;
839
+ box-sizing: border-box;
840
+ border-radius: var(--br, 5px);
841
+ background-color: rgba(0, 0, 0, 0.06);
842
+ background-image: linear-gradient(
843
+ 90deg,
844
+ rgba(0, 0, 0, 0) 0%,
845
+ rgba(0, 0, 0, 0.05) 50%,
846
+ rgba(0, 0, 0, 0) 100%
847
+ );
848
+ background-repeat: no-repeat;
849
+ background-size: 150% 100%;
850
+ animation: visns-dash-skeleton-sweep 1.4s ease-in-out infinite;
851
+ }
852
+
853
+ /* Counter: matches .widgetNo (2rem bottom padding, 3em value) */
854
+ .skeletonCounter {
855
+ width: 100%;
856
+ padding: 0 0 2rem 0;
857
+ box-sizing: border-box;
858
+ }
859
+
860
+ .skeletonCounterValue {
861
+ width: 55%;
862
+ max-width: 220px;
863
+ height: 3.6rem;
864
+ margin: 0 auto;
865
+ }
866
+
867
+ /* Counter button: matches .widgetLink .btn */
868
+ .skeletonButtonRow {
869
+ width: 100%;
870
+ display: block;
871
+ }
872
+
873
+ .skeletonButton {
874
+ width: 180px;
875
+ max-width: 100%;
876
+ height: 34px;
877
+ margin: 0 auto;
878
+ }
879
+
880
+ /* Charts: the wrapper takes widget.height (or the 600px chart default) inline */
881
+ .skeletonChart {
882
+ width: 100%;
883
+ display: flex;
884
+ flex-direction: column;
885
+ align-items: center;
886
+ justify-content: center;
887
+ box-sizing: border-box;
888
+ padding: 20px 0;
889
+ }
890
+
891
+ .skeletonChartStrips {
892
+ align-items: flex-start;
893
+ gap: 14px;
894
+ }
895
+
896
+ .skeletonStrip {
897
+ height: 16px;
898
+ min-height: 16px;
899
+ }
900
+
901
+ .skeletonCircle {
902
+ width: 60%;
903
+ max-width: 260px;
904
+ aspect-ratio: 1 / 1;
905
+ border-radius: 50%;
906
+ }
907
+
908
+ /* Table: header 44px / rows 38px, matching .table-container th and td */
909
+ .skeletonTable {
910
+ width: 100%;
911
+ display: flex;
912
+ flex-direction: column;
913
+ gap: 1px;
914
+ box-sizing: border-box;
915
+ border: 1px solid #dadce0;
916
+ border-radius: var(--br, 5px);
917
+ overflow: hidden;
918
+ }
919
+
920
+ .skeletonTableHeader {
921
+ width: 100%;
922
+ height: 44px;
923
+ border-radius: 0;
924
+ }
925
+
926
+ .skeletonTableRow {
927
+ width: 100%;
928
+ height: 38px;
929
+ border-radius: 0;
930
+ }
931
+
932
+ /* List: rows sized like .dashList li button */
933
+ .skeletonList {
934
+ width: 100%;
935
+ display: flex;
936
+ flex-direction: column;
937
+ gap: 1px;
938
+ font-size: 0.875rem;
939
+ }
940
+
941
+ .skeletonListRow {
942
+ width: 100%;
943
+ height: 30px;
944
+ border-radius: 0;
945
+
946
+ &:first-child {
947
+ border-top-left-radius: var(--br, 5px);
948
+ border-top-right-radius: var(--br, 5px);
949
+ }
950
+
951
+ &:last-child {
952
+ border-bottom-left-radius: var(--br, 5px);
953
+ border-bottom-right-radius: var(--br, 5px);
954
+ }
955
+ }
956
+
957
+ .skeletonTimeline {
958
+ width: 100%;
959
+ }
960
+
961
+ .skeletonGeneric {
962
+ width: 100%;
963
+ height: 120px;
964
+ }
965
+
966
+ /* Keep the shape, drop the motion */
967
+ @media (prefers-reduced-motion: reduce) {
968
+ .skeletonBlock {
969
+ animation: none;
970
+ }
971
+ }
@@ -2275,6 +2275,66 @@ select:not(:placeholder-shown) + .fi__span {
2275
2275
  }
2276
2276
  }
2277
2277
 
2278
+ // Outstanding item rule — a per-answer switch inside the options editor, plus
2279
+ // its hint and stale-reference warning. All of it renders only when the
2280
+ // consuming app enables `outstandingItems` in its builder config.
2281
+ .outstandingHint {
2282
+ font-size: 0.78rem;
2283
+ line-height: 1.45;
2284
+ color: rgba(var(--paragraph-color-rgb), 0.65);
2285
+ padding: 0 0 6px;
2286
+ }
2287
+
2288
+ .outstandingStale {
2289
+ background: #fff8e1;
2290
+ border: 1px solid #f4c544;
2291
+ border-radius: 6px;
2292
+ padding: 8px 12px;
2293
+ margin-bottom: 6px;
2294
+ color: #7a5c00;
2295
+ font-size: 0.8rem;
2296
+ line-height: 1.45;
2297
+ }
2298
+
2299
+ .outstandingFlag {
2300
+ flex-shrink: 0;
2301
+ display: inline-flex;
2302
+ align-items: center;
2303
+ gap: 5px;
2304
+ padding: 4px 9px;
2305
+ border: 1px solid rgba(var(--primary-rgb), 0.15);
2306
+ border-radius: 6px;
2307
+ background: white;
2308
+ font-size: 0.72rem;
2309
+ font-weight: 600;
2310
+ text-transform: uppercase;
2311
+ letter-spacing: 0.04em;
2312
+ color: rgba(var(--paragraph-color-rgb), 0.45);
2313
+ cursor: pointer;
2314
+ user-select: none;
2315
+ transition: all 0.2s ease;
2316
+
2317
+ &:hover {
2318
+ border-color: var(--primary-color);
2319
+ color: var(--paragraph-color);
2320
+ }
2321
+
2322
+ // The modal forces a 44px min-height on every input; a tick box must opt out.
2323
+ input[type='checkbox'] {
2324
+ margin: 0;
2325
+ width: auto !important;
2326
+ height: auto !important;
2327
+ min-height: 0 !important;
2328
+ cursor: pointer;
2329
+ }
2330
+ }
2331
+
2332
+ .outstandingFlagOn {
2333
+ border-color: #e0a800;
2334
+ background: #fff8e1;
2335
+ color: #7a5c00;
2336
+ }
2337
+
2278
2338
  .btnCancel {
2279
2339
  background: rgba(var(--paragraph-color-rgb), 0.08) !important;
2280
2340
  color: var(--paragraph-color) !important;