@7365admin1/layer-common 3.2.1 → 3.2.2-staging.76

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 (55) hide show
  1. package/.changeset/camera-wall-gated-endpoint.md +47 -0
  2. package/.changeset/camera-wall-plain-english.md +60 -0
  3. package/.changeset/camera-wall-selection-and-guidance.md +65 -0
  4. package/.changeset/dark-primary-contrast.md +39 -0
  5. package/.changeset/dashboard-dark-theme-contrast.md +15 -0
  6. package/.changeset/service-provider-invitation-actions.md +28 -0
  7. package/components/CameraWall.vue +1034 -0
  8. package/components/CameraWallTile.vue +818 -0
  9. package/components/DashboardMain.vue +84 -88
  10. package/components/DashboardPanel.vue +1 -1
  11. package/components/EntryPassInformation.vue +4 -4
  12. package/components/FeedbackMain.vue +7 -7
  13. package/components/HidAccessLogDashboard.vue +119 -44
  14. package/components/HidCameraCaptureDialog.vue +199 -0
  15. package/components/HidIntercomManagement.vue +808 -201
  16. package/components/HidQrCodeConfiguration.vue +907 -95
  17. package/components/HidServiceSettingsPanel.vue +20 -1
  18. package/components/HidUserEnrollment.vue +392 -81
  19. package/components/Layout/NavigationDrawer.vue +43 -2
  20. package/components/NavigationItem.vue +9 -0
  21. package/components/Nfc/NFCPatrolRouteMain.vue +52 -7
  22. package/components/OnlineFormConfigurationForm.vue +620 -224
  23. package/components/OnlineFormFill.vue +36 -8
  24. package/components/OnlineFormsConfiguration.vue +6 -2
  25. package/components/QrTemplate/CreditCardLandscape.vue +19 -12
  26. package/components/QrTemplate/PrintDialog.vue +209 -196
  27. package/components/ServiceProviderInvitationPrompt.vue +150 -0
  28. package/components/ServiceProviderMain.vue +258 -14
  29. package/components/SiteSettings.vue +4 -2
  30. package/components/VisitorForm.vue +201 -78
  31. package/components/VisitorManagement.vue +2 -3
  32. package/components/VisitorSocketPopUp.vue +56 -2
  33. package/composables/useComment.ts +3 -3
  34. package/composables/useHidAmico.ts +127 -0
  35. package/composables/useHidNavigation.ts +0 -7
  36. package/composables/useLocalAuth.ts +9 -36
  37. package/composables/useOnlineFormPages.ts +2 -0
  38. package/composables/useServiceProviderInvitation.ts +145 -0
  39. package/composables/useSipWebPhone.ts +311 -0
  40. package/composables/useSiteSettings.ts +50 -0
  41. package/composables/useVisitorSocket.ts +26 -0
  42. package/composables/useWebUsb.ts +127 -37
  43. package/package.json +3 -1
  44. package/pages/[org]/[site]/access-mgmt/intercom/index.vue +1 -1
  45. package/plugins/secure-member.client.ts +21 -56
  46. package/plugins/vuetify.ts +37 -9
  47. package/test/visitor-socket.test.mjs +36 -0
  48. package/types/site.d.ts +71 -0
  49. package/utils/camera-wall.test.ts +571 -0
  50. package/utils/camera-wall.ts +926 -0
  51. package/utils/theme.test.ts +216 -0
  52. package/utils/theme.ts +163 -0
  53. package/components/HidIdentityMapping.vue +0 -975
  54. package/middleware/member.ts +0 -4
  55. package/pages/[org]/[site]/access-mgmt/identity-mapping/index.vue +0 -23
