@7365admin1/layer-common 4.84.0 → 4.84.1-staging.481

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 (79) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +84 -0
  3. package/assets/css/screens.css +8 -4
  4. package/components/AccessCardDetailsDialog.vue +57 -4
  5. package/components/AccessCardPreviewDialog.vue +291 -50
  6. package/components/AppSelect.vue +143 -4
  7. package/components/AttendanceDetailsDialog.vue +48 -1
  8. package/components/AttendanceMain.vue +104 -10
  9. package/components/AttendanceProxyDialog.vue +391 -0
  10. package/components/AttendanceSettingsDialog.vue +53 -1
  11. package/components/BuildingUnitFormEdit.vue +6 -0
  12. package/components/BuildingUnitManagement.vue +298 -0
  13. package/components/BulletinBoardView.vue +1 -1
  14. package/components/Dialog/UpdateMoreAction.vue +11 -1
  15. package/components/DocumentForm.vue +407 -112
  16. package/components/DocumentManagement.vue +213 -12
  17. package/components/Facility/BookingSetup.vue +63 -0
  18. package/components/HidAccessLogDashboard.vue +74 -17
  19. package/components/HidAccessPermissions.vue +424 -0
  20. package/components/HidIntercomManagement.vue +183 -14
  21. package/components/HidProfileQrCode.vue +332 -0
  22. package/components/HidQrCodeConfiguration.vue +17 -195
  23. package/components/HidReaderManagement.vue +398 -0
  24. package/components/HidReaderUserRoster.vue +44 -3
  25. package/components/HidUserEnrollment.vue +2239 -276
  26. package/components/InventoryMain.vue +11 -2
  27. package/components/InvitationClientForm.vue +19 -1
  28. package/components/MemberServiceProviders.vue +43 -45
  29. package/components/MyAttendanceMain.vue +60 -2
  30. package/components/NotificationBell.vue +14 -2
  31. package/components/TableMain.vue +22 -7
  32. package/components/VisitorForm.vue +12 -0
  33. package/components/VisitorManagement.vue +279 -7
  34. package/composables/useAttendance.ts +53 -0
  35. package/composables/useAttendancePermission.ts +25 -0
  36. package/composables/useCleaningPermission.ts +5 -0
  37. package/composables/useDocument.ts +7 -0
  38. package/composables/useFacility.ts +11 -0
  39. package/composables/useHidAmico.ts +165 -0
  40. package/composables/useHidNavigation.ts +25 -1
  41. package/composables/useHidReaderSelection.ts +50 -0
  42. package/composables/useLandscapingPermission.ts +5 -0
  43. package/composables/useMember.ts +12 -0
  44. package/composables/useNFCPatrolReportFilters.ts +21 -3
  45. package/composables/useNFCPatrolRoute.ts +8 -1
  46. package/composables/usePeople.ts +19 -0
  47. package/composables/usePestControlPermission.ts +5 -0
  48. package/composables/usePoolMaintenancePermission.ts +5 -0
  49. package/composables/useSecurityPermission.ts +5 -0
  50. package/composables/useServiceProvider.ts +45 -2
  51. package/composables/useSiteActions.ts +29 -0
  52. package/composables/useSiteCategory.ts +45 -0
  53. package/constants/permissions.ts +1 -0
  54. package/nuxt.config.ts +18 -0
  55. package/package.json +4 -2
  56. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  57. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  58. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  59. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  60. package/plugins/vuetify.ts +6 -0
  61. package/server/plugins/no-powered-by.ts +7 -0
  62. package/types/attendance.d.ts +13 -1
  63. package/types/document.d.ts +14 -0
  64. package/types/facility.d.ts +4 -0
  65. package/types/member.d.ts +4 -0
  66. package/types/people.d.ts +28 -1
  67. package/types/service-provider.d.ts +5 -0
  68. package/types/site.d.ts +7 -1
  69. package/utils/attendance-proxy.ts +110 -0
  70. package/utils/hid-enrolment-subject.ts +344 -0
  71. package/utils/hid-permission-assignments.ts +198 -0
  72. package/utils/hid-reader-selection.ts +52 -0
  73. package/utils/notification-target.ts +43 -4
  74. package/utils/occupancy-role.ts +109 -0
  75. package/utils/sanitize-html.ts +16 -0
  76. package/utils/security-headers.ts +52 -0
  77. package/utils/service-type.ts +30 -0
  78. package/utils/site-actions.ts +18 -0
  79. package/utils/site-provider-members.ts +37 -0
