@7365admin1/layer-common 4.119.0 → 4.121.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 (47) hide show
  1. package/CHANGELOG.md +28 -4
  2. package/components/AccessCardDetailsDialog.vue +3 -2
  3. package/components/AddPassKeyToVisitor.vue +5 -0
  4. package/components/BuildingUnitFormEdit.vue +3 -1
  5. package/components/DataRetentionBanner.vue +73 -0
  6. package/components/DocumentManagement.vue +56 -3
  7. package/components/FeedbackHandlingSettings.vue +385 -0
  8. package/components/HandoverChecklistRecords.vue +223 -0
  9. package/components/HandoverChecklistSetup.vue +224 -0
  10. package/components/IncidentReportEmailsSettings.vue +88 -0
  11. package/components/KeyPressFilter.vue +43 -0
  12. package/components/KeyPressName.vue +14 -0
  13. package/components/KeyPressSelect.vue +31 -0
  14. package/components/KeySettingsPanel.vue +156 -0
  15. package/components/ListCsvExport.vue +71 -0
  16. package/components/MaStaffRegistrationSettings.vue +61 -0
  17. package/components/MemberInformation.vue +2 -2
  18. package/components/MemberSiteAccess.vue +51 -2
  19. package/components/PassInformation.vue +5 -0
  20. package/components/RolePermissionFormCreate.vue +33 -0
  21. package/components/SearchVehicleNumberUser.vue +3 -0
  22. package/components/SiteDataRetention.vue +153 -0
  23. package/components/SiteSettings.vue +48 -0
  24. package/components/SiteSettingsVisitorRegistration.vue +131 -0
  25. package/components/VehicleForm.vue +2 -2
  26. package/components/VisitorForm.vue +45 -6
  27. package/components/VisitorManagement.vue +3 -1
  28. package/components/VisitorSearchResultSummary.vue +2 -1
  29. package/composables/useDataRetention.ts +49 -0
  30. package/composables/useDocument.ts +13 -0
  31. package/composables/useFeedback.ts +12 -0
  32. package/composables/useHandoverChecklist.ts +111 -0
  33. package/composables/useKeyPresses.ts +57 -0
  34. package/composables/useMember.ts +6 -0
  35. package/composables/usePassKey.ts +4 -0
  36. package/composables/useSiteFeatureSettings.ts +37 -0
  37. package/composables/useVisitor.ts +25 -0
  38. package/constants/permissions.ts +12 -0
  39. package/package.json +2 -2
  40. package/types/key-log.d.ts +2 -0
  41. package/types/passKey.d.ts +2 -0
  42. package/utils/feedback-handling-settings.ts +139 -0
  43. package/utils/inventory-presets.ts +108 -2
  44. package/utils/key-presses.ts +55 -0
  45. package/utils/list-export.ts +39 -0
  46. package/utils/notification-target.ts +10 -0
  47. package/utils/site-access.ts +13 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.121.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 19485b6: Data retention is managed by Seven365 Admin: `SiteDataRetention` becomes a read-only summary (rules, dates, removal certificates still download), and the new `DataRetentionBanner` shows staff of the selected site a scheduled permanent deletion (`useDataRetention().getDataRetentionBanner`).
