@7365admin1/layer-common 4.119.0 → 4.120.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 (41) hide show
  1. package/CHANGELOG.md +19 -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/DocumentManagement.vue +56 -3
  6. package/components/FeedbackHandlingSettings.vue +385 -0
  7. package/components/HandoverChecklistRecords.vue +223 -0
  8. package/components/HandoverChecklistSetup.vue +224 -0
  9. package/components/IncidentReportEmailsSettings.vue +88 -0
  10. package/components/KeyPressFilter.vue +43 -0
  11. package/components/KeyPressName.vue +14 -0
  12. package/components/KeyPressSelect.vue +31 -0
  13. package/components/KeySettingsPanel.vue +156 -0
  14. package/components/ListCsvExport.vue +71 -0
  15. package/components/MaStaffRegistrationSettings.vue +61 -0
  16. package/components/MemberInformation.vue +2 -2
  17. package/components/PassInformation.vue +5 -0
  18. package/components/RolePermissionFormCreate.vue +33 -0
  19. package/components/SearchVehicleNumberUser.vue +3 -0
  20. package/components/SiteDataRetention.vue +286 -0
  21. package/components/SiteSettings.vue +48 -0
  22. package/components/SiteSettingsVisitorRegistration.vue +118 -0
  23. package/components/VehicleForm.vue +2 -2
  24. package/components/VisitorForm.vue +41 -6
  25. package/components/VisitorManagement.vue +3 -1
  26. package/components/VisitorSearchResultSummary.vue +2 -1
  27. package/composables/useDataRetention.ts +43 -0
  28. package/composables/useDocument.ts +13 -0
  29. package/composables/useFeedback.ts +12 -0
  30. package/composables/useHandoverChecklist.ts +111 -0
  31. package/composables/useKeyPresses.ts +57 -0
  32. package/composables/usePassKey.ts +4 -0
  33. package/composables/useSiteFeatureSettings.ts +37 -0
  34. package/composables/useVisitor.ts +22 -0
  35. package/package.json +2 -2
  36. package/types/key-log.d.ts +2 -0
  37. package/types/passKey.d.ts +2 -0
  38. package/utils/feedback-handling-settings.ts +139 -0
  39. package/utils/inventory-presets.ts +64 -0
  40. package/utils/key-presses.ts +55 -0
  41. package/utils/list-export.ts +39 -0
