@7365admin1/layer-common 4.97.0 → 4.97.1-staging.501

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 (62) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +57 -0
  3. package/assets/css/screens.css +8 -4
  4. package/components/AccessCardDetailsDialog.vue +59 -6
  5. package/components/AccessCardPreviewDialog.vue +371 -53
  6. package/components/AppSelect.vue +143 -4
  7. package/components/BuildingUnitFormEdit.vue +6 -0
  8. package/components/BuildingUnitManagement.vue +298 -0
  9. package/components/Dialog/UpdateMoreAction.vue +11 -1
  10. package/components/DocumentForm.vue +407 -112
  11. package/components/DocumentManagement.vue +213 -12
  12. package/components/Facility/BookingSetup.vue +63 -0
  13. package/components/HidAccessLogDashboard.vue +74 -17
  14. package/components/HidAccessPermissions.vue +532 -0
  15. package/components/HidIntercomManagement.vue +183 -14
  16. package/components/HidProfileQrCode.vue +332 -0
  17. package/components/HidQrCodeConfiguration.vue +17 -195
  18. package/components/HidReaderManagement.vue +398 -0
  19. package/components/HidReaderUserRoster.vue +44 -3
  20. package/components/HidUserEnrollment.vue +2242 -279
  21. package/components/InvitationClientForm.vue +19 -1
  22. package/components/Nfc/NFCPatrolRouteForm.vue +109 -58
  23. package/components/Nfc/NFCPatrolRouteMain.vue +3 -18
  24. package/components/Nfc/PatrolReport/PatrolActivityTable.vue +0 -8
  25. package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +1 -1
  26. package/components/ServiceProviderMain.vue +48 -0
  27. package/components/SiteProviderChange.vue +371 -0
  28. package/components/TableMain.vue +22 -7
  29. package/components/VisitorForm.vue +12 -0
  30. package/components/VisitorManagement.vue +267 -5
  31. package/composables/useAccessManagement.ts +17 -1
  32. package/composables/useCustomerSite.ts +29 -0
  33. package/composables/useDocument.ts +7 -0
  34. package/composables/useFacility.ts +11 -0
  35. package/composables/useHidAmico.ts +165 -0
  36. package/composables/useHidNavigation.ts +25 -1
  37. package/composables/useHidReaderSelection.ts +50 -0
  38. package/composables/useMember.ts +12 -0
  39. package/composables/useNFCPatrolReport.ts +36 -32
  40. package/composables/useNFCPatrolReportFilters.ts +21 -3
  41. package/composables/useNFCPatrolRoute.ts +10 -3
  42. package/composables/usePeople.ts +19 -0
  43. package/composables/useServiceProvider.ts +45 -2
  44. package/composables/useSiteCategory.ts +45 -0
  45. package/package.json +1 -1
  46. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  47. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  48. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  49. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  50. package/types/document.d.ts +14 -0
  51. package/types/facility.d.ts +4 -0
  52. package/types/member.d.ts +4 -0
  53. package/types/nfc-patrol-report.ts +0 -2
  54. package/types/people.d.ts +28 -1
  55. package/types/service-provider.d.ts +5 -0
  56. package/types/site.d.ts +7 -1
  57. package/utils/hid-enrolment-subject.ts +366 -0
  58. package/utils/hid-permission-assignments.ts +259 -0
  59. package/utils/hid-reader-selection.ts +52 -0
  60. package/utils/nfc-patrol-time.ts +99 -0
  61. package/utils/occupancy-role.ts +109 -0
  62. package/utils/service-type.ts +30 -0
