@7365admin1/layer-common 4.35.1-staging.385 → 4.36.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +10 -4
  2. package/assets/css/primitives.css +0 -43
  3. package/assets/css/robot-hero.css +2 -2
  4. package/components/AccessCardAddForm.vue +4 -5
  5. package/components/AccessCardAssignToUnitForm.vue +2 -3
  6. package/components/AccessCardPreviewDialog.vue +3 -4
  7. package/components/AppSelect.vue +3 -77
  8. package/components/CameraForm.vue +6 -20
  9. package/components/DashboardMain.vue +60 -66
  10. package/components/Dialog/UpdateMoreAction.vue +1 -11
  11. package/components/DocumentForm.vue +112 -407
  12. package/components/DocumentManagement.vue +12 -213
  13. package/components/EntryPass/QrTemplatePreview.vue +25 -21
  14. package/components/EntryPassInformation.vue +24 -119
  15. package/components/Facility/BookingSetup.vue +0 -63
  16. package/components/HidAccessLogDashboard.vue +17 -74
  17. package/components/HidIntercomManagement.vue +14 -180
  18. package/components/HidQrCodeConfiguration.vue +195 -17
  19. package/components/HidReaderManagement.vue +0 -398
  20. package/components/HidReaderUserRoster.vue +3 -44
  21. package/components/HidUserEnrollment.vue +155 -845
  22. package/components/InvitationClientForm.vue +1 -19
  23. package/components/Layout/NavigationDrawer.vue +1 -4
  24. package/components/NotificationBell.vue +7 -59
  25. package/components/Robot/BuildingMap.vue +9 -16
  26. package/components/Robot/LiftQueue.vue +4 -9
  27. package/components/Robot/MapEditor.vue +8 -1
  28. package/components/RolePermissionFormCreate.vue +3 -4
  29. package/components/RolePermissionFormPreviewUpdate.vue +5 -12
  30. package/components/RolePermissionMain.vue +0 -1
  31. package/components/ServiceProviderInvitationPrompt.vue +155 -0
  32. package/components/ServiceProviderMain.vue +30 -178
  33. package/components/VisitorForm.vue +0 -12
  34. package/components/VisitorManagement.vue +10 -285
  35. package/components/VisitorSocketPopUp.vue +1 -2
  36. package/composables/useANPRSettings.ts +15 -45
  37. package/composables/useAccessManagement.ts +0 -5
  38. package/composables/useCleaningPermission.ts +0 -2
  39. package/composables/useCommonPermission.ts +0 -32
  40. package/composables/useDocument.ts +0 -7
  41. package/composables/useFacility.ts +0 -11
  42. package/composables/useHidAmico.ts +0 -159
  43. package/composables/useHidNavigation.ts +1 -25
  44. package/composables/usePeople.ts +0 -19
  45. package/composables/usePropertyPermission.ts +0 -19
  46. package/composables/useRobotBuilding.ts +3 -22
  47. package/composables/useRobots.ts +0 -13
  48. package/composables/useSecurityPermission.ts +0 -19
  49. package/composables/useServiceProvider.ts +0 -3
  50. package/composables/useServiceProviderInvitation.ts +3 -101
  51. package/composables/useSiteEntryPassSettings.ts +0 -26
  52. package/composables/useWebUsb.ts +11 -14
  53. package/package.json +2 -2
  54. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  55. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  56. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  57. package/plugins/API.ts +0 -11
  58. package/types/document.d.ts +0 -14
  59. package/types/facility.d.ts +0 -4
  60. package/types/people.d.ts +1 -28
  61. package/utils/module-routes.ts +1 -8
  62. package/utils/nav-sections.ts +2 -8
  63. package/utils/notification-target.ts +8 -277
  64. package/utils/permission-labels.ts +2 -20
  65. package/components/ClientInvitations.vue +0 -291
  66. package/components/HidAccessPermissions.vue +0 -424
  67. package/components/HidProfileQrCode.vue +0 -332
  68. package/composables/useHidReaderSelection.ts +0 -50
  69. package/composables/useSiteCategory.ts +0 -45
  70. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
  71. package/utils/dashboard-widget-prefs.ts +0 -54
  72. package/utils/hid-permission-assignments.ts +0 -190
  73. package/utils/hid-reader-selection.ts +0 -52
  74. package/utils/occupancy-role.ts +0 -109
  75. package/utils/robot-fleet-core.ts +0 -57
  76. package/utils/sp-inviter-row.ts +0 -97
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.36.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 230f9f0: Building map editor: drag to move, resize handles, drag wall/door ends and zone corners, add/remove zone corners, turn doors and lift shafts, flip a door's swing, multi-select (Shift+click, box select), snap to grid, keyboard nudge/delete/escape and redo. A whole drag is one undo step.
8
+
3
9
  ## 4.35.0
