@7365admin1/layer-common 4.119.0 → 4.121.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -4
- package/components/AccessCardDetailsDialog.vue +3 -2
- package/components/AddPassKeyToVisitor.vue +5 -0
- package/components/BuildingUnitFormEdit.vue +3 -1
- package/components/DataRetentionBanner.vue +73 -0
- 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/MemberSiteAccess.vue +51 -2
- package/components/PassInformation.vue +5 -0
- package/components/RolePermissionFormCreate.vue +33 -0
- package/components/SearchVehicleNumberUser.vue +3 -0
- package/components/SiteDataRetention.vue +153 -0
- package/components/SiteSettings.vue +48 -0
- package/components/SiteSettingsVisitorRegistration.vue +131 -0
- package/components/VehicleForm.vue +2 -2
- package/components/VisitorForm.vue +45 -6
- package/components/VisitorManagement.vue +3 -1
- package/components/VisitorSearchResultSummary.vue +2 -1
- package/composables/useDataRetention.ts +49 -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/useMember.ts +6 -0
- package/composables/usePassKey.ts +4 -0
- package/composables/useSiteFeatureSettings.ts +37 -0
- package/composables/useVisitor.ts +25 -0
- package/constants/permissions.ts +12 -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 +108 -2
- package/utils/key-presses.ts +55 -0
- package/utils/list-export.ts +39 -0
- package/utils/notification-target.ts +10 -0
- package/utils/site-access.ts +13 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div data-test="handover-setup">
|
|
3
|
+
<div class="d-flex flex-wrap align-center ga-2 mb-4">
|
|
4
|
+
<p class="text-body-2 text-medium-emphasis mb-0 flex-grow-1">
|
|
5
|
+
One checklist per room or inspection. The site team uses it to hand the room over (before use) and take it back (after
|
|
6
|
+
use).
|
|
7
|
+
</p>
|
|
8
|
+
<template v-if="canEdit">
|
|
9
|
+
<v-menu>
|
|
10
|
+
<template #activator="{ props: menu }">
|
|
11
|
+
<v-btn v-bind="menu" variant="outlined" class="screen-btn-ghost" prepend-icon="mdi-file-document-outline"
|
|
12
|
+
text="Use a ready-made template" />
|
|
13
|
+
</template>
|
|
14
|
+
<v-list density="compact">
|
|
15
|
+
<v-list-item v-for="s in HANDOVER_STARTERS" :key="s.name" :title="s.name" @click="openEditor(s.template)" />
|
|
16
|
+
</v-list>
|
|
17
|
+
</v-menu>
|
|
18
|
+
<v-btn class="screen-btn-primary" color="primary-button" variant="flat" prepend-icon="mdi-plus"
|
|
19
|
+
text="Add checklist" @click="openEditor()" />
|
|
20
|
+
</template>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<v-progress-linear v-if="loading" indeterminate class="mb-2" />
|
|
24
|
+
<p v-else-if="!templates.length" class="text-body-2 text-medium-emphasis">
|
|
25
|
+
No checklists yet. The site team will not see room handover until you add one.
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<AppCard v-for="t in templates" :key="t._id" class="mb-3 pa-4">
|
|
29
|
+
<div class="d-flex align-start ga-2">
|
|
30
|
+
<div class="flex-grow-1">
|
|
31
|
+
<div class="text-subtitle-1 font-weight-bold">{{ t.title }}</div>
|
|
32
|
+
<div v-if="t.description" class="text-body-2 text-medium-emphasis">{{ t.description }}</div>
|
|
33
|
+
<div class="text-caption text-medium-emphasis">{{ t.items.length }} item{{ t.items.length === 1 ? "" : "s" }}</div>
|
|
34
|
+
</div>
|
|
35
|
+
<template v-if="canEdit">
|
|
36
|
+
<v-btn icon="mdi-pencil" variant="text" size="small" aria-label="Edit" @click="openEditor(t)" />
|
|
37
|
+
<v-btn icon="mdi-delete" variant="text" size="small" aria-label="Remove" @click="remove(t)" />
|
|
38
|
+
</template>
|
|
39
|
+
</div>
|
|
40
|
+
</AppCard>
|
|
41
|
+
|
|
42
|
+
<v-dialog v-model="editor.show" max-width="760" scrollable>
|
|
43
|
+
<v-card>
|
|
44
|
+
<v-card-title>{{ editor.id ? "Edit checklist" : "New checklist" }}</v-card-title>
|
|
45
|
+
<v-card-text>
|
|
46
|
+
<v-form ref="form">
|
|
47
|
+
<v-text-field v-model.trim="editor.title" label="Room or inspection name" placeholder="e.g. Function room 1"
|
|
48
|
+
:rules="[required]" density="comfortable" variant="outlined" />
|
|
49
|
+
<v-textarea v-model.trim="editor.description" label="Instructions for the guard (optional)" rows="2"
|
|
50
|
+
auto-grow density="comfortable" variant="outlined" />
|
|
51
|
+
|
|
52
|
+
<div class="text-subtitle-2 mb-2">Items to check</div>
|
|
53
|
+
<div v-for="(item, i) in editor.items" :key="i" class="handover-item mb-3 pa-3">
|
|
54
|
+
<v-row dense>
|
|
55
|
+
<v-col cols="12" sm="6">
|
|
56
|
+
<v-text-field v-model.trim="item.label" label="Description" :rules="[required]" density="compact"
|
|
57
|
+
variant="outlined" hide-details="auto" />
|
|
58
|
+
</v-col>
|
|
59
|
+
<v-col cols="4" sm="2">
|
|
60
|
+
<v-text-field v-model.number="item.quantity" label="Qty" type="number" min="0" density="compact"
|
|
61
|
+
variant="outlined" hide-details />
|
|
62
|
+
</v-col>
|
|
63
|
+
<v-col cols="8" sm="4">
|
|
64
|
+
<v-text-field v-model.trim="item.tagNo" label="Serial / tag no." placeholder="e.g. Chair 1-8"
|
|
65
|
+
density="compact" variant="outlined" hide-details />
|
|
66
|
+
</v-col>
|
|
67
|
+
</v-row>
|
|
68
|
+
<div class="d-flex align-center ga-2 mt-2">
|
|
69
|
+
<v-img v-if="item.referencePhotoId" :src="getFileUrl(item.referencePhotoId)" width="56" height="56"
|
|
70
|
+
max-width="56" cover class="rounded" />
|
|
71
|
+
<v-btn size="small" variant="text" prepend-icon="mdi-camera" :loading="uploading === i"
|
|
72
|
+
:text="item.referencePhotoId ? 'Change reference photo' : 'Add reference photo'"
|
|
73
|
+
@click="pickPhoto(i)" />
|
|
74
|
+
<v-btn v-if="item.referencePhotoId" size="small" variant="text" text="Remove photo"
|
|
75
|
+
@click="item.referencePhotoId = null" />
|
|
76
|
+
<v-spacer />
|
|
77
|
+
<v-btn icon="mdi-close" size="small" variant="text" aria-label="Remove item"
|
|
78
|
+
:disabled="editor.items.length === 1" @click="editor.items.splice(i, 1)" />
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<v-btn variant="text" prepend-icon="mdi-plus" text="Add item" @click="editor.items.push(blankItem())" />
|
|
82
|
+
</v-form>
|
|
83
|
+
<input ref="fileInput" type="file" accept="image/*" hidden @change="uploadPhoto" />
|
|
84
|
+
</v-card-text>
|
|
85
|
+
<v-card-actions>
|
|
86
|
+
<v-spacer />
|
|
87
|
+
<v-btn text="Cancel" @click="editor.show = false" />
|
|
88
|
+
<v-btn class="screen-btn-primary" color="primary-button" variant="flat" text="Save" :loading="saving"
|
|
89
|
+
@click="save" />
|
|
90
|
+
</v-card-actions>
|
|
91
|
+
</v-card>
|
|
92
|
+
</v-dialog>
|
|
93
|
+
|
|
94
|
+
<Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<script setup lang="ts">
|
|
99
|
+
import useFile from "../composables/useFile";
|
|
100
|
+
import useHandoverChecklist, {
|
|
101
|
+
HANDOVER_STARTERS,
|
|
102
|
+
type THandoverItem,
|
|
103
|
+
type THandoverTemplate,
|
|
104
|
+
} from "../composables/useHandoverChecklist";
|
|
105
|
+
|
|
106
|
+
const props = defineProps({
|
|
107
|
+
site: { type: String, required: true },
|
|
108
|
+
canEdit: { type: Boolean, default: false },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const { getTemplates, addTemplate, updateTemplate, deleteTemplate } = useHandoverChecklist();
|
|
112
|
+
const { addFile, getFileUrl } = useFile();
|
|
113
|
+
|
|
114
|
+
const templates = ref<THandoverTemplate[]>([]);
|
|
115
|
+
const loading = ref(false);
|
|
116
|
+
const saving = ref(false);
|
|
117
|
+
const uploading = ref<number | null>(null);
|
|
118
|
+
const form = ref<any>(null);
|
|
119
|
+
const fileInput = ref<HTMLInputElement | null>(null);
|
|
120
|
+
const toast = reactive({ show: false, message: "", color: "" });
|
|
121
|
+
const required = (v: string) => !!v || "Required";
|
|
122
|
+
const blankItem = (): THandoverItem => ({ label: "", quantity: null, tagNo: "", referencePhotoId: null });
|
|
123
|
+
|
|
124
|
+
const editor = reactive({
|
|
125
|
+
show: false,
|
|
126
|
+
id: "" as string | undefined,
|
|
127
|
+
title: "",
|
|
128
|
+
description: "",
|
|
129
|
+
items: [] as THandoverItem[],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function notify(message: string, color = "success") {
|
|
133
|
+
Object.assign(toast, { show: true, message, color });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function load() {
|
|
137
|
+
loading.value = true;
|
|
138
|
+
try {
|
|
139
|
+
templates.value = (await getTemplates(props.site)) ?? [];
|
|
140
|
+
} catch (e: any) {
|
|
141
|
+
notify(e?.data?.message || "Could not load the checklists.", "error");
|
|
142
|
+
} finally {
|
|
143
|
+
loading.value = false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A stored checklist to edit, a starter to copy, or nothing for a blank one. */
|
|
148
|
+
function openEditor(t?: THandoverTemplate) {
|
|
149
|
+
editor.id = t?._id;
|
|
150
|
+
editor.title = t?.title ?? "";
|
|
151
|
+
editor.description = t?.description ?? "";
|
|
152
|
+
editor.items = t?.items?.length ? t.items.map((i) => ({ ...blankItem(), ...i })) : [blankItem()];
|
|
153
|
+
editor.show = true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let photoIndex = -1;
|
|
157
|
+
function pickPhoto(i: number) {
|
|
158
|
+
photoIndex = i;
|
|
159
|
+
fileInput.value?.click();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function uploadPhoto(e: Event) {
|
|
163
|
+
const input = e.target as HTMLInputElement;
|
|
164
|
+
const file = input.files?.[0];
|
|
165
|
+
input.value = "";
|
|
166
|
+
if (!file || photoIndex < 0) return;
|
|
167
|
+
uploading.value = photoIndex;
|
|
168
|
+
try {
|
|
169
|
+
const res = await addFile(file);
|
|
170
|
+
editor.items[photoIndex].referencePhotoId = res?.id ?? null;
|
|
171
|
+
} catch (err: any) {
|
|
172
|
+
notify(err?.data?.message || "Could not upload the photo.", "error");
|
|
173
|
+
} finally {
|
|
174
|
+
uploading.value = null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function save() {
|
|
179
|
+
const { valid } = await form.value.validate();
|
|
180
|
+
if (!valid) return;
|
|
181
|
+
const body: THandoverTemplate = {
|
|
182
|
+
title: editor.title,
|
|
183
|
+
description: editor.description || null,
|
|
184
|
+
items: editor.items.map((i) => ({
|
|
185
|
+
label: i.label,
|
|
186
|
+
quantity: typeof i.quantity === "number" && !Number.isNaN(i.quantity) ? i.quantity : null,
|
|
187
|
+
tagNo: i.tagNo || null,
|
|
188
|
+
referencePhotoId: i.referencePhotoId || null,
|
|
189
|
+
})),
|
|
190
|
+
};
|
|
191
|
+
saving.value = true;
|
|
192
|
+
try {
|
|
193
|
+
if (editor.id) await updateTemplate(editor.id, body);
|
|
194
|
+
else await addTemplate(props.site, body);
|
|
195
|
+
editor.show = false;
|
|
196
|
+
notify("Checklist saved.");
|
|
197
|
+
await load();
|
|
198
|
+
} catch (err: any) {
|
|
199
|
+
notify(err?.data?.message || "Could not save the checklist.", "error");
|
|
200
|
+
} finally {
|
|
201
|
+
saving.value = false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function remove(t: THandoverTemplate) {
|
|
206
|
+
if (!t._id || !window.confirm(`Remove "${t.title}"? Records already submitted are kept.`)) return;
|
|
207
|
+
try {
|
|
208
|
+
await deleteTemplate(t._id);
|
|
209
|
+
notify("Checklist removed.");
|
|
210
|
+
await load();
|
|
211
|
+
} catch (err: any) {
|
|
212
|
+
notify(err?.data?.message || "Could not remove the checklist.", "error");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
watch(() => props.site, load, { immediate: true });
|
|
217
|
+
</script>
|
|
218
|
+
|
|
219
|
+
<style scoped>
|
|
220
|
+
.handover-item {
|
|
221
|
+
border: 1px solid var(--border, #e0e0e0);
|
|
222
|
+
border-radius: 8px;
|
|
223
|
+
}
|
|
224
|
+
</style>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div data-test="incident-report-emails">
|
|
3
|
+
<p class="text-body-2 mb-3">For people who do not use the app, e.g. your HQ or insurer.</p>
|
|
4
|
+
<v-combobox
|
|
5
|
+
v-model="emails"
|
|
6
|
+
label="Also email every incident report to"
|
|
7
|
+
placeholder="Type an email address and press Enter"
|
|
8
|
+
multiple
|
|
9
|
+
chips
|
|
10
|
+
closable-chips
|
|
11
|
+
hide-no-data
|
|
12
|
+
:readonly="readOnly"
|
|
13
|
+
:rules="[validRule]"
|
|
14
|
+
:hint="`Up to ${MAX} addresses.`"
|
|
15
|
+
persistent-hint
|
|
16
|
+
density="comfortable"
|
|
17
|
+
variant="outlined"
|
|
18
|
+
/>
|
|
19
|
+
<v-btn
|
|
20
|
+
v-if="!readOnly"
|
|
21
|
+
class="screen-btn-primary mt-4"
|
|
22
|
+
color="primary-button"
|
|
23
|
+
variant="flat"
|
|
24
|
+
text="Save"
|
|
25
|
+
:loading="saving"
|
|
26
|
+
:disabled="!loaded || validRule(emails) !== true"
|
|
27
|
+
@click="save"
|
|
28
|
+
/>
|
|
29
|
+
<Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<script setup lang="ts">
|
|
34
|
+
import useSiteFeatureSettings from "../composables/useSiteFeatureSettings";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Site setting `metadata.incidentReportEmails` (core site-feature-settings).
|
|
38
|
+
* Empty = today: only app users who switched on incident report emails get one.
|
|
39
|
+
*/
|
|
40
|
+
const props = defineProps({
|
|
41
|
+
siteId: { type: String, required: true },
|
|
42
|
+
saved: { type: Array as PropType<string[]>, default: () => [] },
|
|
43
|
+
readOnly: { type: Boolean, default: false },
|
|
44
|
+
/** The site has been read; Save stays off until then. */
|
|
45
|
+
loaded: { type: Boolean, default: false },
|
|
46
|
+
});
|
|
47
|
+
const emit = defineEmits(["saved"]);
|
|
48
|
+
|
|
49
|
+
const MAX = 10;
|
|
50
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
51
|
+
|
|
52
|
+
const emails = ref<string[]>([]);
|
|
53
|
+
watch(
|
|
54
|
+
() => props.saved,
|
|
55
|
+
(v) => (emails.value = Array.isArray(v) ? [...v] : []),
|
|
56
|
+
{ immediate: true },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
function validRule(list: string[]) {
|
|
60
|
+
const clean = (list ?? []).map((e) => String(e).trim());
|
|
61
|
+
if (clean.length > MAX) return `Up to ${MAX} addresses.`;
|
|
62
|
+
const bad = clean.find((e) => !EMAIL.test(e));
|
|
63
|
+
return bad ? `"${bad}" is not a valid email address.` : true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { saveSiteFeature } = useSiteFeatureSettings();
|
|
67
|
+
const saving = ref(false);
|
|
68
|
+
const toast = reactive({ show: false, message: "", color: "" });
|
|
69
|
+
|
|
70
|
+
async function save() {
|
|
71
|
+
if (!props.loaded) return;
|
|
72
|
+
saving.value = true;
|
|
73
|
+
try {
|
|
74
|
+
const value = [...new Set(emails.value.map((e) => String(e).trim().toLowerCase()))];
|
|
75
|
+
await saveSiteFeature(props.siteId, "incidentReportEmails", value);
|
|
76
|
+
Object.assign(toast, { show: true, message: "Incident report emails saved.", color: "success" });
|
|
77
|
+
emit("saved");
|
|
78
|
+
} catch (e: any) {
|
|
79
|
+
Object.assign(toast, {
|
|
80
|
+
show: true,
|
|
81
|
+
message: e?.data?.message || "Could not save the incident report emails.",
|
|
82
|
+
color: "error",
|
|
83
|
+
});
|
|
84
|
+
} finally {
|
|
85
|
+
saving.value = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
</script>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Shown only when the site has key presses. Tells the list whether it
|
|
3
|
+
has any (`available`), so the list can add its Key press column. -->
|
|
4
|
+
<v-select
|
|
5
|
+
v-if="cabinets.length"
|
|
6
|
+
:model-value="modelValue"
|
|
7
|
+
:items="items"
|
|
8
|
+
item-title="name"
|
|
9
|
+
item-value="id"
|
|
10
|
+
label="Filter by key press"
|
|
11
|
+
multiple
|
|
12
|
+
clearable
|
|
13
|
+
hide-details
|
|
14
|
+
density="compact"
|
|
15
|
+
menu-icon="mdi-chevron-down"
|
|
16
|
+
style="max-width: 220px; min-width: 150px"
|
|
17
|
+
data-test="key-press-filter"
|
|
18
|
+
@update:model-value="(v: string[] | null) => emit('update:modelValue', v ?? [])"
|
|
19
|
+
/>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<script setup lang="ts">
|
|
23
|
+
import type { PropType } from "vue";
|
|
24
|
+
import useKeyPresses from "../composables/useKeyPresses";
|
|
25
|
+
|
|
26
|
+
const props = defineProps({
|
|
27
|
+
site: { type: String, required: true },
|
|
28
|
+
modelValue: { type: Array as PropType<string[]>, default: () => [] },
|
|
29
|
+
});
|
|
30
|
+
const emit = defineEmits<{
|
|
31
|
+
(e: "update:modelValue", value: string[]): void;
|
|
32
|
+
(e: "update:available", value: boolean): void;
|
|
33
|
+
}>();
|
|
34
|
+
|
|
35
|
+
const { cabinets } = useKeyPresses(() => props.site);
|
|
36
|
+
// "" = keys kept in no named press.
|
|
37
|
+
const items = computed(() => [...cabinets.value, { id: "", name: "No key press" }]);
|
|
38
|
+
watch(
|
|
39
|
+
() => cabinets.value.length > 0,
|
|
40
|
+
(has) => emit("update:available", has),
|
|
41
|
+
{ immediate: true },
|
|
42
|
+
);
|
|
43
|
+
</script>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span>{{ nameOf(id) }}</span>
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts">
|
|
6
|
+
import useKeyPresses from "../composables/useKeyPresses";
|
|
7
|
+
|
|
8
|
+
// The key's press name in a list cell. One site read per page, shared.
|
|
9
|
+
const props = defineProps({
|
|
10
|
+
site: { type: String, required: true },
|
|
11
|
+
id: { type: String, default: "" },
|
|
12
|
+
});
|
|
13
|
+
const { nameOf } = useKeyPresses(() => props.site);
|
|
14
|
+
</script>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Shown only when the site has key presses; otherwise nothing changes. -->
|
|
3
|
+
<div v-if="cabinets.length">
|
|
4
|
+
<div class="text-subtitle-1 text-medium-emphasis">Key press</div>
|
|
5
|
+
<v-select
|
|
6
|
+
:model-value="modelValue || null"
|
|
7
|
+
:items="cabinets"
|
|
8
|
+
item-title="name"
|
|
9
|
+
item-value="id"
|
|
10
|
+
placeholder="Choose a key press"
|
|
11
|
+
density="comfortable"
|
|
12
|
+
hint="Where this key is kept."
|
|
13
|
+
persistent-hint
|
|
14
|
+
clearable
|
|
15
|
+
data-test="key-press-select"
|
|
16
|
+
@update:model-value="(v: string | null) => emit('update:modelValue', v ?? '')"
|
|
17
|
+
/>
|
|
18
|
+
</div>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<script setup lang="ts">
|
|
22
|
+
import useKeyPresses from "../composables/useKeyPresses";
|
|
23
|
+
|
|
24
|
+
const props = defineProps({
|
|
25
|
+
site: { type: String, required: true },
|
|
26
|
+
modelValue: { type: String, default: "" },
|
|
27
|
+
});
|
|
28
|
+
const emit = defineEmits<{ (e: "update:modelValue", value: string): void }>();
|
|
29
|
+
|
|
30
|
+
const { cabinets } = useKeyPresses(() => props.site);
|
|
31
|
+
</script>
|
|
@@ -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 & 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>
|