@@ -388,9 +388,27 @@ watch(
388
388
  const roles = ref<any[]>([])
389
389
  const { getRoles } = useRole()
390
390
 
391
+ /*
392
+ * `type` is the ROLE type, not the service the client subscribes to, and the
393
+ * only caller (`ClientMain.vue` "Invite Client") passes no `app` prop -- so
394
+ * this sent `type: ""` and the endpoint applied no type filter at all. The
395
+ * dropdown therefore listed every org-less role of every type, which includes
396
+ * the platform's OWN staff roles (`type: "admin"` -- the two rows the console's
397
+ * `/super-admin/role-permissions` screen edits). Offering a Seven365 console
398
+ * staff role on a CLIENT invitation is the defect.
399
+ *
400
+ * A client admin's role is `type: "organization"`, which is what the client
401
+ * roles screen (`org/[organization]/roles-permissions.vue`) already lists. This
402
+ * narrows to exactly that. It is a narrowing of this one picker only: no role
403
+ * changes, no permission changes, and the org-less `organization` roles that
404
+ * were already being picked are all still offered.
405
+ *
406
+ * Keying off the selected service instead would empty the dropdown -- no
407
+ * org-less role carries a service type -- so `props.app` is not the filter.
408
+ */
391
409
  const { data: roleData, refresh: refreshRoles } = await useLazyAsyncData(
392
410
  "roles",
393
- () => getRoles({ org: org.value?._id, type: props.app, limit: 50 }),
411
+ () => getRoles({ org: org.value?._id, type: "organization", limit: 50 }),
394
412
  { watch: [() => props.app] }
395
413
  )
396
414
 
@@ -49,10 +49,16 @@
49
49
  </label>
50
50
  <v-text-field
51
51
  v-model="form.tolerance"
52
- placeholder="60 mins"
52
+ placeholder="60"
53
+ suffix="mins"
54
+ inputmode="numeric"
55
+ maxlength="2"
53
56
  variant="outlined"
54
57
  density="comfortable"
55
- hide-details
58
+ :rules="[rules.required, rules.tolerance]"
59
+ hide-details="auto"
60
+ @keydown="allowDigitsOnly"
61
+ @paste="pasteDigitsOnly"
56
62
  />
57
63
  </v-col>
58
64
 
@@ -163,17 +169,11 @@
163
169
  Checkpoints
164
170
  </v-col>
165
171
  <v-col
166
- cols="6"
172
+ cols="10"
167
173
  class="text-caption screen-hint font-weight-medium pl-4"
168
174
  >
169
175
  NFC Tag
170
176
  </v-col>
171
- <v-col
172
- cols="4"
173
- class="text-caption screen-hint font-weight-medium text-right"
174
- >
175
- Travel time
176
- </v-col>
177
177
  </v-row>
178
178
  </v-col>
179
179
 
@@ -182,12 +182,27 @@
182
182
  v-for="(checkpoint, index) in form.checkPoints"
183
183
  :key="index"
184
184
  no-gutters
185
- class="mb-3 align-center"
185
+ class="mb-3 align-center checkpoint-row"
186
+ :class="{
187
+ 'checkpoint-row--dragging': dragIndex === index,
188
+ 'checkpoint-row--over':
189
+ dragOverIndex === index && dragIndex !== index,
190
+ }"
191
+ @dragover="onCheckpointDragOver($event, index)"
192
+ @drop="dropCheckpoint($event, index)"
186
193
  >
187
- <v-col cols="1" class="text-body-2">
194
+ <v-col cols="1" class="text-body-2 d-flex align-center">
195
+ <v-icon
196
+ icon="mdi-drag"
197
+ size="small"
198
+ class="drag-handle text-grey mr-1"
199
+ draggable="true"
200
+ @dragstart="startCheckpointDrag($event, index)"
201
+ @dragend="endCheckpointDrag"
202
+ />
188
203
  {{ index + 1 }}
189
204
  </v-col>
190
- <v-col cols="7" class="pr-2">
205
+ <v-col cols="11">
191
206
  <v-select
192
207
  v-model="checkpoint.nfcTag_id"
193
208
  :items="nfcTags"
@@ -199,35 +214,6 @@
199
214
  hide-details
200
215
  />
201
216
  </v-col>
202
- <v-col cols="4">
203
- <v-text-field
204
- v-model.number="checkpoint.travelTime"
205
- placeholder="10"
206
- variant="outlined"
207
- density="compact"
208
- hide-details
209
- suffix="min"
210
- type="number"
211
- />
212
- </v-col>
213
- </v-row>
214
- </v-col>
215
-
216
- <!-- Summary -->
217
- <v-col cols="12">
218
- <v-row no-gutters class="mb-2">
219
- <v-col cols="6" class="text-body-2 screen-hint">
220
- Tolerance Balance:
221
- <span class="font-weight-bold"
222
- >{{ toleranceBalance }} min</span
223
- >
224
- </v-col>
225
- <v-col cols="6" class="text-body-2 screen-hint text-right">
226
- Total Travel Time:
227
- <span class="font-weight-bold"
228
- >{{ totalTravelTime }} min</span
229
- >
230
- </v-col>
231
217
  </v-row>
232
218
  </v-col>
233
219
  </v-row>
@@ -333,7 +319,6 @@ const loading = ref(false);
333
319
 
334
320
  interface Checkpoint {
335
321
  nfcTag_id: string;
336
- travelTime: number;
337
322
  }
338
323
 
