@7365admin1/layer-common 4.90.1 → 4.90.2-staging.484

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/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +57 -0
  3. package/assets/css/robot-hero.css +3 -0
  4. package/assets/css/screens.css +8 -4
  5. package/components/AccessCardDetailsDialog.vue +57 -4
  6. package/components/AccessCardPreviewDialog.vue +291 -50
  7. package/components/AppSelect.vue +143 -4
  8. package/components/BuildingUnitFormEdit.vue +6 -0
  9. package/components/BuildingUnitManagement.vue +298 -0
  10. package/components/ClientMain.vue +56 -14
  11. package/components/Dialog/UpdateMoreAction.vue +11 -1
  12. package/components/DocumentForm.vue +407 -112
  13. package/components/DocumentManagement.vue +213 -12
  14. package/components/Facility/BookingSetup.vue +63 -0
  15. package/components/HidAccessLogDashboard.vue +74 -17
  16. package/components/HidAccessPermissions.vue +424 -0
  17. package/components/HidIntercomManagement.vue +183 -14
  18. package/components/HidProfileQrCode.vue +332 -0
  19. package/components/HidQrCodeConfiguration.vue +17 -195
  20. package/components/HidReaderManagement.vue +398 -0
  21. package/components/HidReaderUserRoster.vue +44 -3
  22. package/components/HidUserEnrollment.vue +2239 -276
  23. package/components/InvitationClientForm.vue +19 -1
  24. package/components/MemberInformation.vue +4 -4
  25. package/components/PassInformation.vue +2 -2
  26. package/components/TableMain.vue +22 -7
  27. package/components/VisitorForm.vue +65 -19
  28. package/components/VisitorManagement.vue +267 -5
  29. package/composables/useDocument.ts +7 -0
  30. package/composables/useFacility.ts +11 -0
  31. package/composables/useHidAmico.ts +165 -0
  32. package/composables/useHidNavigation.ts +25 -1
  33. package/composables/useHidReaderSelection.ts +50 -0
  34. package/composables/useMember.ts +12 -0
  35. package/composables/useNFCPatrolReportFilters.ts +21 -3
  36. package/composables/useNFCPatrolRoute.ts +8 -1
  37. package/composables/usePeople.ts +19 -0
  38. package/composables/useServiceProvider.ts +45 -2
  39. package/composables/useSiteCategory.ts +45 -0
  40. package/package.json +1 -1
  41. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  42. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  43. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  44. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  45. package/types/document.d.ts +14 -0
  46. package/types/facility.d.ts +4 -0
  47. package/types/member.d.ts +4 -0
  48. package/types/people.d.ts +28 -1
  49. package/types/service-provider.d.ts +5 -0
  50. package/types/site.d.ts +7 -1
  51. package/utils/hid-enrolment-subject.ts +344 -0
  52. package/utils/hid-permission-assignments.ts +198 -0
  53. package/utils/hid-reader-selection.ts +52 -0
  54. package/utils/occupancy-role.ts +109 -0
  55. 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
 
@@ -10,12 +10,12 @@
10
10
  <v-form ref="formRef" v-model="validForm" :disabled="processing" @click="errorScanPassMessage = ''">
11
11
  <v-row no-gutters class="pt-5 ga-2">
12
12
  <v-col cols="12">
13
- <InputLabel class="text-capitalize" title="Full Name" required />
13
+ <InputLabel class="text-capitalize" title="Full Name (Required)" required />
14
14
  <v-text-field v-model.trim="memberForm.name" density="compact" hide-details :rules="[requiredRule]" />
15
15
  </v-col>
16
16
 
17
17
  <v-col cols="12">
18
- <InputLabel class="text-capitalize" title="NRIC" required />
18
+ <InputLabel class="text-capitalize" title="NRIC (Required)" required />
19
19
  <v-menu v-model="memberNRICSearchMenu" location="bottom" offset-y :close-on-content-click="false">
20
20
  <template #activator="{ props: menuProps }">
21
21
  <InputNRICNumber v-bind="menuProps" v-model.trim="memberForm.nric" density="compact" hide-details
