@7365admin1/layer-common 3.2.2-staging.78 → 3.2.2-staging.80
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/.changeset/notification-preferences.md +17 -0
- package/components/NotificationSettings.vue +361 -0
- package/components/PassInformation.vue +104 -45
- package/components/ScanVisitorQRCode.vue +35 -7
- package/composables/useNotificationPreference.ts +87 -0
- package/composables/useVisitor.ts +6 -1
- package/package.json +1 -1
- package/pages/notification-settings.vue +19 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/layer-common": patch
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Add a Notification Settings screen, shared by every web app.
|
|
6
|
+
|
|
7
|
+
A person can now choose, per module and per delivery route, what they hear
|
|
8
|
+
about: in the app, on their phone, or by email. The screen only lists modules
|
|
9
|
+
their role actually gives them, and a route a module cannot use is shown dimmed
|
|
10
|
+
with the reason in plain words rather than hidden.
|
|
11
|
+
|
|
12
|
+
Modules are collapsed by default with a one-line summary of what is on, so
|
|
13
|
+
somebody with many modules gets a short list instead of thirty switches. There
|
|
14
|
+
is a master control for everything and one per module.
|
|
15
|
+
|
|
16
|
+
The choices are enforced where notifications are sent, in `@7365admin1/core` and
|
|
17
|
+
in the API, so a switch here actually stops the message.
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-row no-gutters class="pa-8" justify="center">
|
|
3
|
+
<v-col cols="12" md="10">
|
|
4
|
+
<v-row no-gutters>
|
|
5
|
+
<v-col cols="12" class="text-h5">Notifications</v-col>
|
|
6
|
+
|
|
7
|
+
<v-col cols="12" md="8" class="mt-4 font-weight-light">
|
|
8
|
+
Choose what you want to hear about, and how. These settings are yours
|
|
9
|
+
alone and apply everywhere you use iService — on this site and on your
|
|
10
|
+
phone.
|
|
11
|
+
</v-col>
|
|
12
|
+
|
|
13
|
+
<!-- loading -->
|
|
14
|
+
<v-col v-if="loading" cols="12" class="mt-8">
|
|
15
|
+
<v-skeleton-loader type="list-item-two-line@4" />
|
|
16
|
+
</v-col>
|
|
17
|
+
|
|
18
|
+
<!-- could not load -->
|
|
19
|
+
<v-col v-else-if="loadError" cols="12" class="mt-8">
|
|
20
|
+
<v-alert type="error" variant="tonal" rounded="lg">
|
|
21
|
+
{{ loadError }}
|
|
22
|
+
</v-alert>
|
|
23
|
+
</v-col>
|
|
24
|
+
|
|
25
|
+
<!-- nothing to offer -->
|
|
26
|
+
<v-col v-else-if="!categories.length" cols="12" class="mt-8">
|
|
27
|
+
<v-alert type="info" variant="tonal" rounded="lg">
|
|
28
|
+
You are not set up to receive any notifications yet. Once you are
|
|
29
|
+
given access to a part of the system, its settings will appear here.
|
|
30
|
+
</v-alert>
|
|
31
|
+
</v-col>
|
|
32
|
+
|
|
33
|
+
<template v-else>
|
|
34
|
+
<!-- everything, at once -->
|
|
35
|
+
<v-col cols="12" class="mt-8">
|
|
36
|
+
<v-card
|
|
37
|
+
width="100%"
|
|
38
|
+
border="grey-darken-3 sm"
|
|
39
|
+
variant="outlined"
|
|
40
|
+
rounded="lg"
|
|
41
|
+
>
|
|
42
|
+
<v-row no-gutters align="center" class="pa-6">
|
|
43
|
+
<v-col cols="12" sm="8">
|
|
44
|
+
<div class="text-h6">Everything</div>
|
|
45
|
+
<div class="text-body-2 text-medium-emphasis mt-1">
|
|
46
|
+
{{ everythingSummary }}
|
|
47
|
+
</div>
|
|
48
|
+
</v-col>
|
|
49
|
+
<v-col cols="12" sm="4" class="d-flex justify-sm-end mt-3 mt-sm-0">
|
|
50
|
+
<v-btn
|
|
51
|
+
variant="tonal"
|
|
52
|
+
rounded="xl"
|
|
53
|
+
class="text-none"
|
|
54
|
+
:disabled="saving"
|
|
55
|
+
@click="setAll(!allOn)"
|
|
56
|
+
>
|
|
57
|
+
{{ allOn ? "Turn everything off" : "Turn everything on" }}
|
|
58
|
+
</v-btn>
|
|
59
|
+
</v-col>
|
|
60
|
+
</v-row>
|
|
61
|
+
</v-card>
|
|
62
|
+
</v-col>
|
|
63
|
+
|
|
64
|
+
<!-- one panel per module, collapsed by default so a person with many
|
|
65
|
+
modules gets a short list rather than a wall of switches -->
|
|
66
|
+
<v-col cols="12" class="mt-4">
|
|
67
|
+
<v-expansion-panels variant="accordion" multiple>
|
|
68
|
+
<v-expansion-panel
|
|
69
|
+
v-for="category in categories"
|
|
70
|
+
:key="category.key"
|
|
71
|
+
:value="category.key"
|
|
72
|
+
elevation="0"
|
|
73
|
+
class="border-sm rounded-lg mb-2"
|
|
74
|
+
>
|
|
75
|
+
<v-expansion-panel-title>
|
|
76
|
+
<v-row no-gutters align="center">
|
|
77
|
+
<v-col cols="12" sm="6">
|
|
78
|
+
<span class="text-subtitle-1 font-weight-medium">
|
|
79
|
+
{{ category.label }}
|
|
80
|
+
</span>
|
|
81
|
+
<v-chip
|
|
82
|
+
v-if="category.safety"
|
|
83
|
+
size="x-small"
|
|
84
|
+
class="ml-2"
|
|
85
|
+
variant="tonal"
|
|
86
|
+
color="info"
|
|
87
|
+
>
|
|
88
|
+
Safety
|
|
89
|
+
</v-chip>
|
|
90
|
+
</v-col>
|
|
91
|
+
<v-col
|
|
92
|
+
cols="12"
|
|
93
|
+
sm="6"
|
|
94
|
+
class="text-body-2 text-medium-emphasis"
|
|
95
|
+
>
|
|
96
|
+
{{ summaryFor(category) }}
|
|
97
|
+
</v-col>
|
|
98
|
+
</v-row>
|
|
99
|
+
</v-expansion-panel-title>
|
|
100
|
+
|
|
101
|
+
<v-expansion-panel-text>
|
|
102
|
+
<div class="text-body-2 text-medium-emphasis mb-4">
|
|
103
|
+
{{ category.description }}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<v-btn
|
|
107
|
+
size="small"
|
|
108
|
+
variant="outlined"
|
|
109
|
+
rounded="xl"
|
|
110
|
+
class="text-none mb-4"
|
|
111
|
+
:disabled="saving || !switchableIn(category).length"
|
|
112
|
+
@click="setCategory(category, !categoryAllOn(category))"
|
|
113
|
+
>
|
|
114
|
+
{{
|
|
115
|
+
categoryAllOn(category)
|
|
116
|
+
? "Turn all off for " + category.label
|
|
117
|
+
: "Turn all on for " + category.label
|
|
118
|
+
}}
|
|
119
|
+
</v-btn>
|
|
120
|
+
|
|
121
|
+
<!-- one channel per row: a switch must never sit beside the
|
|
122
|
+
NEXT channel's label, which is what a three-up grid does -->
|
|
123
|
+
<v-row no-gutters>
|
|
124
|
+
<v-col
|
|
125
|
+
v-for="(channel, index) in category.channels"
|
|
126
|
+
:key="channel.key"
|
|
127
|
+
cols="12"
|
|
128
|
+
:class="index < category.channels.length - 1 ? 'border-b-thin' : ''"
|
|
129
|
+
>
|
|
130
|
+
<div
|
|
131
|
+
class="d-flex align-center justify-space-between py-2"
|
|
132
|
+
:class="{ 'channel-unavailable': !channel.supported }"
|
|
133
|
+
>
|
|
134
|
+
<div class="pr-4">
|
|
135
|
+
<div class="text-body-1">{{ channel.label }}</div>
|
|
136
|
+
<div v-if="!channel.supported" class="text-caption mt-1">
|
|
137
|
+
{{ channel.note }}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<v-switch
|
|
142
|
+
:model-value="channel.enabled"
|
|
143
|
+
:disabled="!channel.supported || saving"
|
|
144
|
+
color="primary"
|
|
145
|
+
hide-details
|
|
146
|
+
density="compact"
|
|
147
|
+
:aria-label="`${channel.label} for ${category.label}`"
|
|
148
|
+
@update:model-value="
|
|
149
|
+
(value) => onToggle(category, channel, !!value)
|
|
150
|
+
"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
</v-col>
|
|
154
|
+
</v-row>
|
|
155
|
+
</v-expansion-panel-text>
|
|
156
|
+
</v-expansion-panel>
|
|
157
|
+
</v-expansion-panels>
|
|
158
|
+
</v-col>
|
|
159
|
+
</template>
|
|
160
|
+
</v-row>
|
|
161
|
+
</v-col>
|
|
162
|
+
|
|
163
|
+
<!-- switching a safety alert off: say plainly what stops arriving -->
|
|
164
|
+
<v-dialog v-model="confirmOpen" max-width="460" persistent>
|
|
165
|
+
<v-card rounded="lg" class="pa-2">
|
|
166
|
+
<v-card-title class="text-h6">{{ confirmText.title }}</v-card-title>
|
|
167
|
+
<v-card-text class="text-body-1">{{ confirmText.body }}</v-card-text>
|
|
168
|
+
<v-card-actions class="justify-end">
|
|
169
|
+
<v-btn variant="text" class="text-none" @click="cancelConfirm">
|
|
170
|
+
{{ confirmText.cancel }}
|
|
171
|
+
</v-btn>
|
|
172
|
+
<v-btn
|
|
173
|
+
color="primary"
|
|
174
|
+
variant="tonal"
|
|
175
|
+
class="text-none"
|
|
176
|
+
@click="acceptConfirm"
|
|
177
|
+
>
|
|
178
|
+
{{ confirmText.confirm }}
|
|
179
|
+
</v-btn>
|
|
180
|
+
</v-card-actions>
|
|
181
|
+
</v-card>
|
|
182
|
+
</v-dialog>
|
|
183
|
+
|
|
184
|
+
<v-snackbar v-model="noticeOpen" :timeout="4000" location="bottom">
|
|
185
|
+
{{ notice }}
|
|
186
|
+
</v-snackbar>
|
|
187
|
+
</v-row>
|
|
188
|
+
</template>
|
|
189
|
+
|
|
190
|
+
<script setup lang="ts">
|
|
191
|
+
import type {
|
|
192
|
+
TNotificationCategorySetting,
|
|
193
|
+
TNotificationChannelSetting,
|
|
194
|
+
} from "../composables/useNotificationPreference";
|
|
195
|
+
|
|
196
|
+
const { getPreferences, savePreferences } = useNotificationPreference();
|
|
197
|
+
|
|
198
|
+
const categories = ref<TNotificationCategorySetting[]>([]);
|
|
199
|
+
const loading = ref(true);
|
|
200
|
+
const saving = ref(false);
|
|
201
|
+
const loadError = ref("");
|
|
202
|
+
const notice = ref("");
|
|
203
|
+
const noticeOpen = ref(false);
|
|
204
|
+
|
|
205
|
+
const confirmOpen = ref(false);
|
|
206
|
+
const confirmText = ref({ title: "", body: "", confirm: "", cancel: "" });
|
|
207
|
+
let confirmAccept: (() => void) | null = null;
|
|
208
|
+
|
|
209
|
+
/** the channels a person can actually move on this category */
|
|
210
|
+
const switchableIn = (category: TNotificationCategorySetting) =>
|
|
211
|
+
category.channels.filter((c) => c.supported);
|
|
212
|
+
|
|
213
|
+
const categoryAllOn = (category: TNotificationCategorySetting) =>
|
|
214
|
+
switchableIn(category).every((c) => c.enabled);
|
|
215
|
+
|
|
216
|
+
const allOn = computed(
|
|
217
|
+
() =>
|
|
218
|
+
categories.value.length > 0 &&
|
|
219
|
+
categories.value.every((category) => categoryAllOn(category)),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
/** what is on, in words — so a collapsed panel still says what it is doing */
|
|
223
|
+
function summaryFor(category: TNotificationCategorySetting) {
|
|
224
|
+
const on = switchableIn(category).filter((c) => c.enabled);
|
|
225
|
+
if (!on.length) return "Off";
|
|
226
|
+
if (on.length === switchableIn(category).length) return "On everywhere";
|
|
227
|
+
return `On: ${on.map((c) => c.label).join(", ")}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const everythingSummary = computed(() => {
|
|
231
|
+
const total = categories.value.length;
|
|
232
|
+
const on = categories.value.filter((c) => categoryAllOn(c)).length;
|
|
233
|
+
if (on === total) return "Everything is on.";
|
|
234
|
+
if (on === 0) return "Everything is off.";
|
|
235
|
+
return `${on} of ${total} fully on.`;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
async function load() {
|
|
239
|
+
loading.value = true;
|
|
240
|
+
loadError.value = "";
|
|
241
|
+
try {
|
|
242
|
+
const res = await getPreferences();
|
|
243
|
+
categories.value = res.data.categories;
|
|
244
|
+
} catch {
|
|
245
|
+
loadError.value =
|
|
246
|
+
"We could not load your notification settings. Please refresh the page and try again.";
|
|
247
|
+
} finally {
|
|
248
|
+
loading.value = false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Save the whole screen after any change, and put it back the way it was if the
|
|
254
|
+
* save fails — so what is on screen is never a setting the server did not take.
|
|
255
|
+
*/
|
|
256
|
+
async function persist(previous: TNotificationCategorySetting[]) {
|
|
257
|
+
saving.value = true;
|
|
258
|
+
try {
|
|
259
|
+
const res = await savePreferences({ channels: [], categories: categories.value });
|
|
260
|
+
categories.value = res.data.categories;
|
|
261
|
+
notice.value = "Saved.";
|
|
262
|
+
} catch {
|
|
263
|
+
categories.value = previous;
|
|
264
|
+
notice.value = "We could not save that change. Please try again.";
|
|
265
|
+
} finally {
|
|
266
|
+
noticeOpen.value = true;
|
|
267
|
+
saving.value = false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const snapshot = () =>
|
|
272
|
+
JSON.parse(JSON.stringify(categories.value)) as TNotificationCategorySetting[];
|
|
273
|
+
|
|
274
|
+
function apply(mutate: () => void) {
|
|
275
|
+
const previous = snapshot();
|
|
276
|
+
mutate();
|
|
277
|
+
void persist(previous);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function onToggle(
|
|
281
|
+
category: TNotificationCategorySetting,
|
|
282
|
+
channel: TNotificationChannelSetting,
|
|
283
|
+
value: boolean,
|
|
284
|
+
) {
|
|
285
|
+
if (!channel.supported) return;
|
|
286
|
+
|
|
287
|
+
const commit = () => apply(() => (channel.enabled = value));
|
|
288
|
+
|
|
289
|
+
// switching a safety alert OFF is the only thing worth stopping to confirm
|
|
290
|
+
if (category.safety && !value) {
|
|
291
|
+
confirmText.value = safetyOffConfirmation(category.label, channel.label);
|
|
292
|
+
confirmAccept = commit;
|
|
293
|
+
confirmOpen.value = true;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
commit();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function setCategory(category: TNotificationCategorySetting, value: boolean) {
|
|
301
|
+
const commit = () =>
|
|
302
|
+
apply(() => {
|
|
303
|
+
for (const channel of switchableIn(category)) channel.enabled = value;
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (category.safety && !value) {
|
|
307
|
+
confirmText.value = safetyOffConfirmation(category.label, "every channel");
|
|
308
|
+
confirmAccept = commit;
|
|
309
|
+
confirmOpen.value = true;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
commit();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function setAll(value: boolean) {
|
|
317
|
+
const commit = () =>
|
|
318
|
+
apply(() => {
|
|
319
|
+
for (const category of categories.value) {
|
|
320
|
+
for (const channel of switchableIn(category)) channel.enabled = value;
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if (!value && categories.value.some((c) => c.safety)) {
|
|
325
|
+
confirmText.value = safetyOffConfirmation("safety and security", "every channel");
|
|
326
|
+
confirmAccept = commit;
|
|
327
|
+
confirmOpen.value = true;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
commit();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function acceptConfirm() {
|
|
335
|
+
confirmOpen.value = false;
|
|
336
|
+
confirmAccept?.();
|
|
337
|
+
confirmAccept = null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function cancelConfirm() {
|
|
341
|
+
confirmOpen.value = false;
|
|
342
|
+
confirmAccept = null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
onMounted(load);
|
|
346
|
+
</script>
|
|
347
|
+
|
|
348
|
+
<style scoped>
|
|
349
|
+
/* Unavailable channels are shown and explained, never hidden.
|
|
350
|
+
*
|
|
351
|
+
* They are NOT faded. The disabled switch already says the control cannot be
|
|
352
|
+
* moved, and the sentence beside it is the only place a person is told why —
|
|
353
|
+
* dimming that sentence is dimming the explanation. A blanket opacity here
|
|
354
|
+
* measured 2.3:1 against the card, which is unreadable; the row is now marked
|
|
355
|
+
* by a rule down its edge instead, and every word stays at full strength. */
|
|
356
|
+
.channel-unavailable {
|
|
357
|
+
border-left: 2px solid rgb(var(--v-border-color));
|
|
358
|
+
padding-left: 12px;
|
|
359
|
+
opacity: 1;
|
|
360
|
+
}
|
|
361
|
+
</style>
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
:height="40"
|
|
9
9
|
text="Scan QR Code"
|
|
10
10
|
class="text-capitalize"
|
|
11
|
-
disabled
|
|
12
11
|
prepend-icon="mdi-qrcode"
|
|
12
|
+
@click="handleScanQRCode"
|
|
13
13
|
/>
|
|
14
14
|
<v-autocomplete
|
|
15
15
|
v-model="selectedPass"
|
|
@@ -88,6 +88,11 @@
|
|
|
88
88
|
</v-card-text>
|
|
89
89
|
</v-card>
|
|
90
90
|
</v-row>
|
|
91
|
+
<visitor-pass-key-q-r-scanner
|
|
92
|
+
:dialog="dialog.scanQRCode"
|
|
93
|
+
v-model:scannedValue="scannedValue"
|
|
94
|
+
@close-dialog="dialog.scanQRCode = false"
|
|
95
|
+
/>
|
|
91
96
|
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
92
97
|
</template>
|
|
93
98
|
|
|
@@ -95,46 +100,36 @@
|
|
|
95
100
|
import type { PropType } from "vue";
|
|
96
101
|
import type { ValidationRule } from "vuetify/lib/types.mjs";
|
|
97
102
|
import usePassKey from "../composables/usePassKey";
|
|
98
|
-
import
|
|
99
|
-
import
|
|
103
|
+
import { errorConverter } from "../utils/data";
|
|
104
|
+
import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
passRules
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
type: Array,
|
|
124
|
-
default: [],
|
|
125
|
-
},
|
|
126
|
-
hideKeys: {
|
|
127
|
-
type: Boolean,
|
|
128
|
-
default: false,
|
|
129
|
-
},
|
|
130
|
-
clearable: {
|
|
131
|
-
type: Boolean,
|
|
132
|
-
default: false,
|
|
133
|
-
},
|
|
106
|
+
type PassInformationProps = {
|
|
107
|
+
passRules?: ValidationRule[];
|
|
108
|
+
countRules?: ValidationRule[];
|
|
109
|
+
site: string;
|
|
110
|
+
type: TVisitorType;
|
|
111
|
+
contractorType?: string;
|
|
112
|
+
passKeys?: unknown[];
|
|
113
|
+
hideKeys?: boolean;
|
|
114
|
+
clearable?: boolean;
|
|
115
|
+
pass?: TPassKeyPayload[];
|
|
116
|
+
keys?: TPassKeyPayload[];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const props = withDefaults(defineProps<PassInformationProps>(), {
|
|
120
|
+
passRules: [],
|
|
121
|
+
countRules: [],
|
|
122
|
+
contractorType: "",
|
|
123
|
+
passKeys: [],
|
|
124
|
+
hideKeys: false,
|
|
125
|
+
clearable: false,
|
|
126
|
+
pass: [],
|
|
127
|
+
keys: [],
|
|
134
128
|
});
|
|
135
129
|
|
|
136
|
-
const
|
|
137
|
-
const
|
|
130
|
+
const emit = defineEmits(["update:pass", "update:keys"]);
|
|
131
|
+
const pass = ref<TPassKeyPayload[]>(props.pass);
|
|
132
|
+
const keys = ref<TPassKeyPayload[]>(props.keys);
|
|
138
133
|
const selectedPass = ref<string>("");
|
|
139
134
|
const selectedKeys = ref<string[]>([]);
|
|
140
135
|
const passInput = ref("");
|
|
@@ -144,6 +139,11 @@ const keyItems = ref<any[]>([]);
|
|
|
144
139
|
const selectedType = ref<"qr-pass" | "nfc-card">();
|
|
145
140
|
const count = ref(1);
|
|
146
141
|
|
|
142
|
+
const dialog = reactive({
|
|
143
|
+
scanQRCode: false,
|
|
144
|
+
});
|
|
145
|
+
const scannedValue = ref("");
|
|
146
|
+
|
|
147
147
|
const { getPassKeysByPageSearch } = usePassKey();
|
|
148
148
|
|
|
149
149
|
const typeItems = [
|
|
@@ -179,6 +179,23 @@ function showMessage(msg: string, color: string) {
|
|
|
179
179
|
messageSnackbar.value = true;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
function getItemLabel(item: any) {
|
|
183
|
+
return (item?.prefixAndName ?? item?.raw?.prefixAndName ?? "")
|
|
184
|
+
.toString()
|
|
185
|
+
.trim();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleScanQRCode() {
|
|
189
|
+
scannedValue.value = "";
|
|
190
|
+
dialog.scanQRCode = true;
|
|
191
|
+
if (passItems.value.length === 0) {
|
|
192
|
+
fetchPasses();
|
|
193
|
+
}
|
|
194
|
+
if (!props.hideKeys && keyItems.value.length === 0) {
|
|
195
|
+
fetchKeys();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
182
199
|
const isPassListLoading = ref(false);
|
|
183
200
|
|
|
184
201
|
async function fetchPasses() {
|
|
@@ -207,7 +224,7 @@ const isKeyListLoading = ref(false);
|
|
|
207
224
|
async function fetchKeys() {
|
|
208
225
|
isKeyListLoading.value = true;
|
|
209
226
|
try {
|
|
210
|
-
const
|
|
227
|
+
const result: any = await getPassKeysByPageSearch({
|
|
211
228
|
page: 1,
|
|
212
229
|
sites: [props.site],
|
|
213
230
|
limit: 10000,
|
|
@@ -223,7 +240,7 @@ async function fetchKeys() {
|
|
|
223
240
|
return false;
|
|
224
241
|
});
|
|
225
242
|
|
|
226
|
-
const filteredArray =
|
|
243
|
+
const filteredArray = result.items.filter((item: any) => {
|
|
227
244
|
if (Array.isArray(props?.passKeys) && props.passKeys.length) {
|
|
228
245
|
return !props.passKeys.includes(item._id);
|
|
229
246
|
}
|
|
@@ -231,18 +248,60 @@ async function fetchKeys() {
|
|
|
231
248
|
});
|
|
232
249
|
keyItems.value = [...previouslySelectedKeys, ...filteredArray];
|
|
233
250
|
} catch (error) {
|
|
234
|
-
showMessage(errorConverter(error
|
|
251
|
+
showMessage(errorConverter(error), "error");
|
|
235
252
|
} finally {
|
|
236
253
|
isKeyListLoading.value = false;
|
|
237
254
|
}
|
|
238
255
|
}
|
|
239
256
|
|
|
240
257
|
watch(selectedPass, (newVal) => {
|
|
241
|
-
|
|
258
|
+
const updated = newVal ? [{ keyId: newVal }] : [];
|
|
259
|
+
pass.value = updated;
|
|
260
|
+
emit("update:pass", updated);
|
|
242
261
|
});
|
|
243
262
|
|
|
244
263
|
watch(selectedKeys, (newVal) => {
|
|
245
|
-
|
|
264
|
+
const updated = newVal.map((key) => ({ keyId: key }));
|
|
265
|
+
keys.value = updated;
|
|
266
|
+
emit("update:keys", updated);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
watch(scannedValue, async (newValue) => {
|
|
270
|
+
if (!newValue) return;
|
|
271
|
+
|
|
272
|
+
const scanned = String(newValue).trim();
|
|
273
|
+
if (!scanned) return;
|
|
274
|
+
|
|
275
|
+
if (passItems.value.length === 0) {
|
|
276
|
+
await fetchPasses();
|
|
277
|
+
}
|
|
278
|
+
if (!props.hideKeys && keyItems.value.length === 0) {
|
|
279
|
+
await fetchKeys();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const matchedPass = passItems.value.find((item) => {
|
|
283
|
+
return getItemLabel(item).toLowerCase() === scanned.toLowerCase();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (matchedPass && matchedPass._id) {
|
|
287
|
+
selectedPass.value = matchedPass._id;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!props.hideKeys) {
|
|
292
|
+
const matchedKey = keyItems.value.find((item) => {
|
|
293
|
+
return getItemLabel(item).toLowerCase() === scanned.toLowerCase();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (matchedKey && matchedKey._id) {
|
|
297
|
+
if (!selectedKeys.value.includes(matchedKey._id)) {
|
|
298
|
+
selectedKeys.value = [...selectedKeys.value, matchedKey._id];
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
showMessage(`Scanned pass or key "${scanned}" not found`, "error");
|
|
246
305
|
});
|
|
247
306
|
|
|
248
307
|
//prevent negative value;
|
|
@@ -254,10 +313,10 @@ watch(count, (newCount) => {
|
|
|
254
313
|
|
|
255
314
|
onMounted(() => {
|
|
256
315
|
if (pass.value.length > 0) {
|
|
257
|
-
selectedPass.value = pass.value[0]
|
|
316
|
+
selectedPass.value = pass.value[0]?.keyId ?? "";
|
|
258
317
|
}
|
|
259
318
|
if (keys.value.length > 0) {
|
|
260
|
-
selectedKeys.value = keys.value.map((k) => k.keyId);
|
|
319
|
+
selectedKeys.value = keys.value.map((k) => k.keyId ?? "");
|
|
261
320
|
}
|
|
262
321
|
});
|
|
263
322
|
</script>
|
|
@@ -60,7 +60,9 @@ const props = defineProps({
|
|
|
60
60
|
|
|
61
61
|
const { standardFormatDate } = useUtils();
|
|
62
62
|
|
|
63
|
-
const { validateVisitorQrCode } = useVisitor();
|
|
63
|
+
const { validateVisitorQrCode, getVisitors } = useVisitor();
|
|
64
|
+
|
|
65
|
+
const { getPassKeysByPageSearch } = usePassKey();
|
|
64
66
|
|
|
65
67
|
const emits = defineEmits([
|
|
66
68
|
"closeDialog",
|
|
@@ -97,8 +99,6 @@ const handleScanVisitorQRCode = async () => {
|
|
|
97
99
|
showNotFoundMessage.value = false;
|
|
98
100
|
// Automatically navigate to the scanned URL if it's a valid link
|
|
99
101
|
if (isValidUrl(qrCodeMessage)) {
|
|
100
|
-
console.log("QR Code value", qrCodeMessage);
|
|
101
|
-
console.log("Last _id", qrCodeMessage.split("/").pop());
|
|
102
102
|
// get the last _id in the url
|
|
103
103
|
const id = qrCodeMessage.split("/").pop();
|
|
104
104
|
// check if it's a valid mongodb _id
|
|
@@ -108,7 +108,6 @@ const handleScanVisitorQRCode = async () => {
|
|
|
108
108
|
props.site as string,
|
|
109
109
|
id as string
|
|
110
110
|
);
|
|
111
|
-
console.log("validateVisitorQrCode", response);
|
|
112
111
|
if (response.errorMessage) {
|
|
113
112
|
showNotFoundMessage.value = true;
|
|
114
113
|
errorMessage.value =
|
|
@@ -122,9 +121,38 @@ const handleScanVisitorQRCode = async () => {
|
|
|
122
121
|
emits("openVisitorDataFromScannedQrCodeDialog", response);
|
|
123
122
|
}
|
|
124
123
|
} else {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
const result: any = await getPassKeysByPageSearch({
|
|
125
|
+
sites: [props.site as string],
|
|
126
|
+
search: qrCodeMessage,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (result.items?.length < 1) {
|
|
130
|
+
showMessage(`Pass or Key ${qrCodeMessage} not found.`, "error");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const data: any = await getVisitors({
|
|
135
|
+
site: props.site as string,
|
|
136
|
+
passOrKey: {
|
|
137
|
+
keyId: result.items[0]._id,
|
|
138
|
+
type: result.items[0].passType == "pass-key" ? "key" : "pass",
|
|
139
|
+
},
|
|
140
|
+
checkedOut: false,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (data.items?.length < 1) {
|
|
144
|
+
showMessage(
|
|
145
|
+
`There are no unchecked-out visitor transactions for the scanned ${
|
|
146
|
+
result.items[0].passType == "pass-key" ? "Key" : "Pass"
|
|
147
|
+
} ${qrCodeMessage}.`,
|
|
148
|
+
"error"
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
closeDialog();
|
|
155
|
+
emits("openVisitorDataFromScannedQrCodeDialog", data.items[0]);
|
|
128
156
|
}
|
|
129
157
|
|
|
130
158
|
// window.location.href = qrCodeMessage;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person's own notification settings.
|
|
3
|
+
*
|
|
4
|
+
* The server decides everything that matters here: which categories this person
|
|
5
|
+
* is offered, which channels each one can actually reach them on, and what the
|
|
6
|
+
* words are. Nothing on this side maintains a second list — a screen that made
|
|
7
|
+
* up its own module names is how the previous version came to show three
|
|
8
|
+
* switches that governed nothing.
|
|
9
|
+
*
|
|
10
|
+
* There is no user id in either call. Whose settings these are comes from the
|
|
11
|
+
* session, so a person can only ever see and change their own.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type TNotificationChannelSetting = {
|
|
15
|
+
key: string;
|
|
16
|
+
label: string;
|
|
17
|
+
/** false = this category cannot reach them this way; drawn, dimmed, explained */
|
|
18
|
+
supported: boolean;
|
|
19
|
+
/** the plain-English reason, present only when unsupported */
|
|
20
|
+
note?: string;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type TNotificationCategorySetting = {
|
|
25
|
+
key: string;
|
|
26
|
+
label: string;
|
|
27
|
+
description: string;
|
|
28
|
+
safety: boolean;
|
|
29
|
+
channels: TNotificationChannelSetting[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type TNotificationPreferences = {
|
|
33
|
+
channels: Array<{ key: string; label: string }>;
|
|
34
|
+
categories: TNotificationCategorySetting[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const EMPTY: TNotificationPreferences = { channels: [], categories: [] };
|
|
38
|
+
|
|
39
|
+
export default function useNotificationPreference() {
|
|
40
|
+
function getPreferences() {
|
|
41
|
+
return useNuxtApp().$api<{ data: TNotificationPreferences }>(
|
|
42
|
+
"/api/notification-preferences",
|
|
43
|
+
{ method: "GET" },
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Save the whole screen at once.
|
|
49
|
+
*
|
|
50
|
+
* What is sent is the list of switches that are OFF, which is also how the
|
|
51
|
+
* server stores it: somebody who has changed nothing has no stored row at
|
|
52
|
+
* all, and behaves exactly as they did before this screen existed.
|
|
53
|
+
*/
|
|
54
|
+
function savePreferences(preferences: TNotificationPreferences) {
|
|
55
|
+
const off = preferences.categories.flatMap((category) =>
|
|
56
|
+
category.channels
|
|
57
|
+
.filter((channel) => channel.supported && !channel.enabled)
|
|
58
|
+
.map((channel) => ({ category: category.key, channel: channel.key })),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return useNuxtApp().$api<{ data: TNotificationPreferences }>(
|
|
62
|
+
"/api/notification-preferences",
|
|
63
|
+
{ method: "PUT", body: { off } },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { getPreferences, savePreferences, EMPTY_PREFERENCES: EMPTY };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The sentence shown when somebody switches a safety or security alert off.
|
|
72
|
+
*
|
|
73
|
+
* It says what will stop arriving and how to undo it, and it does not argue —
|
|
74
|
+
* the owner's decision is that these can be switched off, so the job here is to
|
|
75
|
+
* make sure nobody does it by accident, not to talk them out of it.
|
|
76
|
+
*/
|
|
77
|
+
export function safetyOffConfirmation(categoryLabel: string, channelLabel: string) {
|
|
78
|
+
return {
|
|
79
|
+
title: `Turn off ${categoryLabel}?`,
|
|
80
|
+
body:
|
|
81
|
+
`You will stop getting ${categoryLabel.toLowerCase()} alerts under "${channelLabel}". ` +
|
|
82
|
+
`These are safety and security alerts, so they are on for everyone by default. ` +
|
|
83
|
+
`You can turn them back on here at any time.`,
|
|
84
|
+
confirm: "Turn it off",
|
|
85
|
+
cancel: "Keep it on",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -92,6 +92,7 @@ export default function () {
|
|
|
92
92
|
checkedOut?: boolean;
|
|
93
93
|
plateNumber?: string;
|
|
94
94
|
tab?: string;
|
|
95
|
+
passOrKey?: string;
|
|
95
96
|
};
|
|
96
97
|
|
|
97
98
|
type SearchUncheckedOutUnregisteredVisitorParams = {
|
|
@@ -130,6 +131,7 @@ export default function () {
|
|
|
130
131
|
plateNumber = "",
|
|
131
132
|
checkedOut,
|
|
132
133
|
tab,
|
|
134
|
+
passOrKey,
|
|
133
135
|
}: GetVisitorsParams = {}) {
|
|
134
136
|
const query = Object.fromEntries(
|
|
135
137
|
Object.entries({
|
|
@@ -146,7 +148,10 @@ export default function () {
|
|
|
146
148
|
plateNumber,
|
|
147
149
|
order,
|
|
148
150
|
tab,
|
|
149
|
-
|
|
151
|
+
passOrKey,
|
|
152
|
+
}).filter(
|
|
153
|
+
([, value]) => value !== undefined && value !== null && value !== ""
|
|
154
|
+
)
|
|
150
155
|
);
|
|
151
156
|
|
|
152
157
|
return await useNuxtApp().$api<Record<string, any>>(
|
package/package.json
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<NotificationSettings />
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts">
|
|
6
|
+
/**
|
|
7
|
+
* Notification settings, reachable from Profile.
|
|
8
|
+
*
|
|
9
|
+
* The page lives in the shared layer so every web app has the route and there
|
|
10
|
+
* is only one screen to keep correct; the link to it sits in Profile, in the
|
|
11
|
+
* account app, which is where "Manage Account" already sends everybody.
|
|
12
|
+
*/
|
|
13
|
+
definePageMeta({
|
|
14
|
+
middleware: ["01-auth"],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const { authenticate } = useLocalAuth();
|
|
18
|
+
authenticate();
|
|
19
|
+
</script>
|