@@ -0,0 +1,156 @@
1
+ <template>
2
+ <!--
3
+ Passes & keys site switches (TLR B8 / D2). Saved as one value,
4
+ `metadata.keySettings`, through the shared one-switch save
5
+ (`useSiteFeatureSettings`).
6
+ Every switch starts off, so a site that never opens this behaves as before.
7
+ -->
8
+ <div class="key-settings" data-test="key-settings-panel">
9
+ <InputLabel title="Key presses" class="d-block mb-1" />
10
+ <p class="text-caption text-medium-emphasis mb-2">
11
+ Add one per key press, e.g. Management office and Guardhouse, so an unreturned key shows which press it came
12
+ from.
13
+ </p>
14
+ <div v-for="(row, i) in rows" :key="row.id || `new-${i}`" class="d-flex align-center ga-2 mb-2">
15
+ <v-text-field
16
+ v-model="row.name"
17
+ placeholder="Key press name"
18
+ density="compact"
19
+ hide-details
20
+ maxlength="60"
21
+ :readonly="readOnly"
22
+ :data-test="`key-press-name-${i}`"
23
+ />
24
+ <v-btn
25
+ v-if="!readOnly"
26
+ icon="mdi-delete-outline"
27
+ variant="text"
28
+ density="comfortable"
29
+ :aria-label="`Remove ${row.name || 'key press'}`"
30
+ @click="rows.splice(i, 1)"
31
+ />
32
+ </div>
33
+ <v-btn
34
+ v-if="!readOnly && rows.length < 20"
35
+ variant="text"
36
+ color="primary"
37
+ prepend-icon="mdi-plus"
38
+ class="mb-4"
39
+ data-test="key-press-add"
40
+ @click="rows.push({ name: '' })"
41
+ >
42
+ Add key press
43
+ </v-btn>
44
+
45
+ <v-divider class="my-3" />
46
+
47
+ <v-switch
48
+ v-model="dailyUnreturnedAlert"
49
+ label="Daily list of unreturned keys"
50
+ color="primary"
51
+ hide-details
52
+ :disabled="readOnly"
53
+ data-test="key-daily-alert-switch"
54
+ />
55
+ <p class="text-caption text-medium-emphasis mb-2">
56
+ Once a day, people who look after Passes &amp; Keys get a list of keys still out, by key press.
57
+ </p>
58
+ <v-text-field
59
+ v-if="dailyUnreturnedAlert"
60
+ v-model="alertTime"
61
+ type="time"
62
+ label="Send at (site time)"
63
+ density="compact"
64
+ hide-details
65
+ style="max-width: 200px"
66
+ :readonly="readOnly"
67
+ data-test="key-daily-alert-time"
68
+ />
69
+
70
+ <v-switch
71
+ v-model="blockCheckoutWithKeys"
72
+ label="Stop check-out while a key is still out"
73
+ color="primary"
74
+ hide-details
75
+ class="mt-3"
76
+ :disabled="readOnly"
77
+ data-test="key-block-checkout-switch"
78
+ />
79
+ <p class="text-caption text-medium-emphasis mb-4">
80
+ The guard cannot check a visitor out until their key is returned or marked lost/damaged.
81
+ </p>
82
+
83
+ <v-btn
84
+ v-if="!readOnly"
85
+ color="primary"
86
+ :loading="saving"
87
+ :disabled="!siteData || !alertTimeValid"
88
+ data-test="key-settings-save"
89
+ @click="save"
90
+ >
91
+ Save
92
+ </v-btn>
93
+ </div>
94
+ </template>
95
+
96
+ <script setup lang="ts">
97
+ import useSiteFeatureSettings from "../composables/useSiteFeatureSettings";
98
+ import useKeyPresses from "../composables/useKeyPresses";
99
+ import { cleanKeyPresses, readKeySettings } from "../utils/key-presses";
100
+
101
+ const props = defineProps({
102
+ site: { type: String, required: true },
103
+ siteData: { type: Object, default: null },
104
+ readOnly: { type: Boolean, default: false },
105
+ });
106
+ const emit = defineEmits<{
107
+ (e: "saved"): void;
108
+ (e: "message", text: string, color: string): void;
109
+ }>();
110
+
111
+ const { saveSiteFeature } = useSiteFeatureSettings();
112
+ // Not `useKeyPresses(site)`: that would read the site again on open.
113
+ const { forget } = useKeyPresses(() => "");
114
+
115
+ const rows = ref<Array<{ id?: string; name: string }>>([]);
116
+ const dailyUnreturnedAlert = ref(false);
117
+ const alertTime = ref("18:00");
118
+ const blockCheckoutWithKeys = ref(false);
119
+ const saving = ref(false);
120
+
121
+ watch(
122
+ () => props.siteData,
123
+ (site) => {
124
+ const s = readKeySettings(site);
125
+ rows.value = s.cabinets.map((c) => ({ ...c }));
126
+ dailyUnreturnedAlert.value = s.dailyUnreturnedAlert;
127
+ alertTime.value = s.alertTime;
128
+ blockCheckoutWithKeys.value = s.blockCheckoutWithKeys;
129
+ },
130
+ { immediate: true },
131
+ );
132
+
133
+ const alertTimeValid = computed(() => /^([01]\d|2[0-3]):[0-5]\d$/.test(alertTime.value || ""));
134
+
135
+ async function save() {
136
+ if (!props.siteData) return;
137
+ saving.value = true;
138
+ try {
139
+ const cabinets = cleanKeyPresses(rows.value);
140
+ await saveSiteFeature(props.site, "keySettings", {
141
+ cabinets,
142
+ dailyUnreturnedAlert: dailyUnreturnedAlert.value,
143
+ alertTime: alertTime.value,
144
+ blockCheckoutWithKeys: blockCheckoutWithKeys.value,
145
+ });
146
+ rows.value = cabinets.map((c) => ({ ...c }));
147
+ forget(props.site);
148
+ emit("message", "Passes & keys settings saved", "success");
149
+ emit("saved");
150
+ } catch (e: any) {
151
+ emit("message", e?.data?.message || "Could not save Passes & keys settings", "error");
152
+ } finally {
153
+ saving.value = false;
154
+ }
155
+ }
156
+ </script>
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <!--
3
+ "Download CSV" for a list screen: the same list API and filters the screen
4
+ uses, every page, as one CSV file. Reads only what the list already shows.
5
+ -->
6
+ <v-btn
7
+ variant="text"
8
+ size="small"
9
+ class="text-none"
10
+ prepend-icon="mdi-download"
11
+ :loading="busy"
12
+ data-test="list-csv-export"
13
+ @click="download"
14
+ >
15
+ {{ label }}
16
+ </v-btn>
17
+ <Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import { collectAllPages, EXPORT_MAX_ROWS, listCsv, type TExportColumn } from "../utils/list-export";
22
+
23
+ const props = defineProps({
24
+ /** `(page, limit) => list API result` with the screen's current filters. */
25
+ fetchPage: {
26
+ type: Function as PropType<(page: number, limit: number) => Promise<any>>,
27
+ required: true,
28
+ },
29
+ columns: { type: Array as PropType<TExportColumn[]>, required: true },
30
+ /** File name without the date or extension, e.g. "feedback". */
31
+ fileName: { type: String, required: true },
32
+ label: { type: String, default: "Download CSV" },
33
+ });
34
+
35
+ const busy = ref(false);
36
+ const toast = reactive({ show: false, message: "", color: "" });
37
+
38
+ async function download() {
39
+ busy.value = true;
40
+ try {
41
+ const { rows, truncated } = await collectAllPages(props.fetchPage);
42
+ if (!rows.length) {
43
+ Object.assign(toast, { show: true, message: "Nothing to download for these filters.", color: "info" });
44
+ return;
45
+ }
46
+ const blob = new Blob([listCsv(props.columns, rows)], { type: "text/csv;charset=utf-8" });
47
+ const link = document.createElement("a");
48
+ link.href = URL.createObjectURL(blob);
49
+ link.download = `${props.fileName}-${new Date().toISOString().slice(0, 10)}.csv`;
50
+ link.click();
51
+ // Revoked a moment later: some browsers start the download after click() returns.
52
+ const href = link.href;
53
+ setTimeout(() => URL.revokeObjectURL(href), 1000);
54
+ if (truncated) {
55
+ Object.assign(toast, {
56
+ show: true,
57
+ message: `Only the first ${EXPORT_MAX_ROWS} rows were downloaded. Narrow the dates to get the rest.`,
58
+ color: "warning",
59
+ });
60
+ }
61
+ } catch (error: any) {
62
+ Object.assign(toast, {
63
+ show: true,
64
+ message: error?.data?.message || "Could not download the list.",
65
+ color: "error",
66
+ });
67
+ } finally {
68
+ busy.value = false;
69
+ }
70
+ }
71
+ </script>
@@ -0,0 +1,61 @@
1
+ <template>
2
+ <div data-test="ma-staff-registration">
3
+ <v-switch v-model="enabled" :disabled="readOnly" label="Offer &quot;Managing agent staff&quot; at the guardhouse"
4
+ color="primary" hide-details />
5
+ <p class="text-caption text-medium-emphasis mt-1 mb-3">
6
+ The guardhouse can register your staff drawing keys as a contractor of type "Managing agent staff", with your company
7
+ name filled in.
8
+ </p>
9
+ <v-text-field v-if="enabled" v-model.trim="company" :readonly="readOnly" label="Company name to fill in"
10
+ placeholder="e.g. JLL" density="comfortable" variant="outlined" maxlength="120" />
11
+ <v-btn v-if="!readOnly" class="screen-btn-primary" color="primary-button" variant="flat" text="Save"
12
+ :loading="saving" :disabled="!loaded" @click="save" />
13
+ <Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
14
+ </div>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import useSiteFeatureSettings from "../composables/useSiteFeatureSettings";
19
+
20
+ /**
21
+ * Site setting `metadata.maStaffRegistration` (core site-feature-settings).
22
+ * Off = today: the contractor type list is unchanged.
23
+ */
24
+ const props = defineProps({
25
+ siteId: { type: String, required: true },
26
+ saved: { type: Object as PropType<{ enabled?: boolean; company?: string } | null>, default: null },
27
+ readOnly: { type: Boolean, default: false },
28
+ /** The site has been read; Save stays off until then. */
29
+ loaded: { type: Boolean, default: false },
30
+ });
31
+ const emit = defineEmits(["saved"]);
32
+
33
+ const enabled = ref(false);
34
+ const company = ref("");
35
+ watch(
36
+ () => props.saved,
37
+ (v) => {
38
+ enabled.value = v?.enabled === true;
39
+ company.value = v?.company ?? "";
40
+ },
41
+ { immediate: true },
42
+ );
43
+
44
+ const { saveSiteFeature } = useSiteFeatureSettings();
45
+ const saving = ref(false);
46
+ const toast = reactive({ show: false, message: "", color: "" });
47
+
48
+ async function save() {
49
+ if (!props.loaded) return;
50
+ saving.value = true;
51
+ try {
52
+ await saveSiteFeature(props.siteId, "maStaffRegistration", { enabled: enabled.value, company: company.value });
53
+ Object.assign(toast, { show: true, message: "Saved.", color: "success" });
54
+ emit("saved");
55
+ } catch (e: any) {
56
+ Object.assign(toast, { show: true, message: e?.data?.message || "Could not save.", color: "error" });
57
+ } finally {
58
+ saving.value = false;
59
+ }
60
+ }
61
+ </script>
@@ -114,7 +114,7 @@
114
114
  <div class="nric-selected-person d-flex align-center justify-space-between ga-3 mb-5">