@@ -34,7 +34,7 @@
34
34
  </v-col>
35
35
 
36
36
  <v-col cols="12">
37
- <InputLabel class="text-capitalize" title="Phone Number" required />
37
+ <InputLabel class="text-capitalize" title="Phone Number (Required)" required />
38
38
  <v-menu v-model="memberPhoneSearchMenu" location="bottom" offset-y :close-on-content-click="false">
39
39
  <template #activator="{ props: menuProps }">
40
40
  <InputPhoneNumberV2 v-bind="menuProps" v-model="memberForm.contact" density="compact"
@@ -54,7 +54,7 @@
54
54
 
55
55
  <v-col cols="12">
56
56
  <v-autocomplete v-model="selectedPass" v-model:search="passInput" :hide-no-data="false" class="mt-3"
57
- :items="passItemsFilteredFinal" item-title="prefixAndName" item-value="_id" label="Pass (optional)"
57
+ :items="passItemsFilteredFinal" item-title="prefixAndName" item-value="_id" label="Pass (Optional)"
58
58
  variant="outlined" density="compact" :error-messages="errorScanPassMessage" persistent-hint small-chips
59
59
  :clearable="clearable" @click="fetchPasses">
60
60
  <template v-slot:no-data>
@@ -19,7 +19,7 @@
19
19
  :rules="props.passRules"
20
20
  item-title="prefixAndName"
21
21
  item-value="_id"
22
- label="Pass"
22
+ label="Pass (Optional)"
23
23
  variant="outlined"
24
24
  hide-details
25
25
  persistent-hint
@@ -48,7 +48,7 @@
48
48
  :rules="props.passRules"
49
49
  item-title="prefixAndName"
50
50
  item-value="_id"
51
- label="Keys"
51
+ label="Keys (Optional)"
52
52
  multiple
53
53
  variant="outlined"
54
54
  hide-details
@@ -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"
@@ -16,14 +16,14 @@
16
16
  <v-form ref="formRef" v-model="validForm" :disabled="processing" @click="errorMessage = ''">
17
17
  <v-row no-gutters class="pt-4">
18
18
  <v-col v-if="shouldShowField('attachments')" class="w-100">
19
- <InputLabel class="text-capitalize" title="Attach Image" />
19
+ <InputLabel class="text-capitalize" title="Attach Image (Optional)" />
20
20
  <InputFileV2 v-model="visitor.attachments" multiple :max-length="3" />
21
21
  </v-col>
22
22
 
23
23
  <v-col v-if="shouldShowField('nric')" cols="12">
24
24
  <v-row>
25
25
  <v-col cols="12">
26
- <InputLabel class="text-capitalize" title="NRIC" :required="requireNRIC" />
26
+ <InputLabel class="text-capitalize" :title="requireNRIC ? 'NRIC (Required)' : 'NRIC (Optional)'" :required="requireNRIC" />
27
27
  <v-menu v-model="nricSearchMenu" location="bottom" offset-y :close-on-content-click="false"
28
28
  content-class="visitor-form__menu">
29
29
  <template #activator="{ props }">
@@ -55,11 +55,11 @@
55
55
  <v-col v-if="shouldShowField('contact')" cols="12">
56
56
  <v-row>
57
57
  <v-col cols="12">
58
- <InputLabel class="text-capitalize" title="Phone Number" required />
58
+ <InputLabel class="text-capitalize" :title="requireContact ? 'Phone Number (Required)' : 'Phone Number (Optional)'" :required="requireContact" />
59
59
  <v-menu v-model="phoneSearchMenu" location="bottom" offset-y :close-on-content-click="false"
60
60
  content-class="visitor-form__menu">
61
61
  <template #activator="{ props }">
62
- <InputPhoneNumberV2 v-bind="props" v-model="visitor.contact" :rules="[requiredRule]"
62
+ <InputPhoneNumberV2 v-bind="props" v-model="visitor.contact" :rules="[requireContact ? requiredRule : () => true]"
63
63
  @focus="isPhoneFieldFocused = true" @blur="isPhoneFieldFocused = false" density="comfortable"