@@ -0,0 +1,150 @@
1
+ <template>
2
+ <v-card
3
+ v-if="invitation"
4
+ width="100%"
5
+ rounded="lg"
6
+ variant="outlined"
7
+ border="thin"
8
+ class="sp-invite-prompt"
9
+ >
10
+ <v-card-item>
11
+ <template #prepend>
12
+ <v-avatar color="primary" variant="tonal" size="44">
13
+ <v-icon>mdi-office-building-marker-outline</v-icon>
14
+ </v-avatar>
15
+ </template>
16
+ <v-card-title class="text-wrap sp-invite-prompt-title">
17
+ {{ invitation.orgName }} has invited you to {{ invitation.siteName }}
18
+ </v-card-title>
19
+ <v-card-subtitle class="text-wrap">
20
+ Approved by Seven365 · {{ expiryLine }}
21
+ </v-card-subtitle>
22
+ </v-card-item>
23
+
24
+ <v-card-text class="text-body-2">
25
+ {{ invitation.orgName }} would like your company to provide services at
26
+ <strong>{{ invitation.siteName }}</strong
27
+ >. If you accept, this site will be added to your list of sites.
28
+ </v-card-text>
29
+
30
+ <v-card-actions class="px-4 pb-4">
31
+ <v-btn
32
+ variant="flat"
33
+ color="primary"
34
+ class="text-none"
35
+ :loading="working === 'accept'"
36
+ :disabled="Boolean(working) || !open"
37
+ @click="accept"
38
+ >
39
+ Accept
40
+ </v-btn>
41
+ <v-btn
42
+ variant="outlined"
43
+ class="text-none ml-2"
44
+ :loading="working === 'decline'"
45
+ :disabled="Boolean(working) || !open"
46
+ @click="decline"
47
+ >
48
+ Decline
49
+ </v-btn>
50
+ <v-spacer />
51
+ <v-chip
52
+ v-if="!open"
53
+ size="small"
54
+ variant="tonal"
55
+ class="text-none"
56
+ :color="invitation.status === 'complete' ? 'success' : undefined"
57
+ >
58
+ {{ invitation.statusLabel }}
59
+ </v-chip>
60
+ </v-card-actions>
61
+ </v-card>
62
+
63
+ <v-card v-else-if="error" width="100%" rounded="lg" variant="outlined" border="thin">
64
+ <v-card-text class="text-body-2">{{ error }}</v-card-text>
65
+ </v-card>
66
+ </template>
67
+
68
+ <script setup lang="ts">
69
+ /**
70
+ * The invitation as the service provider sees it — inside the app they already
71
+ * use, from the session they already have.
72
+ *
73
+ * There is deliberately no sign-up and no one-time code anywhere on this
74
+ * screen: a company that already exists on iService365 never creates a second
75
+ * account. The email they receive only carries them here.
76
+ *
77
+ * An invitation that has not been approved by Seven365 is not readable at all —
78
+ * the server answers "not found" — so this component cannot show one.
79
+ */
80
+ const props = defineProps({
81
+ invitationId: { type: String, required: true },
82
+ });
83
+
84
+ const emit = defineEmits<{ (e: "answered", status: string): void }>();
85
+
86
+ const { getInvitation, declineInvitation } = useServiceProviderInvitation();
87
+ const { addViaInvite } = useCustomerSite();
88
+
89
+ const invitation = ref<any>(null);
90
+ const error = ref("");
91
+ const working = ref("");
92
+
93
+ const open = computed(() => invitation.value?.status === "pending");
94
+
95
+ const expiryLine = computed(() => {
96
+ const expires = invitation.value?.expireAt;
97
+ if (!expires) return "";
98
+ const days = Math.max(
99
+ 0,
100
+ Math.ceil((new Date(expires).getTime() - Date.now()) / 86400000),
101
+ );
102
+ if (!open.value) return "no longer waiting for an answer";
103
+ return days <= 1 ? "expires within a day" : `expires in ${days} days`;
104
+ });
105
+
106
+ async function load() {
107
+ try {
108
+ invitation.value = await getInvitation(props.invitationId);
109
+ } catch (err: any) {
110
+ error.value =
111
+ err?.data?.message || "This invitation is no longer available.";
112
+ }
113
+ }
114
+
115
+ async function accept() {
116
+ working.value = "accept";
117
+ try {
118
+ await addViaInvite(props.invitationId);
119
+ await load();
120
+ emit("answered", "complete");
121
+ } catch (err: any) {
122
+ error.value = err?.data?.message || "Could not accept this invitation.";
123
+ } finally {
124
+ working.value = "";
125
+ }
126
+ }
127
+
128
+ async function decline() {
129
+ working.value = "decline";
130
+ try {
131
+ await declineInvitation(props.invitationId);
132
+ await load();
133
+ emit("answered", "declined");
134
+ } catch (err: any) {
135
+ error.value = err?.data?.message || "Could not decline this invitation.";
136
+ } finally {
137
+ working.value = "";
138
+ }
139
+ }
140
+
141
+ await load();
142
+ </script>
143
+
144
+ <style scoped>
145
+ .sp-invite-prompt-title {
146
+ font-size: 1.05rem;
147
+ font-weight: 600;
148
+ line-height: 1.35;
149
+ }
150
+ </style>
@@ -65,9 +65,12 @@
65
65
  :loading="loading"