115
115
  <span>{{ memberSelectedSearchResult.name || "Unknown" }}</span>
116
116
  <span class="text-no-wrap">
117
- NRIC: {{ memberSelectedSearchResult.nric || "N/A" }}
117
+ NRIC: {{ maskNRIC(memberSelectedSearchResult.nric) || "N/A" }}
118
118
  </span>
119
119
  </div>
120
120
 
@@ -214,7 +214,7 @@ const props = defineProps({
214
214
  },
215
215
  });
216
216
 
217
- const { requiredRule, debounce } = useUtils();
217
+ const { requiredRule, debounce, maskNRIC } = useUtils();
218
218
  const { getPassKeysByPageSearch } = usePassKey();
219
219
  const {
220
220
  findPersonByNRICMultipleResult,
@@ -48,6 +48,7 @@
48
48
  :rules="props.passRules"
49
49
  item-title="prefixAndName"
50
50
  item-value="_id"
51
+ :item-props="keyItemProps"
51
52
  label="Keys (Optional)"
52
53
  multiple
53
54
  variant="outlined"
@@ -97,6 +98,7 @@
97
98
  import type { PropType } from "vue";
98
99
  import type { ValidationRule } from "vuetify/lib/types.mjs";
99
100
  import usePassKey from "../composables/usePassKey";
101
+ import useKeyPresses from "../composables/useKeyPresses";
100
102
  import { errorConverter } from "../utils/data";
101
103
  import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
102
104
 
@@ -125,6 +127,9 @@ const props = withDefaults(defineProps<PassInformationProps>(), {
125
127
  });
126
128
 
127
129
  const emit = defineEmits(["update:pass", "update:keys"]);
130
+ // The key's press (if the site has any) under each key in the picker. Read
131
+ // only when the key picker is shown.
132
+ const { keyItemProps } = useKeyPresses(() => (props.hideKeys ? "" : props.site));
128
133
  const pass = ref<TPassKeyPayload[]>(props.pass);
129
134
  const keys = ref<TPassKeyPayload[]>(props.keys);
130
135
  const selectedPass = ref<string>("");
@@ -79,6 +79,27 @@
79
79
  />
80
80
  </v-col>
81
81
 
82
+ <!--
83
+ Whole-role starting points (e.g. Guardhouse officer). Replaces the
84
+ ticks below; nothing is saved until Create. Only offered when this
85
+ catalogue has every permission the preset uses.
86
+ -->
87
+ <v-col v-if="rolePresets.length" cols="12" class="mt-3" data-test="role-presets">
88
+ <div class="text-caption font-weight-bold mb-1">Start from a ready-made role</div>
89
+ <div class="d-flex flex-wrap ga-2">
90
+ <v-tooltip v-for="p in rolePresets" :key="p.key" :text="p.description" location="top">
91
+ <template #activator="{ props: tip }">
92
+ <v-chip v-bind="tip" size="small" variant="outlined" class="text-none" @click="applyRoleTemplate(p)">
93
+ {{ p.label }}
94
+ </v-chip>
95
+ </template>
96
+ </v-tooltip>
97
+ </div>
98
+ <div class="text-caption text-medium-emphasis mt-1">
99
+ Replaces the ticks below. Adjust them if needed, then create the role.
100
+ </div>
101
+ </v-col>
102
+
82
103
  <v-col cols="12" class="mt-3">
83
104
  <div class="role-create__list">
84
105
  <div
@@ -262,6 +283,7 @@ import useRole from "../composables/useRole";
262
283
  import useUtils from "../composables/useUtils";
263
284
  import useSite from "../composables/useSite";
264
285
  import { readApiError } from "../utils/subscription-form";
286
+ import { applyRolePreset, ROLE_PRESETS, type TRolePreset } from "../utils/inventory-presets";
265
287
 
266
288
  /** Shown after "Couldn't save:" when the server gave no reason (network, timeout). */
267
289
  const SAVE_FALLBACK =
@@ -406,6 +428,17 @@ function toggleCategory(resource: string, value: boolean | null) {
406
428
  }
407
429
  }