8
+ - 495ae9c: Site Settings > Visitor registration: the NRIC / FIN setting is now a choice of "Ask and keep in full (as usual)", "Ask, but keep only the last 4 characters" or "Don't ask", with help text for each. On a last-4 site the visitor form tells staff that only the last 4 characters will be kept. Needs @7365admin1/core with visitorFields.nric = "last4".
9
+ - 42cdb65: The Security Ops permissions are registered in the canonical vocabulary, so a misspelling is caught rather than silently false. The role editor offers two one-click sets, Security Ops officer and Head of security, and a Security Ops notification now opens the Ops Review page instead of nothing.
10
+ - f663200: Site access has a switch marking a person as part of Security Ops: the officers who go out, walk a site and fill in the visit checklists. It applies to every site ticked in the dialog, shows a chip on each one, and reads the result back in a sentence. It grants nothing on its own, so the Role column still decides what a member may do.
11
+
12
+ ## 4.120.0
13
+
14
+ ### Minor Changes
15
+
16
+ - bccdbb9: Data retention (PDPA) per site: `SiteDataRetention` settings card (switch with confirmation, keep-for months per record type, notice days, preview, removal records with certificate download), `SiteDataRemovalDialog` for the staff contract-end removal, and `useDataRetention`.
17
+ - bccdbb9: Site Settings "Feedback handling" card (`FeedbackHandlingSettings`): office hours, public holidays, who receives new feedback per category, escalation steps and auto-close categories, saved as `site.metadata.feedbackSettings`. `useFeedback().sendToProvider` for "Send to another provider".
18
+ - bccdbb9: Passes & keys site settings (key presses, daily unreturned-key list, stop check-out while a key is out), `KeyPressSelect` and `useKeyPresses`, and the key press shown under each key in the visitor key pickers. Nothing changes for a site until it adds a key press or turns a switch on.
19
+ - bccdbb9: Room handover / takeover checklists (setup with ready-made starters, records with PDF) and an optional "Managing agent staff" contractor type behind a site switch.
20
+ - bccdbb9: Site Settings: Incident report emails. Documents: Archived tab with archive/restore. Create role: Guardhouse officer preset. New ListCsvExport component for list CSV downloads.
21
+ - bccdbb9: Site Settings gains a "Visitor registration" panel (Ask for NRIC / FIN, Contact number, Vehicle number), and the visitor form follows it. Sites that never saved it keep today's form.
22
+
23
+ ### Patch Changes
24
+
25
+ - d4ae8bb: Show NRIC as last 4 characters only (e.g. **\***567A) in the remaining places that printed it in full: visitor autofill search result and pick dialog, the non-checked-out / Skip NRIC prompt, member autofill, vehicle owner list, unit vehicles table, vehicle-number user picker and access card details. Display only — the value typed into or saved by the forms is unchanged.
26
+
3
27
  ## 4.119.0
4
28
 
5
29
  ### Minor Changes
@@ -3194,11 +3218,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3194
3218
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3195
3219
  own rule from two endpoints the console already calls, unprojected:
3196
3220
 
3197
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3198
- GET /api/roles/id/:role that membership's role document
3221
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3222
+ GET /api/roles/id/:role that membership's role document
3199
3223
 
3200
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3201
- staff = member.type === "admin" && role.type === "admin"
3224
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3225
+ staff = member.type === "admin" && role.type === "admin"
3202
3226
 
3203
3227
  `role.default` is the marker because it is the only property of a platform
3204
3228
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -79,7 +79,7 @@
79
79
 
80
80
  <div v-if="details.userCred.nric">
81
81
  <div class="card-details__label">NRIC</div>
82
- <div class="card-details__value">{{ details.userCred.nric }}</div>
82
+ <div class="card-details__value">{{ maskNRIC(details.userCred.nric) }}</div>
83
83
  </div>
84
84
 
85
85
  <div v-if="details.userCred.contact">
@@ -162,7 +162,7 @@
162
162
  <template v-if="entry.type === 'assigned' || entry.visitorId">
163
163
  <div class="card-details__value">{{ entry.name ?? "N/A" }}</div>
164
164
  <div class="card-details__sub">
165
- <span v-if="entry.nric">NRIC: {{ entry.nric }}</span>
165
+ <span v-if="entry.nric">NRIC: {{ maskNRIC(entry.nric) }}</span>
166
166
  <span v-if="entry.contact" class="ml-2">· {{ entry.contact }}</span>
167
167
  <span v-if="entry.email && !entry.nric && !entry.contact">{{ entry.email }}</span>
168
168
  </div>
@@ -193,6 +193,7 @@
193
193
  </template>