66
66
  >
67
67
  <div class="sp-table-header">
68
+ <!-- No fixed colour: `grey-lighten-4` is a light-mode grey in both
69
+ themes, which is what made this strip a white slab in dark
70
+ mode. `transparent` lets the themed .sp-table-header show. -->
68
71
  <v-toolbar
69
72
  density="compact"
70
- color="grey-lighten-4"
73
+ color="transparent"
71
74
  flat
72
75
  class="sp-toolbar-top px-2"
73
76
  >
@@ -137,17 +140,68 @@
137
140
  <span class="text-body-2">{{ item.members }}</span>
138
141
  </template>
139
142
  <template #item.status="{ item }">
143
+ <!--
144
+ Invitation rows use a FILLED chip, not a tonal one. A tonal chip
145
+ paints the status colour as TEXT, and measured on white the
146
+ warning and info tokens come out at 2.4:1 and 3.1:1 — under the
147
+ 4.5:1 a 12px label needs. Filled puts the colour in the
148
+ background and lets Vuetify pick a readable foreground for it.
149
+ -->
140
150
  <v-chip
141
- :color="statusChipColor(item.status)"
151
+ :color="statusChipColor(item.status, item.statusRaw)"
142
152
  size="small"
143
- variant="tonal"
153
+ :variant="item.source === 'invite' ? 'flat' : 'tonal'"
144
154
  class="text-none"
145
155
  >
146
156
  {{ item.status }}
147
157
  </v-chip>
158
+ <div
159
+ v-if="item.rejectionReason"
160
+ class="text-caption text-medium-emphasis mt-1 sp-reject-reason"
161
+ >
162
+ Seven365 said: {{ item.rejectionReason }}
163
+ </div>
148
164
  </template>
149
165
  <template #item.actions="{ item }">
150
- <v-menu v-if="item.source === 'provider'" location="bottom end">
166
+ <!--
167
+ Invitation rows. The menu used to be drawn only for
168
+ `source === 'provider'`, so a pending invitation showed no
169
+ actions at all and the cancel already wired up behind it was
170
+ unreachable.
171
+ -->
172
+ <v-menu v-if="item.source === 'invite'" location="bottom end">
173
+ <template #activator="{ props: menuProps }">
174
+ <v-btn
175
+ v-bind="menuProps"
176
+ icon
177
+ variant="text"
178
+ density="comfortable"
179
+ aria-label="Invitation actions"
180
+ >
181
+ <v-icon>mdi-dots-vertical</v-icon>
182
+ </v-btn>
183
+ </template>
184
+ <v-list density="compact" min-width="220">
185
+ <v-list-item
186
+ title="Cancel invitation"
187
+ :disabled="!canCancelInvite(item)"
188
+ :subtitle="canCancelInvite(item) ? undefined : 'Only while it is still waiting.'"
189
+ @click="openInviteAction(item, 'cancel')"
190
+ />
191
+ <v-list-item
192
+ title="Resend invitation"
193
+ :disabled="!canResendInvite(item)"
194
+ :subtitle="resendHint(item)"
195
+ @click="openInviteAction(item, 'resend')"
196
+ />
197
+ <v-list-item
198
+ title="Delete invitation"
199
+ base-color="error"
200
+ @click="openInviteAction(item, 'delete')"
201
+ />
202
+ </v-list>
203
+ </v-menu>
204
+ <v-menu v-else-if="item.source === 'provider'" location="bottom end">
151
205
  <template #activator="{ props: menuProps }">
152
206
  <v-btn
153
207
  v-bind="menuProps"
@@ -284,6 +338,56 @@
284
338
  </v-card>
285
339
  </v-dialog>
286
340
 