408
430
 
431
+ /** Whole-role presets this catalogue can express in full. */
432
+ const rolePresets = computed(() =>
433
+ ROLE_PRESETS.filter((p) => applyRolePreset(safePermissions.value as any, p)),
434
+ );
435
+
436
+ function applyRoleTemplate(preset: TRolePreset) {
437
+ const state = applyRolePreset(safePermissions.value as any, preset);
438
+ // never an empty list: empty means "everything" on the server
439
+ if (state?.selected.length) permState.value = state;
440
+ }
441
+
409
442
  /** A module preset replaced that module's ticks (see RolePermissionPresets). */
410
443
  function applyPreset(selected: string[]) {
411
444
  permState.value = { selected, enabled: permState.value.enabled };
@@ -19,6 +19,8 @@
19
19
  </span>
20
20
  </template>
21
21
 
22
+ <template #item.nric="{ value }">{{ maskNRIC(value) || "-" }}</template>
23
+
22
24
  <template #item.actions="{ item }">
23
25
  <AppButton variant="ghost" @click="handleSelectUser(item)">Select</AppButton>
24
26
  </template>
@@ -37,6 +39,7 @@
37
39
  </template>
38
40
 
39
41
  <script setup lang="ts">
42
+ const { maskNRIC } = useUtils();
40
43
 
41
44
  const prop = defineProps({
42
45
  vehicleNumber: {