339
324
  const form = ref({
@@ -359,10 +344,30 @@ const dayOptions = [
359
344
  { title: "Sunday", value: 0 },
360
345
  ];
361
346
 
347
+ const MAX_TOLERANCE_MINUTES = 60;
348
+
362
349
  const rules = {
363
350
  required: (v: any) => !!v || "This field is required",
351
+ tolerance: (v: any) => {
352
+ if (!/^\d+$/.test(String(v ?? ""))) return "Numbers only";
353
+ if (Number(v) > MAX_TOLERANCE_MINUTES)
354
+ return `Maximum is ${MAX_TOLERANCE_MINUTES} minutes (1 hour)`;
355
+ return true;
356
+ },
364
357
  };
365
358
 
359
+ // Tolerance is minutes: keep letters and symbols out as they are typed or
360
+ // pasted. Keys like Backspace, Tab and the arrows have longer names and pass.
361
+ function allowDigitsOnly(event: KeyboardEvent) {
362
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
363
+ if (event.key.length === 1 && !/\d/.test(event.key)) event.preventDefault();
364
+ }
365
+
366
+ function pasteDigitsOnly(event: ClipboardEvent) {
367
+ const text = event.clipboardData?.getData("text") ?? "";
368
+ if (/\D/.test(text)) event.preventDefault();
369
+ }
370
+
366
371
  // Fetch NFC Tags
367
372
  const nfcTags = ref<Array<any>>([]);
368
373
  const { getAll: getAllNFCTags } = useNFCPatrolTag();
@@ -385,7 +390,6 @@ function updateCheckpointsCount(count: number) {
385
390
  for (let i = currentLength; i < count; i++) {
386
391
  form.value.checkPoints.push({
387
392
  nfcTag_id: "",
388
- travelTime: 10,
389
393
  });
390
394
  }
391
395
  } else if (count < currentLength) {
@@ -395,6 +399,43 @@ function updateCheckpointsCount(count: number) {
395
399
 
396
400
  }
397
401
 
402
+ // Drag a checkpoint onto another to swap their positions
403
+ const dragIndex = ref<number | null>(null);
404
+ const dragOverIndex = ref<number | null>(null);
405
+
406
+ function startCheckpointDrag(event: DragEvent, index: number) {
407
+ dragIndex.value = index;
408
+ if (!event.dataTransfer) return;
409
+ event.dataTransfer.effectAllowed = "move";
410
+ // Firefox needs data set to start a drag; a custom type keeps inputs from accepting it on drop
411
+ event.dataTransfer.setData("application/x-nfc-checkpoint", String(index));
412
+ const row = (event.target as HTMLElement).closest(".checkpoint-row");
413
+ if (row) event.dataTransfer.setDragImage(row, 0, 0);
414
+ }
415
+
416
+ function onCheckpointDragOver(event: DragEvent, index: number) {
417
+ if (dragIndex.value === null) return;
418
+ event.preventDefault();
419
+ dragOverIndex.value = index;
420
+ }
421
+
422
+ function dropCheckpoint(event: DragEvent, index: number) {
423
+ if (dragIndex.value === null) return;
424
+ event.preventDefault();
425
+ const from = dragIndex.value;
426
+ if (from !== index) {
427
+ const list = [...form.value.checkPoints];
428
+ [list[from], list[index]] = [list[index], list[from]];
429
+ form.value.checkPoints = list;
430
+ }
431
+ endCheckpointDrag();
432
+ }
433
+
434
+ function endCheckpointDrag() {
435
+ dragIndex.value = null;
436
+ dragOverIndex.value = null;
437
+ }
438
+
398
439
 
399
440
  const hasDuplicateStartTimes = computed(() => {
400
441
  const uniqueTimes = new Set(form.value.startTimes);
@@ -468,20 +509,6 @@ function removeStartTime(index: number) {
468
509
  startTimeKeys.value.splice(index, 1);
469
510
  }
470
511
 
471
- // Computed values
472
- const totalTravelTime = computed(() => {
473
- const total = form.value.checkPoints.reduce((sum, checkpoint) => {
474
- return sum + (checkpoint.travelTime || 0);
475
- }, 0);
476
- return total;
477
- });
478
-
479
- const toleranceBalance = computed(() => {
480
- const tolerance = parseInt(form.value.tolerance) || 0;
481
- const balance = tolerance - totalTravelTime.value;
482
- return balance;
483
- });
484
-
485
512
  // Initialize form
486
513
  onMounted(() => {
487
514
  fetchNFCTags();
@@ -516,7 +543,6 @@ async function initializeEditForm(newRoute: any) {
516
543
  Array.isArray(newRoute.checkPoints) && newRoute.checkPoints.length > 0
517
544
  ? newRoute.checkPoints.map((cp: any) => ({
518
545
  nfcTag_id: cp.nfcTag_id || "",
519
- travelTime: cp.travelTime || 10
520
546
  }))
521
547
  : [];
522
548
 
@@ -533,6 +559,11 @@ async function initializeEditForm(newRoute: any) {
533
559
  startTimeKeys.value = startTimes.map(() => Date.now() + Math.random());
534
560
 
535
561
  formInitialized.value = true;
562
+
563
+ // A route saved before the 60-minute limit can be over it: say why Submit
564
+ // is disabled instead of leaving it greyed out with no message.
565
+ await nextTick();
566
+ formRef.value?.validate();
536
567
  }
537
568
 
538
569
  watch(
@@ -592,3 +623,23 @@ async function submit() {
592
623
  }
593
624
  }
594
625
  </script>
626
+
627
+ <style scoped>
628
+ .drag-handle {
629
+ cursor: grab;
630
+ }
631
+
632
+ .checkpoint-row {
633
+ border-radius: 8px;
634
+ transition: background-color 0.15s;
635
+ }
636
+
637
+ .checkpoint-row--dragging {
638
+ opacity: 0.5;
639
+ }
640
+
641
+ .checkpoint-row--over {
642
+ background-color: rgba(var(--v-theme-primary), 0.08);
643
+ outline: 1px dashed rgb(var(--v-theme-primary));
644
+ }
645
+ </style>
@@ -50,8 +50,7 @@
50
50
 
51
51
  <template #item.tolerance="{ item }">
52
52
  <div class="text-center">
53
- <div>{{ item.tolerance ? `${item.tolerance} mins` : "--" }}</div>
54
- <div class="text-grey">{{ item.totalTravelTime ? `${item.totalTravelTime} mins` : "--" }}</div>
53
+ {{ item.tolerance ? `${item.tolerance} mins` : "--" }}
55
54
  </div>
56
55
  </template>
57
56
 
@@ -141,17 +140,6 @@
141
140
  </span>
142
141
  </v-col>
143
142
 
144
- <v-col
145
- cols="12"
146
- class="mb-2"
147
- v-if="selectedRoute.totalTravelTime"
148
- >
149
- Total Travel Time:
150
- <span class="font-weight-bold">
151
- {{ selectedRoute.totalTravelTime }} mins
152
- </span>
153
- </v-col>
154
-
155
143
  <v-col cols="12" class="mb-2" v-if="selectedRoute.repeatDays">
156
144
  Repeat Every:
157
145
  <span class="font-weight-bold">
@@ -178,7 +166,7 @@
178
166
  Checkpoints:
179
167
  <div class="font-weight-bold">
180
168
  <div v-for="(cp, idx) in selectedRoute.checkpoints" :key="idx" class="my-1">
181
- {{ idx + 1 }}. NFC Tag ID: {{ cp.nfcTagId }} - Travel Time: {{ cp.travelTime }} min
169
+ {{ idx + 1 }}. NFC Tag ID: {{ cp.nfcTagId }}
182
170
  </div>
183
171
  </div>
184
172
  </v-col>
@@ -335,7 +323,7 @@ const props = defineProps({
335
323
  { title: "Route Name", value: "routeName", align: "start" },
336
324
  { title: "Checkpoints", value: "numberOfCheckpoints", align: "center" },
337
325
  {
338
- title: "Tolerance (mins)\nTotal Travel Time (mins)",
326
+ title: "Tolerance (mins)",
339
327
  value: "tolerance",
340
328
  align: "center",
341
329
  sortable: false
@@ -395,7 +383,6 @@ watchEffect(() => {
395
383
  name: item.name,
396
384
  checkPointNumber: item.checkPointNumber,
397
385
  tolerance: item.tolerance,
398
- totalTravelTime: item.totalTravelTime,
399
386
  days: item.days,
400
387
  startTimes: item.startTimes || [],
401
388
  checkPoints: item.checkPoints || [],
@@ -412,7 +399,6 @@ watchEffect(() => {
412
399
  }).join(', ') || '--',
413
400
  checkpoints: item.checkPoints?.map((cp: any) => ({
414
401
  nfcTagId: cp.nfcTag_id,
415
- travelTime: cp.travelTime
416
402
  })) || []
417
403
  })) || [];
418
404
 
@@ -516,7 +502,6 @@ const selectedRoute = ref<any>({
516
502
  checkPointNumber: 5,
517
503
  numberOfCheckpoints: 5,
518
504
  tolerance: undefined,
519
- totalTravelTime: undefined,
520
505
  days: [],
521
506
  repeatDays: "",
522
507
  startTimes: [],
@@ -25,10 +25,6 @@
25
25
  <th>Security Personnel</th>
26
26
  <th>Signature</th>
27
27
  <th>Start - End Time</th>
28
- <th>
29
- Travel time<br />
30
- <span class="text-caption text-medium-emphasis">Actual/Expected</span>
31
- </th>
32
28
  <th>Status</th>
33
29
  </tr>
34
30
  </thead>
@@ -58,10 +54,6 @@
58
54
  <v-icon size="small" class="mr-1">mdi-clock-outline</v-icon>
59
55
  {{ activity.endTime }}
60
56
  </td>
61
- <td>
62
- <v-icon size="small" class="mr-1">mdi-clock-outline</v-icon>
63
- {{ activity.actualTime }} / {{ activity.expectedTime }}
64
- </td>
65
57
  <td>
66
58
  <v-chip :color="statusColor(activity.status)" size="small" variant="flat">
67
59
  {{ activity.status }}
@@ -28,7 +28,7 @@
28
28
  <th>Time</th>
29
29
  <th>Start</th>
30
30
  <th>End</th>
31
- <th>Total travel time</th>
31
+ <th>Total time</th>
32
32
  </tr>
33
33
  </thead>
34
34
  <tbody>
@@ -36,6 +36,14 @@
36
36
  >
37
37
  Add
38
38
  </AppButton>
39
+ <AppButton
40
+ v-if="canInviteServiceProvider && siteId"
41
+ variant="ghost"
42
+ data-test="sp-provider-changes"
43
+ @click="providerChange?.openHistory()"
44
+ >
45
+ Provider changes
46
+ </AppButton>
39
47
  <AppButton
40
48
  v-if="canInviteServiceProvider"
41
49
  @click="
@@ -202,6 +210,17 @@
202
210
  the engagement itself, not a stored service-providers record, so
203
211
  the In-Active move (which acts on that record) does not apply.
204
212
  -->
213
+ <v-menu v-else-if="item.source === 'engagement' && canChangeProvider(item)" location="bottom end">
214
+ <template #activator="{ props: menuProps }">
215
+ <v-btn v-bind="menuProps" icon variant="text" density="comfortable" aria-label="Provider actions">
216
+ <v-icon>mdi-dots-vertical</v-icon>
217
+ </v-btn>
218
+ </template>
219
+ <v-list density="compact" min-width="200">
220
+ <v-list-item title="View provider" @click="openProvider(item)" />
221
+ <v-list-item title="Change or end provider" data-test="sp-change-provider" @click="openProviderChange(item)" />
222
+ </v-list>
223
+ </v-menu>
205
224
  <v-btn
206
225
  v-else-if="item.source === 'engagement'"
207
226
  icon
@@ -230,6 +249,12 @@
230
249
  title="View Members"
231
250
  @click="openView(item)"
232
251
  />
252
+ <v-list-item
253
+ v-if="canChangeProvider(item)"
254
+ title="Change or end provider"
255
+ data-test="sp-change-provider"
256
+ @click="openProviderChange(item)"
257
+ />
233
258
  <v-list-item
234
259
  :title="rowActionLabel(item)"
235
260
  :class="{
@@ -720,6 +745,15 @@
720
745
  </v-dialog>
721
746
 
722
747
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
748
+
749
+ <SiteProviderChange
750
+ v-if="siteId"
751
+ ref="providerChange"
752
+ :site-id="siteId"
753
+ :site-name="siteName || site?.name || ''"
754
+ :owner-org-id="String(site?.orgId || '')"
755
+ @changed="loadList()"
756
+ />
723
757
  </template>
724
758
 
725
759
  <script lang="ts" setup>
@@ -998,6 +1032,20 @@ const { data: site } = await useLazyAsyncData(
998
1032
 
999
1033
  const searchText = ref("");
1000
1034
 
1035
+ // Change or end a provider at this site (owner 2026-10-05). The server decides
1036
+ // who may; the menu only offers it to people who may invite providers, on a
1037
+ // live provider row that names its company.
1038
+ const providerChange = ref<{
1039
+ openChange: (row: { from: string; fromName: string }) => void;
1040
+ openHistory: () => void;
1041
+ } | null>(null);
1042
+ function canChangeProvider(item: TableRow) {
1043
+ return Boolean(canInviteServiceProvider.value && props.siteId && listTab.value === "active" && item.serviceProviderOrgId);
1044
+ }
1045
+ function openProviderChange(item: TableRow) {
1046
+ providerChange.value?.openChange({ from: item.serviceProviderOrgId, fromName: item.companyName });
1047
+ }
1048
+
1001
1049
  const createDialog = ref(false);
1002
1050
  const createInviteDialog = ref(false);
1003
1051
  // const loadingInviteRoles = ref(false);