@7365admin1/layer-common 4.118.10 → 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 (45) hide show
  1. package/CHANGELOG.md +25 -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/useCommonPermission.ts +10 -0
  28. package/composables/useDataRetention.ts +43 -0
  29. package/composables/useDocument.ts +13 -0
  30. package/composables/useFeedback.ts +12 -0
  31. package/composables/useHandoverChecklist.ts +111 -0
  32. package/composables/useKeyPresses.ts +57 -0
  33. package/composables/usePassKey.ts +4 -0
  34. package/composables/useSecurityPermission.ts +38 -0
  35. package/composables/useSettingsPermission.ts +16 -1
  36. package/composables/useSiteFeatureSettings.ts +37 -0
  37. package/composables/useVisitor.ts +22 -0
  38. package/constants/permissions.ts +3 -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 +64 -0
  44. package/utils/key-presses.ts +55 -0
  45. package/utils/list-export.ts +39 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
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
+
18
+ ## 4.119.0
19
+
20
+ ### Minor Changes
21
+
22
+ - 51b87b6: Security Ops: new `security-ops` resource (`start-site-visit`, `see-all-site-visits`) in the security role catalogue, and `site-settings:manage-geofence` added to the site-settings vocabulary so a Master Admin role can be given the Geofencing panel. The security catalogue cherry-picks site-settings entries rather than spreading them, so `manage-geofence` is listed there explicitly too, and `useSettingsPermission` exposes `canManageGeofence` (also folded into `canManageSiteSettings`).
23
+
3
24
  ## 4.118.10
4
25
 
5
26
  ### Patch Changes
@@ -3188,11 +3209,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3188
3209
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3189
3210
  own rule from two endpoints the console already calls, unprojected:
3190
3211
 
3191
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3192
- GET /api/roles/id/:role that membership's role document
3212
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3213
+ GET /api/roles/id/:role that membership's role document
3193
3214
 
3194
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3195
- staff = member.type === "admin" && role.type === "admin"
3215
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3216
+ staff = member.type === "admin" && role.type === "admin"
3196
3217
 
3197
3218
  `role.default` is the marker because it is the only property of a platform
3198
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>