64
64
  :key="currentAutofillSource + 'phone-key'" @update:model-value="handleUpdatePhoneNumber" />
65
65
  </template>
@@ -87,12 +87,13 @@
87
87
  <v-col v-if="shouldShowField('plateNumber')" cols="12">
88
88
  <v-row>
89
89
  <v-col cols="12">
90
- <InputLabel class="text-capitalize" title="Vehicle Number" :required="isVehicleRequired" />
90
+ <InputLabel class="text-capitalize" :title="isVehicleRequired ? 'Vehicle Number (Required)' : 'Vehicle Number (Optional)'" :required="isVehicleRequired" />
91
91
  <!-- <v-text-field v-model.trim.uppercase="visitor.plateNumber" density="comfortable" /> -->
92
92
  <v-menu v-model="vehicleSearchMenu" location="bottom" offset-y :close-on-content-click="false"
93
93
  content-class="visitor-form__menu">
94
94
  <template #activator="{ props }">
95
95
  <InputVehicleNumber v-bind="props" v-model="visitor.plateNumber" density="comfortable"
96
+ :rules="[isVehicleRequired ? requiredRule : () => true]"
96
97
  @update:model-value="handleUpdateVehicleNumber" @focus="handleFocusPlateNumber"
97
98
  @blur="isPlateNumberFieldFocused = false" />
98
99
  </template>
@@ -117,7 +118,7 @@
117
118
  </v-col>
118
119
 
119
120
  <v-col v-if="shouldShowField('contractorType')" cols="12">
120
- <InputLabel class="text-capitalize" title="Contractor Type" required />
121
+ <InputLabel class="text-capitalize" title="Contractor Type (Required)" required />
121
122
  <v-combobox v-model="contractorTypeObj" v-model:search="contractorTypeInput" ref="contractorTypeCombo"
122
123
  autocomplete="off" :hide-no-data="false" @update:focused="handleFocusedContractorType"
123
124
  :items="contractorTypes" :rules="[requiredRule]" item-value="value"
@@ -139,7 +140,7 @@
139
140
  <v-col v-if="shouldShowField('name')" cols="12">
140
141
  <v-row>
141
142
  <v-col cols="12">
142
- <InputLabel class="text-capitalize" title="Full Name" required />
143
+ <InputLabel class="text-capitalize" title="Full Name (Required)" required />
143
144
  <v-text-field v-model.trim="visitor.name" density="comfortable" :rules="[requiredRule]" />
144
145
  </v-col>
145
146
  </v-row>
@@ -159,7 +160,7 @@
159
160
  place of `primary`, which is the darker link tone. Without
160
161
  the label this was the only required field in the form with
161
162
  nothing naming it: just a bare Food/Parcel pair. -->
162
- <InputLabel class="text-capitalize" title="Delivery Type" required />
163
+ <InputLabel class="text-capitalize" title="Delivery Type (Required)" required />
163
164
  <v-radio-group v-model="visitor.deliveryType" inline color="accent" :rules="[requiredRule]">
164
165
  <v-radio label="Food" value="Food"></v-radio>
165
166
  <v-radio label="Parcel" value="Parcel"></v-radio>
@@ -170,8 +171,9 @@
170
171
 
171
172
  <template v-if="shouldShowField('company')">
172
173
  <v-col cols="12">
173
- <InputLabel class="text-capitalize" title="Company Name" />
174
+ <InputLabel class="text-capitalize" :title="requireCompany ? 'Company Name (Required)' : 'Company Name (Optional)'" :required="requireCompany" />
174
175
  <v-combobox v-model="visitor.company" v-model:search="companyNameInput" ref="companyCombo"
176
+ :rules="[requireCompany ? requiredRule : () => true]"
175
177
  autocomplete="off" :hide-no-data="true" :items="companyNames" item-value="value"
176
178
  @update:model-value="handleSelectCompanyName" variant="outlined" density="comfortable" persistent-hint
177
179
  small-chips>
@@ -182,8 +184,9 @@
182
184
 
183
185
  <template v-if="shouldShowField('delivery-company')">
184
186
  <v-col cols="12">