341
+ <!-- Cancel / Resend / Delete an invitation. Same shape as the provider
342
+ dialog above so the two read as one product. -->
343
+ <v-dialog v-model="inviteDialog" max-width="728" persistent>
344
+ <v-card rounded="lg" class="sp-status-card">
345
+ <v-card-title class="sp-status-title">
346
+ {{ inviteDialogTitle }}
347
+ </v-card-title>
348
+
349
+ <v-card-text class="sp-status-body">
350
+ <div class="sp-status-question">
351
+ {{ inviteDialogQuestion }}
352
+ </div>
353
+ <div class="sp-status-copy">
354
+ {{ inviteDialogCopy }}
355
+ </div>
356
+ </v-card-text>
357
+
358
+ <v-divider />
359
+
360
+ <v-card-actions class="pa-0">
361
+ <v-row no-gutters class="fill-height">
362
+ <v-col cols="6">
363
+ <v-btn
364
+ block
365
+ variant="flat"
366
+ height="82"
367
+ class="text-none sp-status-cancel"
368
+ :disabled="inviteWorking"
369
+ @click="closeInviteDialog"
370
+ >
371
+ Keep it
372
+ </v-btn>
373
+ </v-col>
374
+ <v-col cols="6">
375
+ <v-btn
376
+ block
377
+ variant="flat"
378
+ height="82"
379
+ class="text-none sp-status-confirm"
380
+ :loading="inviteWorking"
381
+ @click="confirmInviteAction"
382
+ >
383
+ {{ inviteConfirmLabel }}
384
+ </v-btn>
385
+ </v-col>
386
+ </v-row>
387
+ </v-card-actions>
388
+ </v-card>
389
+ </v-dialog>
390
+
287
391
  <v-dialog v-model="createDialog" width="500" persistent>
288
392
  <v-card width="100%">
289
393
  <v-toolbar>
@@ -582,6 +686,11 @@ type TableRow = {
582
686
  members: string;
583
687
  status: string;
584
688
  source: "provider" | "invite";
689
+ /** the stored status, for deciding which actions a row may offer */
690
+ statusRaw?: string;
691
+ /** shown under a "Not approved" row, in Seven365's own words */
692
+ rejectionReason?: string;
693
+ siteName?: string;
585
694
  };
586
695
 