@@ -149,6 +149,9 @@ const props = defineProps({
149
149
  });
150
150
 
151
151
  const movedFromEquipment = useRoute().query.moved === "equipment";
152
+ // A notification link (`?storeId=&tab=`, NotificationBell): open that store on that tab, once.
153
+ const linkQuery = useRoute().query;
154
+ const linked = { storeId: String(linkQuery.storeId ?? ""), tab: String(linkQuery.tab ?? "") };
152
155
 
153
156
  const { getStores, createStore: apiCreateStore, getRequests, getReport } = useInventory();
154
157
  const { getSites } = useSite();
@@ -227,7 +230,10 @@ async function loadStores() {
227
230
  loadError.value = "";
228
231
  try {
229
232
  stores.value = (await getStores(props.siteId)) ?? [];
230
- if (!stores.value.some((s) => s._id === storeId.value)) {
233
+ const fromLink = stores.value.find((s) => s._id === linked.storeId);
234
+ linked.storeId = "";
235
+ if (fromLink) storeId.value = fromLink._id;
236
+ else if (!stores.value.some((s) => s._id === storeId.value)) {
231
237
  // A service provider working at a property store sees it first.
232
238
  storeId.value = stores.value[0]?._id ?? "";
233
239
  }
@@ -295,7 +301,10 @@ async function createStore() {
295
301
  }
296
302
 
297
303
  watch(storeId, () => {
298
- tab.value = "stock";
304
+ // The linked tab only when this person has it (tabs are hidden, never greyed).
305
+ const wanted = linked.tab;
306
+ linked.tab = "";
307
+ tab.value = wanted && tabs.value.some((t) => t.value === wanted) ? wanted : "stock";
299
308
  refreshCounts();
300
309
  probeCompanyUsage();
301
310
  });
@@ -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
 
@@ -22,21 +22,10 @@
22
22
  </template>
23
23
  </PageHeader>
24
24
 
25
- <!-- One chip per provider company; a click filters to that company. -->
26
- <div v-if="companies.length" class="sp-chips mb-3" data-test="sp-members-companies">
27
- <button
28
- v-for="c in companies"
29
- :key="c.orgId"
30
- type="button"
31
- class="sp-chip"
32
- :class="{ 'sp-chip--active': company === c.orgId }"
33
- :aria-pressed="company === c.orgId"
34
- @click="company = company === c.orgId ? '' : c.orgId"
35
- >
36
- <span class="sp-chip__name">{{ companyChipText(c.name, c.people) }}</span>
37
- <span class="sp-chip__type">{{ c.typeLabel }}<template v-if="c.invitedVia"> · via {{ c.invitedVia }}</template></span>
38
- </button>
39
- </div>
25
+ <!-- Who is on the list at a glance; pick one company with the Company filter. -->
26
+ <p v-if="companies.length" class="sp-summary mb-3" data-test="sp-members-summary">
27
+ {{ summaryText }}
28
+ </p>
40
29
 
41
30
  <AppCard class="table-card">
42
31
  <CardToolbar :count="countText">
@@ -75,6 +64,25 @@
75
64
  aria-label="Service"
76
65
  data-test="sp-members-type"
77
66
  />
67
+ <v-autocomplete
68
+ v-model="company"
69
+ :items="companyItems"
70
+ item-title="title"
71
+ item-value="value"
72
+ density="compact"
73
+ variant="outlined"
74
+ hide-details
75
+ clearable
76
+ placeholder="All companies"
77
+ no-data-text="No company for this service"
78
+ class="sp-company-filter"
79
+ aria-label="Company"
80
+ data-test="sp-members-company"
81
+ >
82
+ <template #item="{ props: itemProps, item }">
83
+ <v-list-item v-bind="itemProps" :subtitle="item.raw.subtitle" />
84
+ </template>
85
+ </v-autocomplete>
78
86
  <AppButton
79
87
  variant="icon"
80
88
  icon="mdi-refresh"
@@ -151,10 +159,11 @@ import type {
151
159
  TSiteProviderMember,
152
160
  } from "../composables/useServiceProvider";
153
161
  import {
154
- companyChipText,
162
+ companyFilterItems,
155
163
  concernMailto,
156
164
  filterProviderMembers,
157
165
  providerMembersEmptyText,
166
+ providerSummaryText,
158
167
  serviceTypeOptions,
159
168
  } from "../utils/site-provider-members";
160
169
  import { spDay } from "../utils/sp-inviter-row";
@@ -189,7 +198,7 @@ const loading = ref(false);
189
198
  const exporting = ref(false);
190
199
  const search = ref("");
191
200
  const type = ref("");
192
- const company = ref("");
201
+ const company = ref<string | null>("");
193
202
  const snackbar = ref(false);
194
203
  const message = ref("");
195
204
 
@@ -209,9 +218,18 @@ const typeItems = computed(() => [
209
218
  ...serviceTypeOptions(items.value),
210
219
  ]);
211
220
 
221
+ const companyItems = computed(() => companyFilterItems(companies.value, type.value));
222
+ const summaryText = computed(() => providerSummaryText(companies.value));
223
+ // A company of another service cannot match; drop it when the service changes.
224
+ watch(type, () => {
225
+ if (company.value && !companyItems.value.some((c) => c.value === company.value)) {
226
+ company.value = "";
227
+ }
228
+ });
229
+
212
230
  const filters = computed(() => ({
213
231
  type: type.value,
214
- company: company.value,
232
+ company: company.value || "",
215
233
  search: search.value,
216
234
  }));
217
235
 
@@ -294,41 +312,21 @@ onMounted(async () => {
294
312
  </script>
295
313
 
296
314
  <style scoped>
297
- .sp-chips {
298
- display: flex;
299
- flex-wrap: wrap;
300
- gap: 8px;
301
- }
302
-
303
- .sp-chip {
304
- display: flex;
305
- flex-direction: column;
306
- align-items: flex-start;
307
- padding: 8px 12px;
308
- border: 1px solid var(--border);
309
- border-radius: var(--r-inner);
310
- background: var(--card);
311
- color: var(--text);
312
- text-align: left;
313
- cursor: pointer;
314
- }
315
-
316
- .sp-chip--active {
317
- border-color: var(--accent-strong);
318
- box-shadow: inset 0 0 0 1px var(--accent-strong);
319
- }
320
-
321
- .sp-chip__name {
322
- font-weight: 600;
315
+ .sp-summary {
323
316
  font-size: 14px;
317
+ color: var(--muted);
324
318
  }
325
319
 
326
- .sp-chip__type,
327
320
  .sp-sub {
328
321
  font-size: 12px;
329
322
  color: var(--muted);
330
323
  }
331
324
 
325
+ .sp-company-filter {
326
+ min-width: 220px;
327
+ max-width: 300px;
328
+ }
329
+
332
330
  .sp-type-filter {
333
331
  min-width: 170px;
334
332
  max-width: 220px;
@@ -1,5 +1,31 @@
1
1
  <template>
2
2
  <v-row no-gutters align="center" justify="center">
3
+ <!-- Site device mode is ON for this site: this shared phone can check other
4
+ people in and out (owner request 2026-10-01). -->
5
+ <v-col v-if="siteDeviceMode && canCheckInOut" cols="12" class="mb-4">
6
+ <v-alert
7
+ type="info"
8
+ variant="tonal"
9
+ icon="mdi-cellphone-check"
10
+ data-test="site-device-mode-banner"
11
+ >
12
+ <div class="font-weight-medium">Site device mode is on</div>
13
+ <div class="mb-3">
14
+ This phone can check staff in and out for them: take their photo, type their
15
+ name and NRIC. Only the last 4 characters of the NRIC are saved.
16
+ </div>
17
+ <v-btn
18
+ class="text-none screen-btn-primary"
19
+ size="large"
20
+ variant="flat"
21
+ prepend-icon="mdi-account-clock"
22
+ data-test="attendance-proxy-open-btn"
23
+ @click="dialogShowProxy = true"
24
+ >
25
+ Check in / Check out for someone
26
+ </v-btn>
27
+ </v-alert>
28
+ </v-col>
3
29
  <v-col cols="12" lg="12">
4
30
  <TableMain
5
31
  title="Attendance Records"
@@ -71,6 +97,14 @@
71
97
  @close="dialogShowMoreActions = false"
72
98
  />
73
99
 
100
+ <AttendanceProxyDialog
101
+ v-if="siteDeviceMode"
102
+ v-model="dialogShowProxy"
103
+ :site="props.site"
104
+ :service-type="props.serviceType"
105
+ @saved="onProxySaved"
106
+ />
107
+
74
108
  <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
75
109
  </template>
76
110
 
@@ -107,7 +141,31 @@ const headers = [
107
141
  { title: "Total Hours", value: "totalHours" },
108
142
  ];
109
143
 
110
- const { getMyAttendances, checkIn, checkOut } = useAttendance();
144
+ const { getMyAttendances, checkIn, checkOut, getAttendanceSettings } = useAttendance();
145
+
146
+ // Site device mode: read once per site. A failed read means OFF (today's flow).
147
+ const siteDeviceMode = ref(false);
148
+ const dialogShowProxy = ref(false);
149
+ watch(
150
+ () => [props.site, props.serviceType],
151
+ async () => {
152
+ siteDeviceMode.value = false;
153
+ if (!props.site) return;
154
+ try {
155
+ const res: any = await getAttendanceSettings(props.site, props.serviceType);
156
+ siteDeviceMode.value = res?.siteDeviceMode === true;
157
+ } catch {
158
+ siteDeviceMode.value = false;
159
+ }
160
+ },
161
+ { immediate: true }
162
+ );
163
+
164
+ function onProxySaved(text: string) {
165
+ message.value = text;
166
+ messageColor.value = "success";
167
+ messageSnackbar.value = true;
168
+ }
111
169
  const { formatDate } = useUtils();
112
170
 
113
171
  const dialogShowMoreActions = ref(false);
@@ -170,7 +228,7 @@ watchEffect(() => {
170
228
  const today = new Date().toISOString().split("T")[0];
171
229
  todayAttendance.value =
172
230
  getMyAttendancesReq.value.items.find((item: any) => {
173
- if (!item.checkInTimestamp) return false;
231
+ if (!item.checkInTimestamp || item.isProxy) return false;
174
232
  const itemDate = new Date(item.checkInTimestamp)
175
233
  .toISOString()
176
234
  .split("T")[0];
@@ -127,6 +127,13 @@
127
127
  <v-btn variant="text" class="text-none" @click="viewFromToast">View</v-btn>
128
128
  </template>
129
129
  </v-snackbar>
130
+
131
+ <v-snackbar v-model="nowhere.show" location="top right" :timeout="6000" color="surface">
132
+ <div class="d-flex align-center ga-3">
133
+ <v-icon icon="mdi-information-outline" color="primary" />
134
+ <div class="text-body-2">{{ nowhere.text }}</div>
135
+ </div>
136
+ </v-snackbar>
130
137
  </div>
131
138
  </template>
132
139
 
@@ -137,7 +144,7 @@ import {
137
144
  timeAgo,
138
145
  type TInboxItem,
139
146
  } from "../utils/notification-inbox";
140
- import { appUrlsFrom, chooseNavigation, notificationTarget } from "../utils/notification-target";
147
+ import { appUrlsFrom, chooseNavigation, noTargetMessage, notificationTarget } from "../utils/notification-target";
141
148
 
142
149
  /**
143
150
  * The top-bar notification feed for the web, in every signed-in app.
@@ -180,6 +187,8 @@ const markingAll = ref(false);
180
187
  const items = ref<Array<Record<string, any>>>([]);
181
188
  const unread = ref(0);
182
189
  const toast = reactive({ show: false, title: "", body: "" });
190
+ /** Said when a clicked row has no page to open - a click is never a silent nothing. */
191
+ const nowhere = reactive({ show: false, text: "" });
183
192
 
184
193
  const userId = computed(() => String(currentUser.value?._id ?? ""));
185
194
  const badgeLabel = computed(() => badgeText(unread.value));
@@ -341,7 +350,10 @@ async function goToSubject(item: Record<string, any>) {
341
350
  hasPage: hasLocalPage,
342
351
  appUrls: appUrls(),
343
352
  });
344
- if (!nav) return;
353
+ if (!nav) {
354
+ Object.assign(nowhere, { show: true, text: noTargetMessage(item as any) });
355
+ return;
356
+ }
345
357
 
346
358
  open.value = false;
347
359
  if (nav.type === "href") {
@@ -59,17 +59,32 @@
59
59
  >
60
60
  {{ createLabel }}
61
61
  </v-btn>
62
- <v-text-field
62
+ <!--
63
+ The design's search field. This was a `v-text-field` with only a
64
+ `max-width`, so inside the actions row it shrank to its minimum
65
+ and showed "Se" and a magnifier, at a different height and
66
+ border from the button beside it. Same `search` model; clearing
67
+ now sets "" instead of Vuetify's null.
68
+ -->
69
+ <AppField
63
70
  v-if="canSearch"
64
71
  v-model="searchInput"
65
- density="compact"
72
+ search
66
73
  placeholder="Search"
67
- clearable
68
- max-width="300"
69
- append-inner-icon="mdi-magnify"
70
- hide-details
74
+ aria-label="Search"
71
75
  class="table-card__search"
72
- />
76
+ >
77
+ <template v-if="searchInput" #trailing>
78
+ <button
79
+ type="button"
80
+ class="app-field__trailing"
81
+ aria-label="Clear search"
82
+ @click="searchInput = ''"
83
+ >
84
+ <v-icon icon="mdi-close" size="16" />
85
+ </button>
86
+ </template>
87
+ </AppField>
73
88
  <!-- Ghost/secondary, as the design specifies for Scan QR Code. -->
74
89
  <v-btn
75
90
  v-if="canScanVisitorQRCode"
@@ -910,8 +910,20 @@ const hidQrReaderId = computed(() => (
910
910
  hidQrConfiguredReaderId.value || String(hidQrReader.value?._id || "")
911
911
  ));
912
912
 
913
+ /*
914
+ * Visitor QR passes are hidden at the manager's request (2026-09-23).
915
+ *
916
+ * A flag rather than a deletion: issuing, the preview dialog, printing and the
917
+ * regenerate handler below are all intact and untouched, so bringing the
918
+ * feature back is this one line. Every surface that offers a Visitor QR reads
919
+ * `hidQrCodePassAllowed`, which is why the switch belongs here and not in each
920
+ * of them.
921
+ */
922
+ const HID_VISITOR_QR_ENABLED = false;
923
+
913
924
  const hidQrCodePassAllowed = computed(() => {
914
925
  return (
926
+ HID_VISITOR_QR_ENABLED &&
915
927
  hidQrCodePassEnabled.value &&
916
928
  ["guest", "contractor", "walk-in"].includes(prop.type)
917
929
  );