194
194
 
195
195
  <script setup lang="ts">
196
+ const { maskNRIC } = useUtils();
196
197
  const props = defineProps({
197
198
  modelValue: {
198
199
  type: Boolean,
@@ -102,6 +102,7 @@
102
102
  :items="keyItems"
103
103
  item-title="prefixAndName"
104
104
  item-value="_id"
105
+ :item-props="keyItemProps"
105
106
  :label="existingKeys.length > 0 ? 'Add more keys' : undefined"
106
107
  multiple
107
108
  variant="outlined"
@@ -147,6 +148,7 @@
147
148
  import type { PropType } from "vue";
148
149
  import usePassKey from "../composables/usePassKey";
149
150
  import useVisitor from "../composables/useVisitor";
151
+ import useKeyPresses from "../composables/useKeyPresses";
150
152
 
151
153
  const prop = defineProps({
152
154
  visitor: {
@@ -195,6 +197,9 @@ const existingKeys = ref<
195
197
 
196
198
  const isEditMode = computed(() => prop.mode === "edit");
197
199
  const showKeys = computed(() => prop.visitor.type === "contractor");
200
+ // The key's press (if the site has any) under each key in the picker. Read
201
+ // only when the key picker is shown.
202
+ const { keyItemProps } = useKeyPresses(() => (showKeys.value ? prop.site : ""));
198
203
 
199
204
  const passTypesComputed = computed(() => {
200
205
  const type = prop.visitor.type;
@@ -163,6 +163,8 @@
163
163
  <TableMain :headers="vehicleHeaders" :show-header="true" :page="vehiclePage" :pages="vehicleTotalPages"
164
164
  :pageRange="vehiclePageRange" :items="unitVehicleItems" @refresh="getUnitVehiclesRefresh" @update:page="handleUpdateVehiclePage">
165
165
 
166
+ <template #item.nric="{ value }">{{ maskNRIC(value) || "-" }}</template>
167
+
166
168
  <template #item.status="{value}">
167
169
  <v-chip :color="formatVehicleStatus(value).color" class="ma-0" small>
168
170
  {{ formatVehicleStatus(value).label }}
@@ -404,7 +406,7 @@ const validForm = ref(false);
404
406
 
405
407
  const { getAll } = useBuilding();
406
408
  const { getPeopleByUnit, updateById: updatePeopleById } = usePeople();
407
- const { debounce } = useUtils();
409
+ const { debounce, maskNRIC } = useUtils();
408
410
  const { searchMultipleVehiclesByUnitId, formatVehicleStatus } = useVehicle();
409
411
 
410
412
  const loading = reactive({
@@ -0,0 +1,73 @@
1
+ <template>
2
+ <!--
3
+ Scheduled data removal at the selected site (owner 2026-10-14). Shown to
4
+ the people who work the site, never residents (the server answers nothing
5
+ to them), and only for a removal that will really happen: Seven365 Admin
6
+ allowed deletion at this site and the site's admins were already told.
7
+ A site with no data retention never shows it - the request answers
8
+ `{ banner: null }`.
9
+ -->
10
+ <v-alert
11
+ v-if="banner"
12
+ type="warning"
13
+ variant="tonal"
14
+ density="comfortable"
15
+ class="drb mb-3"
16
+ icon="mdi-delete-clock-outline"
17
+ data-test="drb"
18
+ >
19
+ <div class="drb__text">{{ banner.message }}</div>
20
+ <button type="button" class="drb__toggle" :aria-expanded="open" data-test="drb-details" @click="open = !open">
21
+ {{ open ? "Hide details" : "Details" }}
22
+ </button>
23
+ <div v-if="open" class="drb__details" data-test="drb-details-body">
24
+ <ul class="drb__list">
25
+ <li v-for="i in banner.items" :key="i.dataType">
26
+ {{ i.label }}: {{ Number(i.count).toLocaleString("en-SG") }} created before {{ day(i.cutoff) }}
27
+ </li>
28
+ </ul>
29
+ <div>
30
+ Only finished records are deleted (closed feedback and work orders, visitors who have checked out, decided
31
+ incident reports, past days' occurrence books). Use each list's export to keep a copy. To postpone this, contact
32
+ Seven365.
33
+ </div>
34
+ </div>
35
+ </v-alert>
36
+ </template>
37
+
38
+ <script lang="ts" setup>
39
+ const props = defineProps({
40
+ /** The selected site. Anything that is not a site id shows nothing. */
41
+ siteId: { type: String, default: "" },
42
+ });
43
+
44
+ const { getDataRetentionBanner } = useDataRetention();
45
+ const banner = ref<Record<string, any> | null>(null);
46
+ const open = ref(false);
47
+
48
+ const day = (d: string) =>
49
+ new Date(d).toLocaleDateString("en-SG", { day: "numeric", month: "short", year: "numeric", timeZone: "Asia/Singapore" });
50
+
51
+ async function load(site: string) {
52
+ banner.value = null;
53
+ open.value = false;
54
+ if (!/^[a-f0-9]{24}$/i.test(site)) return;
55
+ try {
56
+ const res = await getDataRetentionBanner(site);
57
+ // A late answer for a site the user already left is dropped.
58
+ if (props.siteId === site) banner.value = res?.banner ?? null;
59
+ } catch {
60
+ // Not reachable, a resident, or an older server: no banner.
61
+ banner.value = null;
62
+ }
63
+ }
64
+
65
+ watch(() => props.siteId, (s) => load(s), { immediate: true });
66
+ </script>
67
+
68
+ <style scoped>
69
+ .drb__text { word-break: break-word; }
70
+ .drb__toggle { text-decoration: underline; font-weight: 600; margin-top: 4px; background: none; border: 0; padding: 0; color: inherit; cursor: pointer; }
71
+ .drb__details { margin-top: 8px; font-size: 13px; }
72
+ .drb__list { padding-left: 18px; margin-bottom: 6px; }
73
+ </style>
@@ -66,7 +66,12 @@
66
66
  >
67
67
  <v-tab value="active" class="text-none">Documents</v-tab>
68
68
  <v-tab value="expired" class="text-none">Document (Expired)</v-tab>
69
+ <v-tab value="archived" class="text-none" data-test="doc-tab-archived">Archived</v-tab>
69
70
  </v-tabs>
71
+ <p v-if="isArchivedTab" class="text-caption text-medium-emphasis mt-2 mb-0">
72
+ Archived documents are kept but hidden from the main list. Archiving a folder archives everything in it, and
73
+ restoring the folder brings it all back.
74
+ </p>
70
75
  </v-col>
71
76
 
72
77
  <!--
@@ -74,7 +79,7 @@
74
79
  place in the folder tree, so a breadcrumb there would name a folder whose
75
80
  contents are not what is on screen.
76
81
  -->
77
- <v-col cols="12" class="mb-2" v-if="isInsideFolder && !isExpiredTab">
82
+ <v-col cols="12" class="mb-2" v-if="isInsideFolder && !isExpiredTab && !isArchivedTab">
78
83
  <div class="folder-path-bar d-flex align-center ga-2 px-3 py-2">
79
84
  <v-btn
80
85
  icon="mdi-arrow-left"
@@ -227,6 +232,15 @@
227
232
  >
228
233
  <v-list-item-title class="text-body-2">Edit</v-list-item-title>
229
234
  </v-list-item>
235
+ <v-list-item
236
+ v-if="canUpdateDocument"
237
+ class="folder-action-item"
238
+ @click.stop="archiveItem(item._raw, !isArchivedTab)"
239
+ >
240
+ <v-list-item-title class="text-body-2">
241
+ {{ isArchivedTab ? "Restore" : "Archive" }}
242
+ </v-list-item-title>
243
+ </v-list-item>
230
244
  <v-list-item
231
245
  v-if="canDeleteDocument"
232
246
  class="folder-action-item"
@@ -265,6 +279,15 @@
265
279
  >
266
280
  <v-list-item-title class="text-body-2">Edit</v-list-item-title>
267
281
  </v-list-item>
282
+ <v-list-item
283
+ v-if="canUpdateDocument"
284
+ class="folder-action-item"
285
+ @click.stop="archiveItem(item._raw, !isArchivedTab)"
286
+ >
287
+ <v-list-item-title class="text-body-2">
288
+ {{ isArchivedTab ? "Restore" : "Archive" }}
289
+ </v-list-item-title>
290
+ </v-list-item>
268
291
  <v-list-item
269
292
  class="folder-action-item"
270
293
  @click.stop="openDeleteFolderDialog(item._raw)"
@@ -776,6 +799,7 @@ const documentService = useDocument();
776
799
  const {
777
800
  getAll: _getAllDocuments,
778
801
  deleteById,
802
+ setArchived,
779
803
  deleteFolderById,
780
804
  updateFolderById,
781
805
  createFolder,
@@ -841,8 +865,10 @@ const selectedFileType = ref("all");
841
865
  * Which half of the list is on screen. The value is sent straight to the API as
842
866
  * its `expiry` filter, so the tab and the query cannot drift apart.
843
867
  */
844
- const activeTab = ref<"active" | "expired">("active");
868
+ const activeTab = ref<"active" | "expired" | "archived">("active");
845
869
  const isExpiredTab = computed(() => activeTab.value === "expired");
870
+ // Archived: every archived item at the site, wherever it was filed (like Expired).
871
+ const isArchivedTab = computed(() => activeTab.value === "archived");
846
872
 
847
873
  const {
848
874
  data: getDocumentReq,
@@ -859,7 +885,8 @@ const {
859
885
  parentId: activeParentId.value,
860
886
  type:
861
887
  selectedFileType.value === "all" ? undefined : selectedFileType.value,
862
- expiry: activeTab.value,
888
+ expiry: isArchivedTab.value ? "all" : activeTab.value,
889
+ archived: isArchivedTab.value,
863
890
  }),
864
891
  {
865
892
  watch: [page, headerSearch, activeParentId, selectedFileType, activeTab],
@@ -1043,6 +1070,8 @@ function tableRowClickHandler(data: any) {
1043
1070
  const rowData = (data?._raw || data) as TDocument;
1044
1071
 
1045
1072
  if (isFolderItem(rowData)) {
1073
+ // An archived folder is restored, not browsed: the Archived tab is a flat list.
1074
+ if (isArchivedTab.value) return;
1046
1075
  openFolder(rowData);
1047
1076
  return;
1048
1077
  }
@@ -1142,6 +1171,30 @@ function setDocument({
1142
1171
  }
1143
1172
  }
1144
1173
 
1174
+ /** Archive / restore one row (document or folder). Same right as editing it. */
1175
+ async function archiveItem(item: TDocument, archive: boolean) {
1176
+ const id = String((item as any)?._id || "");
1177
+ if (!id) return;
1178
+ documentActionMenu.value[id] = false;
1179
+ folderActionMenu.value[id] = false;
1180
+ try {
1181
+ await setArchived(id, archive);
1182
+ showMessage(
1183
+ archive
1184
+ ? "Archived. Find it under the Archived tab."
1185
+ : "Restored to the main list.",
1186
+ "success"
1187
+ );
1188
+ await getDocuments();
1189
+ } catch (error: any) {
1190
+ showMessage(
1191
+ error?.data?.message ||
1192
+ (archive ? "Could not archive it." : "Could not restore it."),
1193
+ "error"
1194
+ );
1195
+ }
1196
+ }
1197
+
1145
1198
  function showMessage(msg: string, color: string) {
1146
1199
  message.value = msg;
1147
1200
  messageColor.value = color;