185
- <InputLabel class="text-capitalize" title="Delivery Company" />
187
+ <InputLabel class="text-capitalize" title="Delivery Company (Required)" required />
186
188
  <v-combobox v-model="deliveryCompany" v-model:search="deliveryCompanyInput" ref="companyCombo"
189
+ :rules="[requiredRule]"
187
190
  autocomplete="off" :hide-no-data="false" :items="deliveryCompanyList" item-value="value"
188
191
  :loading="siteDataPending" variant="outlined" density="comfortable" persistent-hint small-chips>
189
192
  <template v-slot:no-data>
@@ -205,7 +208,7 @@
205
208
 
206
209
 
207
210
  <v-col v-if="shouldShowField('block')" cols="12">
208
- <InputLabel class="text-capitalize" title="Block" required />
211
+ <InputLabel class="text-capitalize" title="Block (Required)" required />
209
212
  <v-autocomplete v-model="visitor.block" :items="blocksArray" item-value="value" autocomplete="off"
210
213
  item-title="title" :key="blockListDataPending ? 'block-list-pending' : 'block-list-ready'
211
214
  " :loading="blockListDataPending" @update:model-value="handleChangeBlock" density="comfortable"
@@ -213,13 +216,13 @@
213
216
  </v-col>
214
217
 
215
218
  <v-col v-if="shouldShowField('level')" cols="12">
216
- <InputLabel class="text-capitalize" title="Level" required />
219
+ <InputLabel class="text-capitalize" title="Level (Required)" required />
217
220
  <v-autocomplete v-model="visitor.level" :items="levelsArray" density="comfortable" autocomplete="off"
218
221
  :disabled="!visitor.block" :key="levelsDataPending ? 'level-list-pending' : 'level-list-ready'
219
222
  " :loading="levelsDataPending" @update:model-value="handleChangeLevel" :rules="[requiredRule]" />
220
223
  </v-col>
221
224
  <v-col v-if="shouldShowField('unit')" cols="12">
222
- <InputLabel class="text-capitalize" title="Unit" required />
225
+ <InputLabel class="text-capitalize" title="Unit (Required)" required />
223
226
  <v-autocomplete v-model="visitor.unit" :items="unitsArray" density="comfortable" autocomplete="off"
224
227
  item-title="title" item-value="value" :disabled="!visitor.level"
225
228
  :key="unitsDataPending ? 'unit-list-pending' : 'unit-list-ready'" :loading="unitsDataPending"
@@ -227,14 +230,14 @@
227
230
  </v-col>
228
231
 
229
232
  <v-col v-if="shouldShowField('unit')" cols="12">
230
- <InputLabel class="text-capitalize" title="Registered Unit Company Name" required
233
+ <InputLabel class="text-capitalize" title="Registered Unit Company Name"
231
234
  :key="'unit-key' + visitor.level" />
232
235
  <v-text-field v-model.trim="registeredUnitCompanyName" density="comfortable"
233
236
  :loading="buildingUnitDataPending" readonly class="no-pointer" />
234
237
  </v-col>
235
238
 
236
239
  <v-col v-if="shouldShowField('remarks')" cols="12">
237
- <InputLabel class="text-capitalize" title="Remarks" />
240
+ <InputLabel class="text-capitalize" title="Remarks (Optional)" />
238
241
  <v-textarea v-model="visitor.remarks" density="comfortable" :rows="3" no-resize />
239
242
  </v-col>
240
243
 
@@ -831,12 +834,29 @@ const formMode = computed<"add" | "edit" | "register">(() => {
831
834
  return selectedUnregisteredVisitor.value ? "register" : prop.mode;
832
835
  });
833
836
 
