@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.
- package/CHANGELOG.md +19 -4
- package/components/AccessCardDetailsDialog.vue +3 -2
- package/components/AddPassKeyToVisitor.vue +5 -0
- package/components/BuildingUnitFormEdit.vue +3 -1
- package/components/DocumentManagement.vue +56 -3
- package/components/FeedbackHandlingSettings.vue +385 -0
- package/components/HandoverChecklistRecords.vue +223 -0
- package/components/HandoverChecklistSetup.vue +224 -0
- package/components/IncidentReportEmailsSettings.vue +88 -0
- package/components/KeyPressFilter.vue +43 -0
- package/components/KeyPressName.vue +14 -0
- package/components/KeyPressSelect.vue +31 -0
- package/components/KeySettingsPanel.vue +156 -0
- package/components/ListCsvExport.vue +71 -0
- package/components/MaStaffRegistrationSettings.vue +61 -0
- package/components/MemberInformation.vue +2 -2
- package/components/PassInformation.vue +5 -0
- package/components/RolePermissionFormCreate.vue +33 -0
- package/components/SearchVehicleNumberUser.vue +3 -0
- package/components/SiteDataRetention.vue +286 -0
- package/components/SiteSettings.vue +48 -0
- package/components/SiteSettingsVisitorRegistration.vue +118 -0
- package/components/VehicleForm.vue +2 -2
- package/components/VisitorForm.vue +41 -6
- package/components/VisitorManagement.vue +3 -1
- package/components/VisitorSearchResultSummary.vue +2 -1
- package/composables/useDataRetention.ts +43 -0
- package/composables/useDocument.ts +13 -0
- package/composables/useFeedback.ts +12 -0
- package/composables/useHandoverChecklist.ts +111 -0
- package/composables/useKeyPresses.ts +57 -0
- package/composables/usePassKey.ts +4 -0
- package/composables/useSiteFeatureSettings.ts +37 -0
- package/composables/useVisitor.ts +22 -0
- package/package.json +2 -2
- package/types/key-log.d.ts +2 -0
- package/types/passKey.d.ts +2 -0
- package/utils/feedback-handling-settings.ts +139 -0
- package/utils/inventory-presets.ts +64 -0
- package/utils/key-presses.ts +55 -0
- package/utils/list-export.ts +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.120.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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`.
|
|
8
|
+
- 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".
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- bccdbb9: Site Settings: Incident report emails. Documents: Archived tab with archive/restore. Create role: Guardhouse officer preset. New ListCsvExport component for list CSV downloads.
|
|
12
|
+
- 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.
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
3
18
|
## 4.119.0
|
|
4
19
|
|
|
5
20
|
### Minor Changes
|
|
@@ -3194,11 +3209,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3194
3209
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3195
3210
|
own rule from two endpoints the console already calls, unprojected:
|
|
3196
3211
|
|
|
3197
|
-
|
|
3198
|
-
|
|
3212
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3213
|
+
GET /api/roles/id/:role that membership's role document
|
|
3199
3214
|
|
|
3200
|
-
|
|
3201
|
-
|
|
3215
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3216
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3202
3217
|
|
|
3203
3218
|
`role.default` is the marker because it is the only property of a platform
|
|
3204
3219
|
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({
|
|
@@ -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;
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
FEEDBACK HANDLING - per-site office hours, who receives new feedback, the
|
|
3
|
+
escalation steps and the categories that close themselves (TLR, 2026-10-13).
|
|
4
|
+
|
|
5
|
+
Saved as the one switch `site.metadata.feedbackSettings`
|
|
6
|
+
(`useSiteFeatureSettings`). Only the site's owning organisation (or Seven365)
|
|
7
|
+
may change it - the server checks. Until a site saves this card nothing
|
|
8
|
+
changes for it: no routes, no escalation, no auto-close (see
|
|
9
|
+
`utils/feedback-handling-settings.ts`).
|
|
10
|
+
-->
|
|
11
|
+
<template>
|
|
12
|
+
<div class="feedback-handling">
|
|
13
|
+
<v-skeleton-loader v-if="loading" type="article" />
|
|
14
|
+
|
|
15
|
+
<template v-else>
|
|
16
|
+
<div v-if="!canEdit" class="screen-hint fh-note mb-4" data-test="fh-owner-only">
|
|
17
|
+
Only the client that owns this site can change feedback handling.
|
|
18
|
+
</div>
|
|
19
|
+
<!-- Office hours -->
|
|
20
|
+
<section class="mb-6">
|
|
21
|
+
<div class="fh-label">Office hours</div>
|
|
22
|
+
<div class="screen-hint fh-note mt-1 mb-3">
|
|
23
|
+
Used to decide who receives new feedback and, if you choose, how escalation time is counted.
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<v-chip-group v-model="form.workingDays" multiple column selected-class="text-primary" :disabled="!canEdit">
|
|
27
|
+
<v-chip v-for="day in WEEK_DAYS" :key="day.value" :value="day.value" filter variant="outlined" size="small">
|
|
28
|
+
{{ day.title }}
|
|
29
|
+
</v-chip>
|
|
30
|
+
</v-chip-group>
|
|
31
|
+
|
|
32
|
+
<v-row dense class="mt-2">
|
|
33
|
+
<v-col cols="6" sm="3">
|
|
34
|
+
<v-text-field v-model="form.officeStart" type="time" label="Opens" density="comfortable" hide-details :disabled="!canEdit" />
|
|
35
|
+
</v-col>
|
|
36
|
+
<v-col cols="6" sm="3">
|
|
37
|
+
<v-text-field v-model="form.officeEnd" type="time" label="Closes" density="comfortable" hide-details :disabled="!canEdit" />
|
|
38
|
+
</v-col>
|
|
39
|
+
</v-row>
|
|
40
|
+
|
|
41
|
+
<v-switch
|
|
42
|
+
v-model="form.excludePublicHolidays"
|
|
43
|
+
class="app-switch mt-2"
|
|
44
|
+
hide-details
|
|
45
|
+
:disabled="!canEdit"
|
|
46
|
+
label="Treat public holidays as non-working days"
|
|
47
|
+
/>
|
|
48
|
+
<div class="screen-hint fh-note">Uses the Singapore public holiday list kept up to date by Seven365.</div>
|
|
49
|
+
</section>
|
|
50
|
+
|
|
51
|
+
<v-divider class="mb-6" />
|
|
52
|
+
|
|
53
|
+
<!-- Routing -->
|
|
54
|
+
<section class="mb-6">
|
|
55
|
+
<div class="fh-label">Who receives new feedback</div>
|
|
56
|
+
<div class="screen-hint fh-note mt-1 mb-3">
|
|
57
|
+
The managing agent always sees every feedback. Choosing the provider also sends it straight to them.
|
|
58
|
+
Categories not listed here work as they do today.
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div v-for="(route, i) in form.routes" :key="i" class="fh-row pa-3 mb-2">
|
|
62
|
+
<v-row dense align="center">
|
|
63
|
+
<v-col cols="12" md="3">
|
|
64
|
+
<v-select
|
|
65
|
+
v-model="route.category"
|
|
66
|
+
:items="categoryItems(route.category)"
|
|
67
|
+
label="Category"
|
|
68
|
+
density="comfortable"
|
|
69
|
+
hide-details
|
|
70
|
+
:disabled="!canEdit"
|
|
71
|
+
/>
|
|
72
|
+
</v-col>
|
|
73
|
+
<v-col cols="12" md="3">
|
|
74
|
+
<v-select
|
|
75
|
+
v-model="route.provider"
|
|
76
|
+
:items="providerItems"
|
|
77
|
+
label="Provider"
|
|
78
|
+
density="comfortable"
|
|
79
|
+
hide-details
|
|
80
|
+
clearable
|
|
81
|
+
:no-data-text="'No active providers at this site'"
|
|
82
|
+
:disabled="!canEdit"
|
|
83
|
+
/>
|
|
84
|
+
</v-col>
|
|
85
|
+
<v-col cols="12" sm="6" md="3">
|
|
86
|
+
<v-select
|
|
87
|
+
v-model="route.officeHours"
|
|
88
|
+
:items="targetItems"
|
|
89
|
+
label="During office hours"
|
|
90
|
+
density="comfortable"
|
|
91
|
+
hide-details
|
|
92
|
+
:disabled="!canEdit"
|
|
93
|
+
/>
|
|
94
|
+
</v-col>
|
|
95
|
+
<v-col cols="10" sm="5" md="2">
|
|
96
|
+
<v-select
|
|
97
|
+
v-model="route.afterHours"
|
|
98
|
+
:items="targetItems"
|
|
99
|
+
label="Weekends & public holidays"
|
|
100
|
+
density="comfortable"
|
|
101
|
+
hide-details
|
|
102
|
+
:disabled="!canEdit"
|
|
103
|
+
/>
|
|
104
|
+
</v-col>
|
|
105
|
+
<v-col cols="2" sm="1" class="d-flex justify-end">
|
|
106
|
+
<v-btn
|
|
107
|
+
v-if="canEdit"
|
|
108
|
+
icon="mdi-delete-outline"
|
|
109
|
+
variant="text"
|
|
110
|
+
size="small"
|
|
111
|
+
aria-label="Remove this category"
|
|
112
|
+
@click="form.routes.splice(i, 1)"
|
|
113
|
+
/>
|
|
114
|
+
</v-col>
|
|
115
|
+
</v-row>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<AppButton
|
|
119
|
+
v-if="canEdit"
|
|
120
|
+
variant="ghost"
|
|
121
|
+
icon="mdi-plus"
|
|
122
|
+
:disabled="!categoryItems('').length"
|
|
123
|
+
@click="form.routes.push({ category: '', provider: '', officeHours: 'ma', afterHours: 'ma' })"
|
|
124
|
+
>
|
|
125
|
+
Add category
|
|
126
|
+
</AppButton>
|
|
127
|
+
</section>
|
|
128
|
+
|
|
129
|
+
<v-divider class="mb-6" />
|
|
130
|
+
|
|
131
|
+
<!-- Escalation -->
|
|
132
|
+
<section class="mb-6">
|
|
133
|
+
<v-switch
|
|
134
|
+
v-model="form.escalationEnabled"
|
|
135
|
+
class="app-switch"
|
|
136
|
+
hide-details
|
|
137
|
+
:disabled="!canEdit"
|
|
138
|
+
label="Escalate unresolved feedback"
|
|
139
|
+
/>
|
|
140
|
+
<div class="screen-hint fh-note mb-3">
|
|
141
|
+
Tell more people when a feedback is still not completed after the time you set.
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<template v-if="form.escalationEnabled">
|
|
145
|
+
<div v-for="(level, i) in form.levels" :key="i" class="fh-row pa-3 mb-2">
|
|
146
|
+
<div class="fh-step mb-2">Step {{ i + 1 }}</div>
|
|
147
|
+
<v-row dense align="center">
|
|
148
|
+
<v-col cols="12" sm="4" md="3">
|
|
149
|
+
<v-text-field
|
|
150
|
+
v-model.number="level.afterHours"
|
|
151
|
+
type="number"
|
|
152
|
+
min="1"
|
|
153
|
+
max="1440"
|
|
154
|
+
label="If not completed after (hours)"
|
|
155
|
+
density="comfortable"
|
|
156
|
+
hide-details
|
|
157
|
+
:disabled="!canEdit"
|
|
158
|
+
/>
|
|
159
|
+
</v-col>
|
|
160
|
+
<v-col cols="12" sm="8" md="4">
|
|
161
|
+
<v-autocomplete
|
|
162
|
+
v-model="level.users"
|
|
163
|
+
:items="peopleItems"
|
|
164
|
+
label="Notify these people"
|
|
165
|
+
multiple
|
|
166
|
+
chips
|
|
167
|
+
closable-chips
|
|
168
|
+
density="comfortable"
|
|
169
|
+
hide-details
|
|
170
|
+
:disabled="!canEdit"
|
|
171
|
+
/>
|
|
172
|
+
</v-col>
|
|
173
|
+
<v-col cols="10" md="4">
|
|
174
|
+
<v-combobox
|
|
175
|
+
v-model="level.emails"
|
|
176
|
+
label="And these email addresses"
|
|
177
|
+
multiple
|
|
178
|
+
chips
|
|
179
|
+
closable-chips
|
|
180
|
+
density="comfortable"
|
|
181
|
+
hide-details
|
|
182
|
+
:disabled="!canEdit"
|
|
183
|
+
/>
|
|
184
|
+
</v-col>
|
|
185
|
+
<v-col cols="2" md="1" class="d-flex justify-end">
|
|
186
|
+
<v-btn
|
|
187
|
+
v-if="canEdit"
|
|
188
|
+
icon="mdi-delete-outline"
|
|
189
|
+
variant="text"
|
|
190
|
+
size="small"
|
|
191
|
+
aria-label="Remove this step"
|
|
192
|
+
@click="form.levels.splice(i, 1)"
|
|
193
|
+
/>
|
|
194
|
+
</v-col>
|
|
195
|
+
</v-row>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<AppButton
|
|
199
|
+
v-if="canEdit && form.levels.length < MAX_ESCALATION_LEVELS"
|
|
200
|
+
variant="ghost"
|
|
201
|
+
icon="mdi-plus"
|
|
202
|
+
@click="form.levels.push({ id: '', afterHours: 24, users: [], emails: [] })"
|
|
203
|
+
>
|
|
204
|
+
Add step
|
|
205
|
+
</AppButton>
|
|
206
|
+
|
|
207
|
+
<v-switch
|
|
208
|
+
v-model="form.workingTimeOnly"
|
|
209
|
+
class="app-switch mt-3"
|
|
210
|
+
hide-details
|
|
211
|
+
:disabled="!canEdit"
|
|
212
|
+
label="Count office hours only"
|
|
213
|
+
/>
|
|
214
|
+
<div class="screen-hint fh-note">
|
|
215
|
+
Time outside office hours (and public holidays, if treated as non-working) does not count.
|
|
216
|
+
</div>
|
|
217
|
+
</template>
|
|
218
|
+
</section>
|
|
219
|
+
|
|
220
|
+
<v-divider class="mb-6" />
|
|
221
|
+
|
|
222
|
+
<!-- Auto-close -->
|
|
223
|
+
<section class="mb-6">
|
|
224
|
+
<div class="fh-label">Close automatically when the provider marks done</div>
|
|
225
|
+
<div class="screen-hint fh-note mt-1 mb-3">
|
|
226
|
+
Use only for simple jobs like cleaning. The managing agent can still re-open.
|
|
227
|
+
</div>
|
|
228
|
+
<v-select
|
|
229
|
+
v-model="form.autoCloseCategories"
|
|
230
|
+
:items="subjectTitles"
|
|
231
|
+
label="Categories"
|
|
232
|
+
multiple
|
|
233
|
+
chips
|
|
234
|
+
closable-chips
|
|
235
|
+
density="comfortable"
|
|
236
|
+
hide-details
|
|
237
|
+
:disabled="!canEdit"
|
|
238
|
+
/>
|
|
239
|
+
</section>
|
|
240
|
+
|
|
241
|
+
<div v-if="errors.length && tried" class="text-error text-caption mb-3">
|
|
242
|
+
<div v-for="e in errors" :key="e">{{ e }}</div>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<div v-if="canEdit" class="d-flex justify-end">
|
|
246
|
+
<AppButton :disabled="saving || !loaded" @click="save">{{ saving ? "Saving" : "Save feedback handling" }}</AppButton>
|
|
247
|
+
</div>
|
|
248
|
+
</template>
|
|
249
|
+
|
|
250
|
+
<Snackbar v-model="snackbar" :text="message" :color="messageColor" />
|
|
251
|
+
</div>
|
|
252
|
+
</template>
|
|
253
|
+
|
|
254
|
+
<script setup lang="ts">
|
|
255
|
+
import {
|
|
256
|
+
MAX_ESCALATION_LEVELS,
|
|
257
|
+
WEEK_DAYS,
|
|
258
|
+
feedbackHandlingErrors,
|
|
259
|
+
feedbackHandlingForm,
|
|
260
|
+
feedbackSettingsFromForm,
|
|
261
|
+
type TFeedbackHandlingForm,
|
|
262
|
+
} from "../utils/feedback-handling-settings";
|
|
263
|
+
|
|
264
|
+
const props = withDefaults(
|
|
265
|
+
defineProps<{
|
|
266
|
+
siteId: string;
|
|
267
|
+
orgId?: string;
|
|
268
|
+
canEdit?: boolean;
|
|
269
|
+
}>(),
|
|
270
|
+
{ orgId: "", canEdit: false },
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const emit = defineEmits<{ saved: [] }>();
|
|
274
|
+
|
|
275
|
+
const { getSiteById } = useSiteSettings();
|
|
276
|
+
const { saveSiteFeature } = useSiteFeatureSettings();
|
|
277
|
+
const { getAll: getProviders } = useServiceProvider();
|
|
278
|
+
const { getAll: getMembers } = useMember();
|
|
279
|
+
const { subjects } = useLocal();
|
|
280
|
+
|
|
281
|
+
const loading = ref(true);
|
|
282
|
+
/** The stored settings were read; Save stays off until then (a failed read must not save defaults). */
|
|
283
|
+
const loaded = ref(false);
|
|
284
|
+
const saving = ref(false);
|
|
285
|
+
const tried = ref(false);
|
|
286
|
+
const form = ref<TFeedbackHandlingForm>(feedbackHandlingForm(null));
|
|
287
|
+
const providers = ref<Array<{ title: string; value: string }>>([]);
|
|
288
|
+
const people = ref<Array<{ title: string; value: string }>>([]);
|
|
289
|
+
|
|
290
|
+
const snackbar = ref(false);
|
|
291
|
+
const message = ref("");
|
|
292
|
+
const messageColor = ref("");
|
|
293
|
+
|
|
294
|
+
const subjectTitles = subjects.map((s) => s.value);
|
|
295
|
+
const targetItems = [
|
|
296
|
+
{ title: "Managing agent", value: "ma" },
|
|
297
|
+
{ title: "Provider", value: "provider" },
|
|
298
|
+
];
|
|
299
|
+
const providerItems = computed(() => providers.value);
|
|
300
|
+
const peopleItems = computed(() => people.value);
|
|
301
|
+
const errors = computed(() => feedbackHandlingErrors(form.value));
|
|
302
|
+
|
|
303
|
+
/** The subject list minus categories already routed (a row keeps its own). */
|
|
304
|
+
function categoryItems(current: string) {
|
|
305
|
+
const used = new Set(form.value.routes.map((r) => r.category).filter((c) => c && c !== current));
|
|
306
|
+
return subjectTitles.filter((s) => !used.has(s));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function notify(text: string, color: string) {
|
|
310
|
+
message.value = text;
|
|
311
|
+
messageColor.value = color;
|
|
312
|
+
snackbar.value = true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function load() {
|
|
316
|
+
loading.value = true;
|
|
317
|
+
try {
|
|
318
|
+
const site: any = await getSiteById(props.siteId);
|
|
319
|
+
form.value = feedbackHandlingForm(site?.metadata?.feedbackSettings ?? site?.data?.metadata?.feedbackSettings);
|
|
320
|
+
loaded.value = !!site;
|
|
321
|
+
const [p, m] = await Promise.allSettled([
|
|
322
|
+
getProviders({ siteId: props.siteId, limit: 100, status: "active" }),
|
|
323
|
+
getMembers({ siteId: props.siteId, org: props.orgId, limit: 100 }),
|
|
324
|
+
]);
|
|
325
|
+
if (p.status === "fulfilled") {
|
|
326
|
+
providers.value = (p.value?.items ?? []).map((i: any) => ({
|
|
327
|
+
title: i.name || i.orgName || "Provider",
|
|
328
|
+
value: String(i._id),
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
if (m.status === "fulfilled") {
|
|
332
|
+
const seen = new Set<string>();
|
|
333
|
+
people.value = (m.value?.items ?? [])
|
|
334
|
+
.map((i: any) => ({ title: i.name || i.email || "Member", value: String(i.user?._id ?? i.user ?? "") }))
|
|
335
|
+
.filter((i: { value: string }) => /^[a-f0-9]{24}$/i.test(i.value) && !seen.has(i.value) && !!seen.add(i.value));
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
notify(errorConverter(error), "error");
|
|
339
|
+
} finally {
|
|
340
|
+
loading.value = false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function save() {
|
|
345
|
+
tried.value = true;
|
|
346
|
+
if (errors.value.length || !loaded.value) return;
|
|
347
|
+
saving.value = true;
|
|
348
|
+
try {
|
|
349
|
+
await saveSiteFeature(props.siteId, "feedbackSettings", feedbackSettingsFromForm(form.value));
|
|
350
|
+
tried.value = false;
|
|
351
|
+
notify("Feedback handling saved", "success");
|
|
352
|
+
emit("saved");
|
|
353
|
+
} catch (error) {
|
|
354
|
+
notify(errorConverter(error), "error");
|
|
355
|
+
} finally {
|
|
356
|
+
saving.value = false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
onMounted(load);
|
|
361
|
+
</script>
|
|
362
|
+
|
|
363
|
+
<style scoped>
|
|
364
|
+
.fh-label {
|
|
365
|
+
font-family: var(--font-sans);
|
|
366
|
+
font-size: 14px;
|
|
367
|
+
font-weight: 800;
|
|
368
|
+
color: var(--text);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.fh-note {
|
|
372
|
+
font-size: 13px;
|
|
373
|
+
font-weight: 600;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.fh-step {
|
|
377
|
+
font-size: 13px;
|
|
378
|
+
font-weight: 700;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.fh-row {
|
|
382
|
+
border: 1px solid var(--border, rgba(0, 0, 0, 0.12));
|
|
383
|
+
border-radius: 8px;
|
|
384
|
+
}
|
|
385
|
+
</style>
|