4
10
 
5
11
  ### Minor Changes
@@ -1418,11 +1424,11 @@
1418
1424
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1419
1425
  own rule from two endpoints the console already calls, unprojected:
1420
1426
 
1421
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1422
- GET /api/roles/id/:role that membership's role document
1427
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1428
+ GET /api/roles/id/:role that membership's role document
1423
1429
 
1424
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1425
- staff = member.type === "admin" && role.type === "admin"
1430
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1431
+ staff = member.type === "admin" && role.type === "admin"
1426
1432
 
1427
1433
  `role.default` is the marker because it is the only property of a platform
1428
1434
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -775,49 +775,6 @@ select option {
775
775
  box-shadow: var(--shadow-menu);
776
776
  }
777
777
 
778
- /* The filter box of a `searchable` AppSelect. Sticky rather than scrolled
779
- away with the options: the list it filters is long enough to need scrolling,
780
- which is the only reason the box is there. */
781
- .app-select__search {
782
- position: sticky;
783
- top: -6px;
784
- z-index: 1;
785
- display: flex;
786
- align-items: center;
787
- gap: 6px;
788
- margin: -6px -6px 4px;
789
- padding: 8px 10px;
790
- border-bottom: 1px solid var(--border);
791
- background: var(--card);
792
- color: var(--muted);
793
- }
794
-
795
- .app-select__search-input {
796
- flex: 1;
797
- min-width: 0;
798
- border: none;
799
- outline: none;
800
- background: none;
801
- font-family: var(--font-sans);
802
- font-size: var(--fs-cell);
803
- font-weight: 500;
804
- color: var(--text);
805
- }
806
-
807
- .app-select__search-input::placeholder {
808
- color: var(--muted);
809
- font-weight: 500;
810
- }
811
-
812
- .app-select__empty {
813
- margin: 0;
814
- padding: 10px 8px;
815
- font-family: var(--font-sans);
816
- font-size: var(--fs-cell);
817
- color: var(--muted);
818
- text-align: center;
819
- }
820
-
821
778
  .app-select__option {
822
779
  display: flex;
823
780
  align-items: center;
@@ -951,7 +951,6 @@
951
951
  .hero-bmap__wall { stroke: var(--hero-text); stroke-width: 3; opacity: 0.8; }
952
952
  .hero-bmap__zone { fill: color-mix(in srgb, var(--hero-text) 10%, transparent); stroke: var(--hero-text); stroke-width: 1; stroke-dasharray: 4 3; }
953
953
  .hero-bmap__doorline { stroke: var(--hero-accent); stroke-width: 3; }
954
- .hero-bmap__doorline--open { stroke: rgb(var(--v-theme-success)); stroke-dasharray: 2 2; }
955
954
  .hero-bmap__anchor { position: absolute; width: 0; height: 0; transform-style: preserve-3d; }
956
955
  .hero-bmap__routetag { position: absolute; left: -6px; top: -30px; transform-origin: 0 0; white-space: nowrap; font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 800; padding: 1px 6px; background: var(--hero-accent); color: #fff; }
957
956
  .hero-bmap__cp { position: absolute; left: -6px; top: -6px; width: 12px; height: 12px; box-sizing: border-box; border: 2px solid var(--hero-accent); background: var(--hero-bg); display: grid; place-items: center; font-size: 7px; font-weight: 800; color: var(--hero-accent); }
@@ -986,7 +985,6 @@
986
985
  .hero-bmap__legend { position: absolute; left: 14px; bottom: 12px; z-index: 3; font-size: 11px; opacity: 0.65; display: flex; gap: 14px; white-space: nowrap; }
987
986
  .hero-bmap__key { display: inline-block; width: 8px; height: 8px; vertical-align: middle; margin-right: 5px; }
988
987
  .hero-bmap__key--door { background: var(--hero-accent); }
989
- .hero-bmap__key--dooropen { background: rgb(var(--v-theme-success)); }
990
988
  .hero-bmap__key--shaft { background: color-mix(in srgb, var(--hero-accent) 30%, transparent); border: 1px solid var(--hero-accent); box-sizing: border-box; }
991
989
  .hero-bmap__key--bot { background: var(--hero-text); }
992
990
  .hero-bmap__key--route { width: 12px; height: 0; border-top: 2px dashed var(--hero-accent); }
@@ -1012,6 +1010,8 @@
1012
1010
  .hero .hero-input.hero-editor__floor { width: auto; min-height: 32px; padding: 4px 26px 4px 8px; }
1013
1011
  .hero .hero-input.hero-editor__zone { width: 220px; min-height: 32px; }
1014
1012
  .hero-editor__hint { font-size: 12px; opacity: 0.7; order: 1; flex-basis: 100%; } /* own line: switching tools never moves the plan */
1013
+ .hero .v-btn.hero-editor__fs { margin-left: auto; }
1014
+ .hero .v-btn.hero-editor__fs .v-btn__content { gap: 8px; }
1015
1015
  .hero-editor__notice { padding: 10px 12px; font-size: 12.5px; border-left: 2px solid var(--hero-accent); background: var(--hero-surface); }
1016
1016
  .hero-editor__notice--ok { border-left-color: rgb(var(--v-theme-success)); }
1017
1017
  .hero-editor__split { display: flex; flex-wrap: wrap; gap: 24px; align-items: stretch; flex: 1; }
@@ -433,10 +433,7 @@ const {
433
433
  bulkPhysicalAccessCard: _bulkPhysicalAccessCard,
434
434
  getBlockLevelUnitList: _getBlockLevelUnitList,
435
435
  } = useAccessManagement();
436
- const {
437
- getBySiteId: _getEntryPassSettings,
438
- getEncryptedAcmUrl: _getEncryptedAcmUrl,
439
- } = useSiteEntryPassSettings();
436
+ const { getBySiteId: _getEntryPassSettings } = useSiteEntryPassSettings();
440
437
  const { getFileUrl } = useFile();
441
438
 
442
439
  const config = useRuntimeConfig();
@@ -591,7 +588,9 @@ watch(uploadMode, (mode) => {
591
588
 
592
589
  onMounted(async () => {
593
590
  try {
594
- const acmUrl = await _getEncryptedAcmUrl(siteId);
591
+ const { encrypted: acmUrl } = await $fetch<{ encrypted: string }>(
592
+ "/api/encrypt-acm-url"
593
+ );
595
594
  encryptedAcmUrl.value = acmUrl;
596
595
  const [doorLevels, liftLevels, groups] = await Promise.all([
597
596
  _getDoorAccessLevels(acmUrl),
@@ -203,7 +203,6 @@ const {
203
203
  getBlockLevelUnitList: _getBlockLevelUnitList,
204
204
  } = useAccessManagement();
205
205
  const { requiredRule } = useUtils();
206
- const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
207
206
 
208
207
  const route = useRoute();
209
208
  const siteId = computed(() => props.siteId || (route.params.site as string));
@@ -295,7 +294,7 @@ onMounted(async () => {
295
294
 
296
295
  const [blockLevelResult, acmResult] = await Promise.allSettled([
297
296
  _getBlockLevelUnitList(siteId.value),
298
- getEncryptedAcmUrl(siteId.value),
297
+ $fetch<{ encrypted: string }>("/api/encrypt-acm-url"),
299
298
  ]);
300
299
 
301
300
  // Blocks / Levels / Units
@@ -310,7 +309,7 @@ onMounted(async () => {
310
309
 
311
310
  // ACM levels (door + lift)
312
311
  if (acmResult.status === "fulfilled") {
313
- const acmUrl = acmResult.value;
312
+ const acmUrl = acmResult.value.encrypted;
314
313
  try {
315
314
  const [doorLevels, liftLevels] = await Promise.all([
316
315
  _getDoorAccessLevels(acmUrl),
@@ -403,7 +403,6 @@ const emit = defineEmits<{
403
403
  }>();
404
404
 
405
405
  const { getResidents, assignUser } = useAccessManagement();
406
- const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
407
406
 
408
407
  const historyDialog = ref(false);
409
408
  const showAssignForm = ref(false);
@@ -476,12 +475,12 @@ async function openAssignForm() {
476
475
  if (!props.unit?._id || !props.orgId || !props.siteId) return;
477
476
  peopleLoading.value = true;
478
477
  try {
479
- const [residentsRes, acmUrl] = await Promise.all([
478
+ const [residentsRes, acmRes] = await Promise.all([
480
479
  getResidents({ orgId: props.orgId, siteId: props.siteId, unitId: props.unit._id }),
481
- getEncryptedAcmUrl(props.siteId),
480
+ $fetch<{ encrypted: string }>("/api/encrypt-acm-url"),
482
481
  ]);
483
482
  peopleItems.value = residentsRes?.data ?? [];
484
- encryptedAcmUrl.value = acmUrl ?? "";
483
+ encryptedAcmUrl.value = acmRes?.encrypted ?? "";
485
484
  } catch {
486
485
  peopleItems.value = [];
487
486
  } finally {
@@ -18,8 +18,7 @@
18
18
  <template>
19
19
  <v-menu
20
20
  v-if="!multiple"
21
- v-model="singleOpen"
22
- :close-on-content-click="!searchable"
21
+ :close-on-content-click="true"
23
22
  :disabled="disabled"
24
23
  location="bottom start"
25
24
  offset="4"
@@ -54,21 +53,8 @@
54
53
  </template>
55
54
 
56
55
  <div class="app-select__menu" role="listbox">
57
- <div v-if="searchable" class="app-select__search">
58
- <v-icon icon="mdi-magnify" size="16" />
59
- <input
60
- ref="singleSearchInput"
61
- v-model="search"
62
- type="text"
63
- class="app-select__search-input"
64
- :placeholder="searchPlaceholder"
65
- aria-label="Search options"
66
- @keydown.enter.prevent="selectFirstMatch"
67
- @keydown.esc.stop="singleOpen = false"
68
- >
69
- </div>
70
56
  <button
71
- v-for="opt in visibleOptions"
57
+ v-for="opt in normalized"
72
58
  :key="String(opt.value)"
73
59
  type="button"
74
60
  class="app-select__option"
@@ -80,9 +66,6 @@
80
66
  <span class="app-select__option-text">{{ opt.title }}</span>
81
67
  <v-icon v-if="isSingleOn(opt)" icon="mdi-check" size="17" />
82
68
  </button>
83
- <p v-if="searchable && !visibleOptions.length" class="app-select__empty">
84
- {{ normalized.length ? "No matches" : "Nothing to choose from" }}
85
- </p>
86
69
  </div>
87
70
  </v-menu>
88
71
 
@@ -139,18 +122,8 @@
139
122
  </template>
140
123
 
141
124
  <div class="app-select__menu">
142
- <div v-if="searchable" class="app-select__search">
143
- <v-icon icon="mdi-magnify" size="16" />
144
- <input
145
- v-model="search"
146
- type="text"
147
- class="app-select__search-input"
148
- :placeholder="searchPlaceholder"
149
- aria-label="Search options"
150
- >
151
- </div>
152
125
  <button
153
- v-for="opt in visibleOptions"
126
+ v-for="opt in normalized"
154
127
  :key="String(opt.value)"
155
128
  type="button"
156
129
  class="app-select__option"
@@ -165,9 +138,6 @@
165
138
  />
166
139
  <span class="app-select__option-text">{{ opt.title }}</span>
167
140
  </button>
168
- <p v-if="searchable && !visibleOptions.length" class="app-select__empty">
169
- {{ normalized.length ? "No matches" : "Nothing to choose from" }}
170
- </p>
171
141
  </div>
172
142
  </v-menu>
173
143
  </template>
@@ -198,13 +168,6 @@ const props = defineProps({
198
168
  returnObject: { type: Boolean, default: false },
199
169
  clearable: { type: Boolean, default: false },
200
170
  placeholder: { type: String, default: "" },
201
- /**
202
- * A filter box at the top of the popover. Opt-in, so the 44 consumers that
203
- * do not ask for it render exactly the DOM they render today - a list short
204
- * enough to read does not want a search field in front of it.
205
- */
206
- searchable: { type: Boolean, default: false },
207
- searchPlaceholder: { type: String, default: "Search" },
208
171
  });
209
172
 
210
173
  const normalized = computed(() =>
@@ -215,38 +178,6 @@ const normalized = computed(() =>
215
178
  )
216
179
  );
217
180
 
218
- /* ---- search ---------------------------------------------------------- */
219
-
220
- const search = ref("");
221
- const singleOpen = ref(false);
222
- const singleSearchInput = ref<HTMLInputElement | null>(null);
223
-
224
- /* Case-insensitive substring over what the option READS AS, not its value:
225
- the title is the only part the user can see to type. */
226
- const visibleOptions = computed(() => {
227
- const term = search.value.trim().toLowerCase();
228
- if (!props.searchable || !term) return normalized.value;
229
- return normalized.value.filter((opt) =>
230
- String(opt.title ?? "").toLowerCase().includes(term)
231
- );
232
- });
233
-
234
- /* A stale term would hide the list the next time the popover opens, so it is
235
- dropped on every open - and the caret goes into the box, since opening a
236
- searchable select is almost always a prelude to typing. */
237
- watch(singleOpen, async (open) => {
238
- if (!props.searchable) return;
239
- search.value = "";
240
- if (!open) return;
241
- await nextTick();
242
- singleSearchInput.value?.focus();
243
- });
244
-
245
- function selectFirstMatch() {
246
- const [first] = visibleOptions.value;
247
- if (first) selectSingle(first);
248
- }
249
-
250
181
  /* A model entry is either the plain value or - under `return-object` - the
251
182
  source item, so everything compares on this one key. */
252
183
  const keyOf = (v: any) =>
@@ -274,11 +205,6 @@ const isSingleOn = (opt: any) =>
274
205
  function selectSingle(opt: any) {
275
206
  if (props.disabled) return;
276
207
  model.value = props.returnObject ? opt.raw : String(opt.value);
277
- // `close-on-content-click` has to be OFF while searching - it fires on the
278
- // click that puts the caret in the search box, so the popover would shut the
279
- // moment it was typed into. With it off, picking an option has to close the
280
- // popover by hand.
281
- if (props.searchable) singleOpen.value = false;
282
208
  }
283
209
 
284
210
  function clearSingle() {
@@ -161,21 +161,14 @@
161
161
  </v-col>
162
162
 
163
163
  <v-col v-if="isANPR" cols="12">
164
- <!-- Write-only: the saved password is never sent to the browser, so
165
- on edit a blank field keeps it. Required only for a new camera. -->
166
- <InputLabel
167
- class="text-capitalize"
168
- title="Password"
169
- :required="!isEdit"
170
- />
164
+ <InputLabel class="text-capitalize" title="Password" required />
171
165
  <v-text-field
172
166
  v-model.trim="camera.password"
173
167
  type="password"
174
- autocomplete="new-password"
175
168
  class="filter-field"
176
169
  density="comfortable"
177
- :rules="isEdit ? [] : [requiredRule]"
178
- :placeholder="isEdit ? 'Saved · leave blank to keep' : 'Enter password'"
170
+ :rules="[requiredRule]"
171
+ placeholder="Enter password"
179
172
  />
180
173
  </v-col>
181
174
 
@@ -426,12 +419,8 @@ const camera = ref<TSiteCamera>({
426
419
 
427
420
  camera.value.site = prop.site;
428
421
 
429
- const isEdit = prop.mode === "edit";
430
-
431
- if (isEdit) {
422
+ if (prop.mode === "edit") {
432
423
  camera.value = JSON.parse(JSON.stringify(prop.camera));
433
- // Never pre-filled: blank means "keep the saved password".
434
- camera.value.password = "";
435
424
  }
436
425
  camera.value.site = prop.site;
437
426
  camera.value.type = prop.type;
@@ -587,9 +576,7 @@ const hasChanges = computed(() => {
587
576
  return (
588
577
  camera.value.host !== prop.camera.host ||
589
578
  camera.value.username !== prop.camera.username ||
590
- // A typed password is a change; blank keeps the saved one.
591
- !!camera.value.password ||
592
- camera.value.status !== prop.camera.status ||
579
+ camera.value.password !== prop.camera.password ||
593
580
  camera.value.category !== prop.camera.category ||
594
581
  camera.value.guardPost !== prop.camera.guardPost ||
595
582
  camera.value.direction !== prop.camera.direction ||
@@ -908,8 +895,7 @@ async function save(
908
895
  const res = await updateSiteCamera(camera.value._id ?? "", {
909
896
  ...addressPayload(),
910
897
  username: camera.value.username,
911
- // Sent only when typed; left out, the server keeps the saved one.
912
- ...(camera.value.password ? { password: camera.value.password } : {}),
898
+ password: camera.value.password,
913
899
  category: camera.value.category,
914
900
  guardPost: camera.value.guardPost,
915
901
  direction: camera.value.direction,
@@ -317,7 +317,7 @@
317
317
  cols="12"
318
318
  sm="6"
319
319
  md="4"
320
- :lg="kpiColLg"
320
+ lg="3"
321
321
  class="d-flex"
322
322
  >
323
323
  <!--
@@ -1107,19 +1107,14 @@
1107
1107
  {{ widget.description }}
1108
1108
  </div>
1109
1109
  </div>
1110
- <!--
1111
- `app-switch` is the platform toggle (primitives.css): blue
1112
- track + white knob right when ON, grey knob left when OFF, in
1113
- both themes. The old blue-grey colour drew ON as near-black on the
1114
- dark card and grey on the light one - on and off looked alike.
1115
- -->
1116
1110
  <v-switch
1117
1111
  v-model="draftVisibleWidgetKeys"
1118
1112
  :value="widget.key"
1119
- class="app-switch flex-grow-0"
1113
+ color="blue-grey-darken-4"
1114
+ inset
1120
1115
  hide-details
1121
1116
  density="compact"
1122
- :aria-label="`Show ${widget.title}`"
1117
+ aria-label="Toggle dashboard widget"
1123
1118
  />
1124
1119
  </div>
1125
1120
  </v-card>
@@ -1141,7 +1136,7 @@
1141
1136
  </v-btn>
1142
1137
  <v-btn
1143
1138
  variant="flat"
1144
- color="primary"
1139
+ color="blue-grey-darken-4"
1145
1140
  class="text-none font-weight-bold px-6"
1146
1141
  style="min-width: 110px"
1147
1142
  @click="submitCustomize"
@@ -1232,12 +1227,6 @@ import type { CookieOptions } from "#app";
1232
1227
  // `plugins/vuetify.ts`), and a bare call resolved to nothing when driven.
1233
1228
  import { buildWorkOrderStatus } from "../utils/dashboard";
1234
1229
  import { permissionsDenied } from "../utils/module-routes";
1235
- import { grantsPermission } from "../utils/permission-spellings";
1236
- import {
1237
- hiddenDashboardWidgets,
1238
- saveHiddenDashboardWidgets,
1239
- type TDashboardWidgetPrefs,
1240
- } from "../utils/dashboard-widget-prefs";
1241
1230
 
1242
1231
  // ─── Types ────────────────────────────────────────────────────────────────────
1243
1232
 
@@ -1493,20 +1482,21 @@ function canViewWidget(key: string) {
1493
1482
  return false;
1494
1483
  }
1495
1484
 
1496
- // The estate rule (`grantsPermission`): a saved role with an EMPTY list, or
1497
- // `*`, means everything. core seeds default roles with `permissions: []`, so
1498
- // an exact `includes` test showed those accounts an empty dashboard while
1499
- // every menu in the app let them in. `grantsPermission` also accepts every
1500
- // shipped spelling of a grant (`work-orders:` / `workOrder:` / ...).
1501
- if (!permissions.length || permissions.includes("*")) return true;
1485
+ if (permissions.includes("*")) return true;
1502
1486
 
1503
1487
  if (bySchedule) {
1504
1488
  return hasSchedulePermission(permissions, "see-all-schedules");
1505
1489
  }
1506
1490
 
1491
+ if (isPropertyMode.value && modulePermissions) {
1492
+ return modulePermissions.some((permission) =>
1493
+ permissions.includes(permission)
1494
+ );
1495
+ }
1496
+
1507
1497
  if (modulePermissions) {
1508
1498
  return modulePermissions.some((permission) =>
1509
- grantsPermission(permissions, permission)
1499
+ permissions.includes(permission)
1510
1500
  );
1511
1501
  }
1512
1502
 
@@ -2059,28 +2049,33 @@ const securityDefaultWidgetKeys = [
2059
2049
  ];
2060
2050
 
2061
2051
  /**
2062
- * THE CUSTOMISE CHOICE, REMEMBERED - per signed-in user, per app.
2052
+ * THE CUSTOMISE CHOICE, REMEMBERED.
2063
2053
  *
2064
- * A cookie on the layer's own `cookieConfig` (the parent-domain cookie `theme`
2065
- * and `landing-page` already use; localStorage would be per-hostname and there
2066
- * is no preferences API on the server). What is stored and why it is keyed by
2067
- * user and app is in `utils/dashboard-widget-prefs.ts`.
2054
+ * Hiding a widget worked and then forgot: nothing was written anywhere, so a
2055
+ * reload brought every panel back and the dialog was decoration.
2068
2056
  *
2069
- * The earlier `dashboard-widgets-<mode>` cookies are not read: they belonged
2070
- * to whoever last used the browser, and to five apps at once.
2057
+ * A COOKIE, and specifically the layer's own `cookieConfig` - the same
2058
+ * mechanism `sid`, `user`, `landing-page` and the light/dark preference already
2059
+ * travel on (see `composables/useThemePreference.ts` for the full reasoning).
2060
+ * localStorage would be per-origin, and this is one component shipped into
2061
+ * seven applications on seven hostnames. No new storage mechanism, and no
2062
+ * invented endpoint - there is no save API on the server for this.
2063
+ *
2064
+ * One cookie per dashboard MODE, because the three modes have different widget
2065
+ * sets and a security member's choice must not decide what a hygiene member
2066
+ * sees.
2071
2067
  */
2072
- const cookieOptions = runtimeConfig.public.cookieConfig as CookieOptions<any> & {
2073
- readonly?: false;
2074
- };
2075
- const widgetPrefsCookie = useCookie<TDashboardWidgetPrefs | null>(
2076
- "dashboard-widgets",
2077
- cookieOptions
2078
- );
2079
- // The `user` cookie holds the signed-in user's id (`useLocalAuth`).
2080
- const widgetPrefsUser = String(useCookie("user", cookieOptions).value ?? "");
2081
- const widgetPrefsApp = String(props.serviceType ?? "");
2082
- const hiddenByUser = ref<string[]>(
2083
- hiddenDashboardWidgets(widgetPrefsCookie.value, widgetPrefsUser, widgetPrefsApp)
2068
+ const widgetPrefsCookie = useCookie<string[] | null>(
2069
+ `dashboard-widgets-${
2070
+ isPropertyMode.value
2071
+ ? "property"
2072
+ : isSecurityMode.value
2073
+ ? "security"
2074
+ : "service"
2075
+ }`,
2076
+ runtimeConfig.public.cookieConfig as CookieOptions<string[] | null> & {
2077
+ readonly?: false;
2078
+ }
2084
2079
  );
2085
2080
 
2086
2081
  const defaultVisibleWidgetKeys = computed(() => {
@@ -2101,10 +2096,26 @@ const defaultVisibleWidgetKeys = computed(() => {
2101
2096
  watch(
2102
2097
  defaultVisibleWidgetKeys,
2103
2098
  (keys) => {
2104
- visibleWidgetKeys.value = keys.filter(
2105
- (k) => !hiddenByUser.value.includes(k)
2099
+ const nextKeys = [...keys];
2100
+ if (!visibleWidgetKeys.value.length) {
2101
+ // A remembered choice wins on first paint, filtered against the keys this
2102
+ // mode actually has so a stale cookie cannot resurrect a removed widget.
2103
+ // `Array.isArray` rather than a length check: "the user hid everything"
2104
+ // and "there is no cookie" are different answers.
2105
+ const remembered = Array.isArray(widgetPrefsCookie.value)
2106
+ ? widgetPrefsCookie.value.filter((k) => nextKeys.includes(k))
2107
+ : null;
2108
+ const initial = remembered ?? nextKeys;
2109
+ visibleWidgetKeys.value = initial;
2110
+ draftVisibleWidgetKeys.value = [...initial];
2111
+ return;
2112
+ }
2113
+ visibleWidgetKeys.value = visibleWidgetKeys.value.filter((k) =>
2114
+ nextKeys.includes(k)
2115
+ );
2116
+ draftVisibleWidgetKeys.value = draftVisibleWidgetKeys.value.filter((k) =>
2117
+ nextKeys.includes(k)
2106
2118
  );
2107
- draftVisibleWidgetKeys.value = [...visibleWidgetKeys.value];
2108
2119
  },
2109
2120
  { immediate: true }
2110
2121
  );
@@ -2535,13 +2546,6 @@ const visibleKpiCards = computed(() =>
2535
2546
  kpiCards.value.filter((card) => isWidgetVisible(card.key))
2536
2547
  );
2537
2548
 
2538
- // Four tiles fill a row at `lg`; hiding one in Customize left a hole where it
2539
- // had been. Fewer tiles share the row instead.
2540
- const kpiColLg = computed(() => {
2541
- const shown = visibleKpiCards.value.length;
2542
- return shown >= 4 || shown === 0 ? 3 : 12 / shown;
2543
- });
2544
-
2545
2549
  /**
2546
2550
  * Nothing to draw, and which of the two reasons it is.
2547
2551
  *
@@ -3595,20 +3599,10 @@ function resetCustomizeDraft() {
3595
3599
  }
3596
3600
 
3597
3601
  function submitCustomize() {
3598
- const hidden = defaultVisibleWidgetKeys.value.filter(
3599
- (k) => !draftVisibleWidgetKeys.value.includes(k)
3600
- );
3601
- hiddenByUser.value = hidden;
3602
- visibleWidgetKeys.value = defaultVisibleWidgetKeys.value.filter(
3603
- (k) => !hidden.includes(k)
3604
- );
3605
- // The write is the whole point - see `widgetPrefsCookie`.
3606
- widgetPrefsCookie.value = saveHiddenDashboardWidgets(
3607
- widgetPrefsCookie.value,
3608
- widgetPrefsUser,
3609
- widgetPrefsApp,
3610
- hidden
3611
- );
3602
+ visibleWidgetKeys.value = [...draftVisibleWidgetKeys.value];
3603
+ // The write is the whole point - see `widgetPrefsCookie`. Saving on Submit
3604
+ // rather than on every switch keeps Cancel meaning cancel.
3605
+ widgetPrefsCookie.value = [...draftVisibleWidgetKeys.value];
3612
3606
  widgetSearch.value = "";
3613
3607
  customizeDialog.value = false;
3614
3608
  }
@@ -23,7 +23,7 @@
23
23
  Close
24
24
  </v-btn>
25
25
 
26
- <v-menu v-if="canUpdate || canDelete || $slots.actions" contained>
26
+ <v-menu v-if="canUpdate || canDelete" contained>
27
27
  <template #activator="{ props }">
28
28
  <v-btn
29
29
  variant="flat"
@@ -42,16 +42,6 @@
42
42
  render-verified either. Only the row type and the destructive ink
43
43
  change. -->
44
44
  <v-list class="pa-0 update-more__list">
45
- <!--
46
- Screen-specific rows, above Edit and the destructive one.
47
-
48
- Additive: a screen that passes nothing gets the menu it always
49
- had, and `$slots.actions` is undefined unless a caller actually
50
- defines the slot - so the button does not appear over an empty
51
- list when a screen has neither permission nor extra rows.
52
- -->
53
- <slot name="actions" />
54
-
55
45
  <v-list-item v-if="canUpdate" @click="emit('edit')">
56
46
  <v-list-item-title>
57
47
  {{ editButtonLabel }}