837
+ // Fields this form actually has for the visitor type - drives both what is
838
+ // shown and what is sent on submit, so nothing outside the form is saved.
839
+ // typeFieldMap also feeds the details dialog, so it can list more than this:
840
+ // - walk-in "company": not on the security mobile app's walk-in, hidden here
841
+ // but still shown on older walk-in records in the details dialog;
842
+ // - guest "purpose": never drawn in this form.
843
+ const formFieldsExcluded: Partial<Record<TVisitorType, string[]>> = {
844
+ "walk-in": ["company"],
845
+ guest: ["purpose"],
846
+ };
847
+
848
+ const isFormField = (
849
+ fieldKey: keyof TVisitorPayload | "delivery-company"
850
+ ) => {
851
+ if (formFieldsExcluded[prop.type]?.includes(fieldKey)) return false;
852
+ return !!typeFieldMap[prop.type]?.includes(fieldKey);
853
+ };
854
+
834
855
  const shouldShowField = (
835
856
  fieldKey: keyof TVisitorPayload | "delivery-company"
836
857
  ) => {
837
858
  if ((!withStep2.value && !withStep3.value) || contractorStep.value === 1) {
838
- const visibleFields = typeFieldMap[prop.type];
839
- return visibleFields?.includes(fieldKey);
859
+ return isFormField(fieldKey);
840
860
  }
841
861
  };
842
862
 
@@ -866,8 +886,21 @@ const showShowKeysField = computed(() => {
866
886
  return ["guest", "contractor", "walk-in"].includes(prop.type);
867
887
  });
868
888
 
889
+ // Required / optional per type - kept in line with the security mobile app.
869
890
  const requireNRIC = computed(() => {
870
- return prop.type !== "walk-in" && prop.type !== "guest";
891
+ return prop.type === "contractor";
892
+ });
893
+
894
+ const requireContact = computed(() => {
895
+ return ["contractor", "delivery"].includes(prop.type);
896
+ });
897
+
898
+ const isVehicleRequired = computed(() => {
899
+ return ["guest", "pick-up", "drop-off"].includes(prop.type);
900
+ });
901
+
902
+ const requireCompany = computed(() => {
903
+ return prop.type === "contractor";
871
904
  });
872
905
 