587
696
  type ServiceProviderMemberRow = {
@@ -739,6 +848,13 @@ const { getAll: getAllMembers, getAllByUserId } = useMember();
739
848
  const { getVerifications, cancelUserInvitation, getVerificationByEmail } =
740
849
  useVerification();
741
850
 
851
+ const {
852
+ getInvitations,
853
+ cancelInvitation,
854
+ resendInvitation,
855
+ deleteInvitation,
856
+ } = useServiceProviderInvitation();
857
+
742
858
  const existingAccount = ref<any>(null);
743
859
  const checkingExistingAccount = ref(false);
744
860
  // const { getRoles } = useRole();
@@ -877,6 +993,109 @@ function canViewMembers(row: TableRow) {
877
993
  return row.source === "provider";
878
994
  }
879
995
 
996
+ // --- invitation actions (Cancel / Resend / Delete) ---------------------------
997
+ //
998
+ // Each one confirms first, and each one leaves an audit line on the server —
999
+ // who did it, when, and what. Delete is a SOFT delete: the row leaves this
1000
+ // list, Seven365 keeps the record and its history.
1001
+
1002
+ type InviteAction = "cancel" | "resend" | "delete";
1003
+
1004
+ const inviteDialog = ref(false);
1005
+ const inviteWorking = ref(false);
1006
+ const inviteActionRow = ref<TableRow | null>(null);
1007
+ const inviteAction = ref<InviteAction>("cancel");
1008
+
1009
+ function canCancelInvite(row: TableRow) {
1010
+ return ["awaiting-approval", "pending"].includes(row.statusRaw ?? "");
1011
+ }
1012
+
1013
+ function canResendInvite(row: TableRow) {
1014
+ return (row.statusRaw ?? "") === "pending";
1015
+ }
1016
+
1017
+ function resendHint(row: TableRow) {
1018
+ if (canResendInvite(row)) return undefined;
1019
+ return (row.statusRaw ?? "") === "awaiting-approval"
1020
+ ? "You can resend once Seven365 has approved it."
1021
+ : "Only an approved invitation can be sent again.";
1022
+ }
1023
+
1024
+ const inviteDialogTitle = computed(() => {
1025
+ if (inviteAction.value === "resend") return "Resend Invitation";
1026
+ if (inviteAction.value === "delete") return "Delete Invitation";
1027
+ return "Cancel Invitation";
1028
+ });
1029
+
1030
+ const inviteDialogQuestion = computed(() => {
1031
+ const row = inviteActionRow.value;
1032
+ const who = row?.companyName && row.companyName !== "-" ? row.companyName : row?.email;
1033
+ const site = row?.siteName ? ` for ${row.siteName}` : "";
1034
+
1035
+ if (inviteAction.value === "resend") {
1036
+ return `Send this invitation again to ${row?.email}?`;
1037
+ }
1038
+ if (inviteAction.value === "delete") {
1039
+ return "Delete this invitation permanently?";
1040
+ }
1041
+ return `Cancel this invitation to ${who}${site}?`;
1042
+ });
1043
+
1044
+ const inviteDialogCopy = computed(() => {
1045
+ if (inviteAction.value === "resend") {
1046
+ return "They will get a fresh message and another 3 days to accept. This does not create a second invitation.";
1047
+ }
1048
+ if (inviteAction.value === "delete") {
1049
+ return "It will disappear from this list. Seven365 keeps the record of who sent it and what happened to it.";
1050
+ }
1051
+ return "They will not be able to accept it. You can send a new one later.";
1052
+ });
1053
+
1054
+ const inviteConfirmLabel = computed(() => {
1055
+ if (inviteAction.value === "resend") return "Resend";
1056
+ if (inviteAction.value === "delete") return "Delete";
1057
+ return "Cancel invitation";
1058
+ });
1059
+
1060
+ function openInviteAction(row: TableRow, action: InviteAction) {
1061
+ if (action === "cancel" && !canCancelInvite(row)) return;
1062
+ if (action === "resend" && !canResendInvite(row)) return;
1063
+ inviteActionRow.value = row;
1064
+ inviteAction.value = action;
1065
+ inviteDialog.value = true;
1066
+ }
1067
+
1068
+ function closeInviteDialog() {
1069
+ if (inviteWorking.value) return;
1070
+ inviteDialog.value = false;
1071
+ inviteActionRow.value = null;
1072
+ }
1073
+
1074
+ async function confirmInviteAction() {
1075
+ const row = inviteActionRow.value;
1076
+ if (!row) return;
1077
+
1078
+ inviteWorking.value = true;
1079
+ try {
1080
+ const run =
1081
+ inviteAction.value === "resend"
1082
+ ? resendInvitation
1083
+ : inviteAction.value === "delete"
1084
+ ? deleteInvitation
1085
+ : cancelInvitation;
1086
+
1087
+ const result = await run(row._id);
1088
+ showMessage(result?.message ?? "Done.", "success");
1089
+ inviteDialog.value = false;
1090
+ inviteActionRow.value = null;
1091
+ await loadList();
1092
+ } catch (error: any) {
1093
+ showMessage(errorConverter(error), "error");
1094
+ } finally {
1095
+ inviteWorking.value = false;
1096
+ }
1097
+ }
1098
+
880
1099
  const statusDialog = ref(false);
881
1100
  const statusUpdating = ref(false);
882
1101
  const statusActionRow = ref<TableRow | null>(null);
@@ -908,7 +1127,11 @@ function rowActionLabel(row: TableRow) {
908
1127
 
909
1128
  function typeDisplay(type: string, nature?: string) {
910
1129
  if (nature && String(nature).trim()) return String(nature);
911
- const list = natureOfBusinessV3.value as Array<{
1130
+ // useLocal returns a plain array; reading `.value` off it was always
1131
+ // undefined, so every row fell through to the raw stored slug
1132
+ // ("security_agency") instead of "Security".
1133
+ const source = natureOfBusinessV3 as any;
1134
+ const list = (Array.isArray(source) ? source : source?.value) as Array<{
912
1135
  title?: string;
913
1136
  value?: string;
914
1137
  }>;
@@ -924,7 +1147,18 @@ function statusDisplay(raw: string, source: TableRow["source"]) {
924
1147
  return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
925
1148
  }
926
1149
 
927
- function statusChipColor(statusLabel: string) {
1150
+ const INVITE_STATUS_COLOURS: Record<string, string> = {
1151
+ "awaiting-approval": "warning",
1152
+ pending: "info",
1153
+ complete: "success",
1154
+ rejected: "error",
1155
+ declined: "default",
1156
+ cancelled: "default",
1157
+ expired: "default",
1158
+ };
1159
+
1160
+ function statusChipColor(statusLabel: string, statusRaw?: string) {
1161
+ if (statusRaw) return INVITE_STATUS_COLOURS[statusRaw] ?? "primary";
928
1162
  const s = statusLabel.toLowerCase();
929
1163
  if (s === "active") return "success";
930
1164
  if (s === "pending") return "warning";
@@ -1006,7 +1240,12 @@ function mapInviteRows(raw: Record<string, any>[]): TableRow[] {
1006
1240
  ? typeDisplay(row.metadata.app)
1007
1241
  : inviteTypeLabel(row.type ?? ""),
1008
1242
  members: "-",
1009
- status: "Pending",
1243
+ // Every invitation used to read "Pending" whatever had happened to it. It
1244
+ // now says which of the seven states it is actually in, in words.
1245
+ status: row.statusLabel || serviceProviderInviteLabel(row.status),
1246
+ statusRaw: String(row.status ?? ""),
1247
+ rejectionReason: row.rejectionReason ?? "",
1248
+ siteName: row.metadata?.siteName ?? "",
1010
1249
  source: "invite",
1011
1250
  }));
1012
1251
  }
@@ -1022,13 +1261,14 @@ async function loadList() {
1022
1261
  loading.value = true;
1023
1262
  try {
1024
1263
  if (listTab.value === "pending") {
1025
- const data = await getVerifications({
1026
- status: "pending",
1027
- type: "service-provider-invite,service-provider-create-org",
1264
+ // Everything that is not a finished engagement, so a rejection or a
1265
+ // cancellation is visible here instead of the row simply vanishing.
1266
+ const data = await getInvitations({
1267
+ org: props.orgId,
1268
+ status:
1269
+ "awaiting-approval,pending,rejected,declined,cancelled,expired",
1028
1270
  page: page.value,
1029
1271
  search: searchText.value?.trim() || "",
1030
- siteId: props.siteId,
1031
- orgId: props.orgId,
1032
1272
  limit: pageSize,
1033
1273
  });
1034
1274
  items.value = mapInviteRows(data.items ?? []);
@@ -1282,8 +1522,12 @@ async function submitServiceProviderInvite() {
1282
1522
  }
1283
1523
 
1284
1524
  .sp-table-header {
1285
- background: #f5f5f5;
1286
- border-bottom: 1px solid rgba(0, 0, 0, 0.12);
1525
+ /* Was a hardcoded #f5f5f5 with a black border: in dark mode the tab strip
1526
+ became a white slab and the tab labels and the page range disappeared
1527
+ into it. These take the colour of whichever theme they are in. */
1528
+ background: rgb(var(--v-theme-surface-light, var(--v-theme-surface)));
1529
+ border-bottom: 1px solid
1530
+ rgba(var(--v-border-color), var(--v-border-opacity));
1287
1531
  }
1288
1532
 
1289
1533
  .sp-tabs {
@@ -163,7 +163,7 @@
163
163
  @refresh="refreshSiteData"
164
164
  />
165
165
  <v-divider class="my-4" />
166
- <HidQrCodeConfiguration :site="siteId" />
166
+ <HidQrCodeConfiguration :site="siteId" :org="orgId" />
167
167
  </v-expansion-panel-text>
168
168
  </v-expansion-panel>
169
169
 
@@ -266,7 +266,9 @@ const { data: siteData, refresh: refreshSiteData } = await useLazyAsyncData(
266
266
  `site-${props.siteId}`,
267
267
  () => getSiteById(props.siteId)
268
268
  );
269
- const orgId = computed(() => props.orgId || String((siteData.value as any)?.orgId ?? ""));
269
+ const orgId = computed(
270
+ () => props.orgId || String((siteData.value as TSite | null)?.orgId ?? "")
271
+ );
270
272
 
271
273
  watch(
272
274
  siteData,