873
906
  const hidQrCodePassConfig = computed(() => {
@@ -910,8 +943,20 @@ const hidQrReaderId = computed(() => (
910
943
  hidQrConfiguredReaderId.value || String(hidQrReader.value?._id || "")
911
944
  ));
912
945
 
946
+ /*
947
+ * Visitor QR passes are hidden at the manager's request (2026-09-23).
948
+ *
949
+ * A flag rather than a deletion: issuing, the preview dialog, printing and the
950
+ * regenerate handler below are all intact and untouched, so bringing the
951
+ * feature back is this one line. Every surface that offers a Visitor QR reads
952
+ * `hidQrCodePassAllowed`, which is why the switch belongs here and not in each
953
+ * of them.
954
+ */
955
+ const HID_VISITOR_QR_ENABLED = false;
956
+
913
957
  const hidQrCodePassAllowed = computed(() => {
914
958
  return (
959
+ HID_VISITOR_QR_ENABLED &&
915
960
  hidQrCodePassEnabled.value &&
916
961
  ["guest", "contractor", "walk-in"].includes(prop.type)
917
962
  );
@@ -1942,7 +1987,8 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
1942
1987
  if (formMode.value === "add" || formMode.value === "register") {
1943
1988
  const allowedFields = typeFieldMap[prop.type];
1944
1989
  for (const key of allowedFields as (keyof TVisitorPayload)[]) {
1945
- if (visitor[key] !== undefined) {
1990
+ // only what the form has (see isFormField)
1991
+ if (isFormField(key) && visitor[key] !== undefined) {
1946
1992
  payload = {
1947
1993
  ...payload,
1948
1994
  [key]: visitor[key],
@@ -750,11 +750,35 @@
750
750
  class="visitor-preview__row"
751
751
  >
752
752
  <div class="visitor-preview__label">{{ label }}</div>
753
- <div
754
- v-tooltip:top="selectedVisitorObject[key]"
755
- class="visitor-preview__value"
756
- >
757
- {{ maskNRIC(selectedVisitorObject[key]) }}
753
+ <div>
754
+ <!-- Was a hover tooltip on the value: the full NRIC only
755
+ reachable by hovering, which says nothing is there to
756
+ reach and cannot be reached at all on a touch screen.
757
+ The eye alone, at the 14px the Incident Report screens
758
+ draw it, is a 14px target - the floor is 24px. So the
759
+ NUMBER is the control and the eye is its affordance:
760
+ the whole masked value is pressable, which makes the
761
+ target the width of the digits rather than of a glyph,
762
+ and being a real `button` it also takes a tab stop,
763
+ which a `v-icon` with a click handler never did. -->
764
+ <button
765
+ type="button"
766
+ class="visitor-preview__reveal"
767
+ :aria-label="showVisitorNric ? 'Hide NRIC' : 'Show NRIC'"
768
+ :aria-pressed="showVisitorNric"
769
+ @click="showVisitorNric = !showVisitorNric"
770
+ >
771
+ <span class="visitor-preview__value">
772
+ {{
773
+ showVisitorNric
774
+ ? selectedVisitorObject[key]
775
+ : maskNRIC(selectedVisitorObject[key])
776
+ }}
777
+ </span>
778
+ <v-icon size="16" class="visitor-preview__eye">
779
+ {{ showVisitorNric ? "mdi-eye-off-outline" : "mdi-eye-outline" }}
780
+ </v-icon>
781
+ </button>
758
782
  </div>
759
783
  </div>
760
784
 
@@ -804,6 +828,84 @@
804
828
  </div>
805
829
  </div>
806
830
 
831
+ <!--
832
+ THE CONTRACTOR'S PARTY.
833
+
834
+ The list row has always said `( +2 members)` beside the name
835
+ and then had nowhere to send you for the names themselves. The
836
+ block spans both columns rather than sitting in the 116px
837
+ value track: a member is a record, not a value, and several of
838
+ them in a 250px column is unreadable.
839
+
840
+ Read off `selectedVisitorDataObject` (the raw row) and not
841
+ `selectedVisitorObject`, which filters itself down to
842
+ `typeFieldMap` plus five fixed keys - `members` is on neither
843
+ list, and widening that computed would change what four other
844
+ dialogs on this screen see.
845
+ -->
846
+ <div
847
+ v-else-if="key === 'members' && selectedVisitorMembers.length"
848
+ class="visitor-preview__row visitor-preview__row--block"
849
+ >
850
+ <div class="visitor-preview__label">
851
+ {{ label }} ({{ selectedVisitorMembers.length }})
852
+ </div>
853
+ <div class="visitor-preview__members">
854
+ <div
855
+ v-for="(member, index) in selectedVisitorMembers"
856
+ :key="member?.nric || member?.name || index"
857
+ class="visitor-preview__member"
858
+ >
859
+ <div class="visitor-preview__value">
860
+ {{ member?.name || "N/A" }}
861
+ </div>
862
+ <template v-for="field in memberDetailFields" :key="field.key">
863
+ <div
864
+ v-if="member?.[field.key]"
865
+ class="visitor-preview__member-line"
866
+ >
867
+ <span class="visitor-preview__member-key">
868
+ {{ field.label }}
869
+ </span>
870
+ <!-- A member's NRIC is the same class of data as the
871
+ visitor's, so it gets the same control. Each
872
+ member reveals separately - one toggle for the
873
+ whole card would show three people's NRICs to
874
+ read one. -->
875
+ <button
876
+ v-if="field.key === 'nric'"
877
+ type="button"
878
+ class="visitor-preview__reveal"
879
+ :aria-label="
880
+ isMemberNricShown(index)
881
+ ? `Hide NRIC for ${member?.name || 'this member'}`
882
+ : `Show NRIC for ${member?.name || 'this member'}`
883
+ "
884
+ :aria-pressed="isMemberNricShown(index)"
885
+ @click="toggleMemberNric(index)"
886
+ >
887
+ <span>
888
+ {{
889
+ isMemberNricShown(index)
890
+ ? member.nric
891
+ : maskNRIC(member.nric)
892
+ }}
893
+ </span>
894
+ <v-icon size="16" class="visitor-preview__eye">
895
+ {{
896
+ isMemberNricShown(index)
897
+ ? "mdi-eye-off-outline"
898
+ : "mdi-eye-outline"
899
+ }}
900
+ </v-icon>
901
+ </button>
902
+ <span v-else>{{ member[field.key] }}</span>
903
+ </div>
904
+ </template>
905
+ </div>
906
+ </div>
907
+ </div>
908
+
807
909
  <!-- The vehicle number, with Block on its right: the Registered
808
910
  tab only, for someone who may add vehicles. It opens the
809
911
  Vehicle Mgmt Block Listed form with the category taken from
@@ -2124,6 +2226,71 @@ const isSelectedVisitorPendingApproval = computed(
2124
2226
  () => selectedVisitorStatus.value === "pending"
2125
2227
  );
2126
2228
 
2229
+ /**
2230
+ * The members a contractor brought with them. Not gated on the Guests tab: the
2231
+ * list row's own `( +N members)` count is not either, and a contractor with a
2232
+ * party has one wherever the screen shows it.
2233
+ *
2234
+ * Blank entries are dropped rather than drawn as an empty card - the payload
2235
+ * carries `""` for every detail nobody filled in, so a member with no name and
2236
+ * no details would otherwise render as a bare inset box.
2237
+ */
2238
+ const selectedVisitorMembers = computed<Record<string, any>[]>(() => {
2239
+ const members = selectedVisitorDataObject.value?.members;
2240
+ if (!Array.isArray(members)) return [];
2241
+
2242
+ return members.filter(
2243
+ (member: Record<string, any>) =>
2244
+ member?.name || member?.nric || member?.contact || member?.plateNumber
2245
+ );
2246
+ });
2247
+
2248
+ /**
2249
+ * WHICH NRICs ARE UNCOVERED RIGHT NOW.
2250
+ *
2251
+ * Per member and not per card, so revealing one person's number does not
2252
+ * uncover the rest of the party's. Both are cleared when the dialog closes -
2253
+ * a preview must not open on the next visitor with the last one's NRIC
2254
+ * already showing, and it is the same `selectedVisitorId` that would carry it
2255
+ * across if the same row were reopened.
2256
+ */
2257
+ const showVisitorNric = ref(false);
2258
+ const shownMemberNrics = ref<number[]>([]);
2259
+
2260
+ function isMemberNricShown(index: number) {
2261
+ return shownMemberNrics.value.includes(index);
2262
+ }
2263
+
2264
+ function toggleMemberNric(index: number) {
2265
+ shownMemberNrics.value = isMemberNricShown(index)
2266
+ ? shownMemberNrics.value.filter((x) => x !== index)
2267
+ : [...shownMemberNrics.value, index];
2268
+ }
2269
+
2270
+ watch(
2271
+ () => dialog.viewVisitor,
2272
+ (open) => {
2273
+ if (open) return;
2274
+ showVisitorNric.value = false;
2275
+ shownMemberNrics.value = [];
2276
+ }
2277
+ );
2278
+
2279
+ /**
2280
+ * What a member row prints under the name, in order. `plateNumber` is in here
2281
+ * because the API returns it on `members[]` even though neither
2282
+ * `TMemberInfo` (this package) nor `TContractorMembers` (API-core's visitor
2283
+ * model, name and nric only) declares it - the two types have drifted from the
2284
+ * document and from each other. Reading the key defensively costs nothing and
2285
+ * shows the vehicle a member actually arrived in; the types are somebody's to
2286
+ * reconcile separately.
2287
+ */
2288
+ const memberDetailFields = [
2289
+ { key: "nric", label: "NRIC" },
2290
+ { key: "contact", label: "Contact" },
2291
+ { key: "plateNumber", label: "Vehicle" },
2292
+ ] as const;
2293
+
2127
2294
  const formatType = (item: any) =>
2128
2295
  (item.deliveryType ? item.deliveryType + "-" : "") + item.type;
2129
2296
 
@@ -2137,6 +2304,7 @@ const formattedFields = {
2137
2304
  level: "Level",
2138
2305
  unit: "Unit",
2139
2306
  purpose: "Purpose",
2307
+ members: "Members",
2140
2308
  checkIn: "Check In",
2141
2309
  snapshotEntryImage: "Entry Image",
2142
2310
  checkOut: "Check Out",
@@ -3143,6 +3311,100 @@ watchEffect(async () => {
3143
3311
  word-break: break-word;
3144
3312
  }
3145
3313
 
3314
+ /* A row whose value is a set of records rather than a value - it drops the
3315
+ two-column grid and runs the full width of the card body. */
3316
+ .visitor-preview__row--block {
3317
+ display: block;
3318
+ }
3319
+
3320
+ .visitor-preview__members {
3321
+ display: flex;
3322
+ flex-direction: column;
3323
+ gap: 8px;
3324
+ margin-top: 8px;
3325
+ }
3326
+
3327
+ /*
3328
+ * An added member reads as an inset on the card it sits in - `--hover` on
3329
+ * `--border` at the inner radius. Those are `Card/MemberInfoSummary`'s three
3330
+ * decisions, unchanged, so a member looks the same in the preview as it does
3331
+ * in the form that created it. Only the TYPE differs: that card still prints
3332
+ * `<strong>Label:</strong> value`, which is the pattern this dialog just moved
3333
+ * off, so the lines below are set on the scale rather than inherited.
3334
+ */
3335
+ .visitor-preview__member {
3336
+ padding: 9px 12px;
3337
+ border: 1px solid var(--border);
3338
+ border-radius: var(--r-inner);
3339
+ background: var(--hover);
3340
+ }
3341
+
3342
+ .visitor-preview__member-line {
3343
+ display: flex;
3344
+ gap: 8px;
3345
+ margin-top: 3px;
3346
+ font-size: var(--fs-breadcrumb);
3347
+ color: var(--text2);
3348
+ word-break: break-word;
3349
+ }
3350
+
3351
+ .visitor-preview__member-key {
3352
+ flex: 0 0 52px;
3353
+ color: var(--muted);
3354
+ }
3355
+
3356
+ /*
3357
+ * THE NRIC REVEAL.
3358
+ *
3359
+ * The pressable thing is the masked number, not the glyph beside it: an eye on
3360
+ * its own is a 14-16px target where 24px is the floor, and on a 12.5px member
3361
+ * line an `AppButton variant="icon"` - the layer's 32px icon square - would
3362
+ * make the NRIC line half again the height of the Contact line under it.
3363
+ * Pressing the digits gives a target the width of the value at no cost to the
3364
+ * rhythm, and reads the right way round: the number is what changes.
3365
+ *
3366
+ * It inherits its type rather than setting any, so the visitor's NRIC stays on
3367
+ * `--fs-cell` and a member's on the `--fs-breadcrumb` of the line it sits in.
3368
+ * The negative margin pays back the padding, so a revealed value starts on the
3369
+ * same left edge as every other value in the card.
3370
+ */
3371
+ .visitor-preview__reveal {
3372
+ display: inline-flex;
3373
+ align-items: center;
3374
+ gap: 6px;
3375
+ margin: -3px -7px;
3376
+ padding: 3px 7px;
3377
+ border: none;
3378
+ border-radius: var(--r-inner-sm);
3379
+ background: transparent;
3380
+ font: inherit;
3381
+ color: inherit;
3382
+ text-align: left;
3383
+ cursor: pointer;
3384
+ transition: background var(--motion) ease;
3385
+ }
3386
+
3387
+ .visitor-preview__reveal:hover {
3388
+ background: var(--hover);
3389
+ }
3390
+
3391
+ .visitor-preview__reveal:focus-visible {
3392
+ outline: 2px solid var(--accent);
3393
+ outline-offset: 1px;
3394
+ }
3395
+
3396
+ /* The design's interactive ink - not Vuetify's fixed blue, which is one colour
3397
+ on both themes. `affectedEntities` gives its NRIC eye the same value. */
3398
+ .visitor-preview__eye {
3399
+ color: var(--accent-text);
3400
+ }
3401
+
3402
+ @media (prefers-reduced-motion: reduce) {
3403
+ .visitor-preview__reveal {
3404
+ transition: none;
3405
+ }
3406
+ }
3407
+
3146
3408
  /* The width the type filter carried as `max-width="200"` on the v-select. */
3147
3409
  .visitor-type-filter {
3148
3410
  max-width: 200px;