@7365admin1/layer-common 4.96.0 → 4.97.1-staging.500
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 +10 -4
- package/assets/css/primitives.css +57 -0
- package/assets/css/screens.css +8 -4
- package/components/AccessCardDetailsDialog.vue +59 -6
- package/components/AccessCardPreviewDialog.vue +371 -53
- package/components/AppSelect.vue +143 -4
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +424 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +2239 -276
- package/components/InvitationClientForm.vue +19 -1
- package/components/Nfc/NFCPatrolRouteForm.vue +109 -58
- package/components/Nfc/NFCPatrolRouteMain.vue +3 -18
- package/components/Nfc/PatrolReport/PatrolActivityTable.vue +0 -8
- package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +1 -1
- package/components/ServiceProviderMain.vue +48 -0
- package/components/SiteProviderChange.vue +371 -0
- package/components/TableMain.vue +22 -7
- package/components/VehicleQrStickerDialog.vue +64 -4
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +267 -5
- package/composables/useAccessManagement.ts +17 -1
- package/composables/useCustomerSite.ts +29 -0
- package/composables/useDocument.ts +7 -0
- package/composables/useFacility.ts +11 -0
- package/composables/useHidAmico.ts +165 -0
- package/composables/useHidNavigation.ts +25 -1
- package/composables/useHidReaderSelection.ts +50 -0
- package/composables/useMember.ts +12 -0
- package/composables/useNFCPatrolReport.ts +36 -32
- package/composables/useNFCPatrolReportFilters.ts +21 -3
- package/composables/useNFCPatrolRoute.ts +10 -3
- package/composables/usePeople.ts +19 -0
- package/composables/useServiceProvider.ts +45 -2
- package/composables/useSiteCategory.ts +45 -0
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/nfc-patrol-report.ts +0 -2
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/types/site.d.ts +7 -1
- package/utils/hid-enrolment-subject.ts +344 -0
- package/utils/hid-permission-assignments.ts +198 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/nfc-patrol-time.ts +99 -0
- package/utils/occupancy-role.ts +109 -0
- package/utils/service-type.ts +30 -0
- package/utils/vehicle-qr-design.ts +304 -20
- package/utils/vehicle-qr-sticker.ts +126 -20
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!--
|
|
3
|
+
Change provider at a site / change site owner (owner, Harris, 2026-10-05).
|
|
4
|
+
|
|
5
|
+
One dialog to plan the change - the server's preview says, in plain words,
|
|
6
|
+
exactly what will happen before anything does - and one to see the site's
|
|
7
|
+
changes, undo one within 14 days, or cancel one that is still planned.
|
|
8
|
+
Who may do what is decided by the server; a refusal is shown as it says it.
|
|
9
|
+
-->
|
|
10
|
+
<v-dialog v-model="changeOpen" max-width="620" persistent>
|
|
11
|
+
<v-card class="screen-modal" data-test="spc-dialog">
|
|
12
|
+
<v-card-title class="d-flex align-center">
|
|
13
|
+
{{ kind === "owner" ? "Change site owner" : `Change or end ${shownFrom}` }}
|
|
14
|
+
<v-spacer />
|
|
15
|
+
<v-btn variant="text" icon="mdi-close" class="screen-modal__close" aria-label="Close" :disabled="saving" @click="changeOpen = false" />
|
|
16
|
+
</v-card-title>
|
|
17
|
+
<v-card-text class="screen-modal__body">
|
|
18
|
+
<p class="spc-intro">
|
|
19
|
+
<template v-if="kind === 'owner'">
|
|
20
|
+
{{ siteName || "This site" }} moves to a new owner, who takes charge of all of its records.
|
|
21
|
+
Every service provider at the site keeps working as it is. Only the new owner must already be working at the site
|
|
22
|
+
(invite them first; Seven365 can pick any company).
|
|
23
|
+
</template>
|
|
24
|
+
<template v-else>
|
|
25
|
+
Only {{ preview?.service || "this service" }} at {{ siteName || "this site" }} changes. Other services at the site,
|
|
26
|
+
and {{ shownFrom }}'s other sites, are not touched.
|
|
27
|
+
</template>
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<label class="spc-label" for="spc-to">{{ kind === "owner" ? "New owner" : "New company (optional)" }}</label>
|
|
31
|
+
<v-select
|
|
32
|
+
id="spc-to"
|
|
33
|
+
v-model="to"
|
|
34
|
+
:items="toItems"
|
|
35
|
+
item-title="title"
|
|
36
|
+
item-value="value"
|
|
37
|
+
density="comfortable"
|
|
38
|
+
variant="outlined"
|
|
39
|
+
class="filter-field"
|
|
40
|
+
:disabled="saving"
|
|
41
|
+
:no-data-text="kind === 'owner' ? 'Invite the new owner to the site first.' : 'Invite the new company to the site first.'"
|
|
42
|
+
data-test="spc-to"
|
|
43
|
+
@update:model-value="refresh"
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
<div class="spc-label">When</div>
|
|
47
|
+
<v-radio-group v-model="when" inline hide-details :disabled="saving" @update:model-value="refresh">
|
|
48
|
+
<v-radio label="Straight away" value="now" />
|
|
49
|
+
<v-radio label="On a date" value="date" />
|
|
50
|
+
</v-radio-group>
|
|
51
|
+
<input
|
|
52
|
+
v-if="when === 'date'"
|
|
53
|
+
v-model="endDate"
|
|
54
|
+
type="date"
|
|
55
|
+
class="spc-date"
|
|
56
|
+
:min="tomorrow"
|
|
57
|
+
aria-label="Change date"
|
|
58
|
+
data-test="spc-date"
|
|
59
|
+
@change="refresh"
|
|
60
|
+
/>
|
|
61
|
+
<div v-if="when === 'date'" class="spc-sub">
|
|
62
|
+
Until then both companies work at the site. The change happens at the start of that day.
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div class="spc-preview" data-test="spc-preview">
|
|
66
|
+
<div class="spc-preview__title">What will happen</div>
|
|
67
|
+
<div v-if="loading" class="spc-sub">Checking...</div>
|
|
68
|
+
<div v-else-if="error" class="spc-error">{{ error }}</div>
|
|
69
|
+
<template v-else-if="preview">
|
|
70
|
+
<div v-if="!preview.allowed" class="spc-error">{{ preview.reason }}</div>
|
|
71
|
+
<ul class="spc-list">
|
|
72
|
+
<li v-for="line in preview.sentences" :key="line">{{ line }}</li>
|
|
73
|
+
</ul>
|
|
74
|
+
</template>
|
|
75
|
+
</div>
|
|
76
|
+
</v-card-text>
|
|
77
|
+
<div class="screen-modal__footer">
|
|
78
|
+
<v-btn class="screen-btn-ghost" variant="outlined" :disabled="saving" @click="changeOpen = false">Cancel</v-btn>
|
|
79
|
+
<v-btn
|
|
80
|
+
class="screen-btn-primary"
|
|
81
|
+
variant="flat"
|
|
82
|
+
:loading="saving"
|
|
83
|
+
:disabled="!canConfirm"
|
|
84
|
+
data-test="spc-confirm"
|
|
85
|
+
@click="confirm"
|
|
86
|
+
>
|
|
87
|
+
{{ when === "date" ? "Plan this change" : kind === "owner" ? "Change owner" : to ? "Change provider" : "End provider" }}
|
|
88
|
+
</v-btn>
|
|
89
|
+
</div>
|
|
90
|
+
</v-card>
|
|
91
|
+
</v-dialog>
|
|
92
|
+
|
|
93
|
+
<v-dialog v-model="historyOpen" max-width="720">
|
|
94
|
+
<v-card class="screen-modal" data-test="spc-history">
|
|
95
|
+
<v-card-title class="d-flex align-center">
|
|
96
|
+
Provider changes at {{ siteName || "this site" }}
|
|
97
|
+
<v-spacer />
|
|
98
|
+
<v-btn
|
|
99
|
+
v-if="ownerOrgId"
|
|
100
|
+
variant="outlined"
|
|
101
|
+
class="screen-btn-ghost mr-2"
|
|
102
|
+
data-test="spc-change-owner"
|
|
103
|
+
@click="openOwnerChange"
|
|
104
|
+
>
|
|
105
|
+
Change owner
|
|
106
|
+
</v-btn>
|
|
107
|
+
<v-btn variant="text" icon="mdi-close" class="screen-modal__close" aria-label="Close" @click="historyOpen = false" />
|
|
108
|
+
</v-card-title>
|
|
109
|
+
<v-card-text class="screen-modal__body">
|
|
110
|
+
<div v-if="historyLoading" class="spc-sub">Loading...</div>
|
|
111
|
+
<div v-else-if="historyError" class="spc-error">{{ historyError }}</div>
|
|
112
|
+
<div v-else-if="!history.length" class="spc-sub">No changes yet.</div>
|
|
113
|
+
<div v-for="c in history" :key="c._id" class="spc-item" data-test="spc-item">
|
|
114
|
+
<div class="d-flex align-center">
|
|
115
|
+
<strong class="spc-item__title">{{ title(c) }}</strong>
|
|
116
|
+
<v-spacer />
|
|
117
|
+
<span class="spc-chip" :class="`spc-chip--${c.status}`">{{ statusText(c) }}</span>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="spc-sub">{{ whenText(c) }}</div>
|
|
120
|
+
<ul class="spc-list">
|
|
121
|
+
<li v-for="line in c.sentences || []" :key="line">{{ line }}</li>
|
|
122
|
+
</ul>
|
|
123
|
+
<div v-if="c.status === 'failed'" class="spc-error">It could not be done: {{ c.failure }}</div>
|
|
124
|
+
<div class="d-flex justify-end ga-2">
|
|
125
|
+
<v-btn v-if="c.canCancel" size="small" variant="outlined" :loading="busy === c._id" data-test="spc-cancel" @click="cancelChange(c)">
|
|
126
|
+
Cancel this plan
|
|
127
|
+
</v-btn>
|
|
128
|
+
<template v-if="c.canUndo">
|
|
129
|
+
<span v-if="confirmUndo === c._id" class="spc-sub align-self-center">Everything this change did is put back.</span>
|
|
130
|
+
<v-btn v-if="confirmUndo === c._id" size="small" variant="text" @click="confirmUndo = ''">Keep it</v-btn>
|
|
131
|
+
<v-btn
|
|
132
|
+
size="small"
|
|
133
|
+
:variant="confirmUndo === c._id ? 'flat' : 'outlined'"
|
|
134
|
+
:color="confirmUndo === c._id ? 'error' : undefined"
|
|
135
|
+
:loading="busy === c._id"
|
|
136
|
+
data-test="spc-undo"
|
|
137
|
+
@click="confirmUndo === c._id ? undoChange(c) : (confirmUndo = c._id)"
|
|
138
|
+
>
|
|
139
|
+
{{ confirmUndo === c._id ? "Yes, undo" : `Undo (until ${day(c.undoUntil)})` }}
|
|
140
|
+
</v-btn>
|
|
141
|
+
</template>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</v-card-text>
|
|
145
|
+
</v-card>
|
|
146
|
+
</v-dialog>
|
|
147
|
+
|
|
148
|
+
<Snackbar v-model="snack" :text="snackText" :color="snackColor" />
|
|
149
|
+
</template>
|
|
150
|
+
|
|
151
|
+
<script lang="ts" setup>
|
|
152
|
+
const props = defineProps({
|
|
153
|
+
siteId: { type: String, required: true },
|
|
154
|
+
siteName: { type: String, default: "" },
|
|
155
|
+
/** The site's owner organisation; offers "Change owner" in the history dialog. */
|
|
156
|
+
ownerOrgId: { type: String, default: "" },
|
|
157
|
+
});
|
|
158
|
+
const emit = defineEmits<{ (e: "changed"): void }>();
|
|
159
|
+
|
|
160
|
+
const { previewProviderChange, applyProviderChange, listProviderChanges, undoProviderChange, cancelProviderChange } = useCustomerSite();
|
|
161
|
+
|
|
162
|
+
const changeOpen = ref(false);
|
|
163
|
+
const kind = ref<"provider" | "owner">("provider");
|
|
164
|
+
const from = ref("");
|
|
165
|
+
const fromName = ref("");
|
|
166
|
+
const to = ref<string | null>(null);
|
|
167
|
+
const when = ref<"now" | "date">("now");
|
|
168
|
+
const endDate = ref("");
|
|
169
|
+
const preview = ref<Record<string, any> | null>(null);
|
|
170
|
+
const loading = ref(false);
|
|
171
|
+
const saving = ref(false);
|
|
172
|
+
const error = ref("");
|
|
173
|
+
|
|
174
|
+
const history = ref<Record<string, any>[]>([]);
|
|
175
|
+
const historyOpen = ref(false);
|
|
176
|
+
const historyLoading = ref(false);
|
|
177
|
+
const historyError = ref("");
|
|
178
|
+
const busy = ref("");
|
|
179
|
+
/** The change whose Undo was pressed once; the second press does it. */
|
|
180
|
+
const confirmUndo = ref("");
|
|
181
|
+
/** The company being changed, as the server names it once the preview is in. */
|
|
182
|
+
const shownFrom = computed(() => preview.value?.fromName || fromName.value);
|
|
183
|
+
|
|
184
|
+
const snack = ref(false);
|
|
185
|
+
const snackText = ref("");
|
|
186
|
+
const snackColor = ref("success");
|
|
187
|
+
|
|
188
|
+
const tomorrow = computed(() => new Date(Date.now() + 864e5).toISOString().slice(0, 10));
|
|
189
|
+
const replacements = ref<Array<{ org: string; name: string }>>([]);
|
|
190
|
+
const toItems = computed(() => [
|
|
191
|
+
...(kind.value === "provider" ? [{ title: "No new company yet - just end", value: null }] : []),
|
|
192
|
+
...replacements.value.map((r) => ({ title: r.name, value: r.org })),
|
|
193
|
+
]);
|
|
194
|
+
const canConfirm = computed(
|
|
195
|
+
() =>
|
|
196
|
+
!loading.value &&
|
|
197
|
+
!saving.value &&
|
|
198
|
+
Boolean(preview.value?.allowed) &&
|
|
199
|
+
(kind.value === "provider" || Boolean(to.value)) &&
|
|
200
|
+
(when.value === "now" || Boolean(endDate.value)),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
function message(e: any) {
|
|
204
|
+
return e?.data?.message || e?.message || "Something went wrong. Please try again.";
|
|
205
|
+
}
|
|
206
|
+
function say(text: string, color = "success") {
|
|
207
|
+
snackText.value = text;
|
|
208
|
+
snackColor.value = color;
|
|
209
|
+
snack.value = true;
|
|
210
|
+
}
|
|
211
|
+
function body() {
|
|
212
|
+
return {
|
|
213
|
+
site: props.siteId,
|
|
214
|
+
kind: kind.value,
|
|
215
|
+
from: from.value,
|
|
216
|
+
to: to.value || null,
|
|
217
|
+
endDate: when.value === "date" ? endDate.value || null : null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function refresh() {
|
|
222
|
+
if (!from.value) return;
|
|
223
|
+
loading.value = true;
|
|
224
|
+
error.value = "";
|
|
225
|
+
try {
|
|
226
|
+
preview.value = await previewProviderChange(body());
|
|
227
|
+
replacements.value = preview.value?.replacements ?? [];
|
|
228
|
+
} catch (e: any) {
|
|
229
|
+
preview.value = null;
|
|
230
|
+
error.value = message(e);
|
|
231
|
+
} finally {
|
|
232
|
+
loading.value = false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function reset() {
|
|
237
|
+
to.value = null;
|
|
238
|
+
when.value = "now";
|
|
239
|
+
endDate.value = "";
|
|
240
|
+
preview.value = null;
|
|
241
|
+
error.value = "";
|
|
242
|
+
replacements.value = [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function openChange(row: { from: string; fromName: string }) {
|
|
246
|
+
reset();
|
|
247
|
+
kind.value = "provider";
|
|
248
|
+
from.value = row.from;
|
|
249
|
+
fromName.value = row.fromName;
|
|
250
|
+
changeOpen.value = true;
|
|
251
|
+
refresh();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function openOwnerChange() {
|
|
255
|
+
reset();
|
|
256
|
+
kind.value = "owner";
|
|
257
|
+
from.value = props.ownerOrgId;
|
|
258
|
+
fromName.value = "the owner";
|
|
259
|
+
changeOpen.value = true;
|
|
260
|
+
refresh();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function confirm() {
|
|
264
|
+
saving.value = true;
|
|
265
|
+
try {
|
|
266
|
+
const res = await applyProviderChange(body());
|
|
267
|
+
changeOpen.value = false;
|
|
268
|
+
say(
|
|
269
|
+
res?.status === "scheduled"
|
|
270
|
+
? "The change is planned. You can cancel it until that day."
|
|
271
|
+
: res?.alreadyDone
|
|
272
|
+
? "This change was already done."
|
|
273
|
+
: "Done. You can undo it within 14 days from Provider changes.",
|
|
274
|
+
);
|
|
275
|
+
emit("changed");
|
|
276
|
+
if (historyOpen.value) loadHistory();
|
|
277
|
+
} catch (e: any) {
|
|
278
|
+
error.value = message(e);
|
|
279
|
+
} finally {
|
|
280
|
+
saving.value = false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function loadHistory() {
|
|
285
|
+
historyLoading.value = true;
|
|
286
|
+
historyError.value = "";
|
|
287
|
+
try {
|
|
288
|
+
history.value = (await listProviderChanges(props.siteId)) ?? [];
|
|
289
|
+
} catch (e: any) {
|
|
290
|
+
history.value = [];
|
|
291
|
+
historyError.value = message(e);
|
|
292
|
+
} finally {
|
|
293
|
+
historyLoading.value = false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function openHistory() {
|
|
298
|
+
historyOpen.value = true;
|
|
299
|
+
loadHistory();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function undoChange(c: Record<string, any>) {
|
|
303
|
+
confirmUndo.value = "";
|
|
304
|
+
busy.value = c._id;
|
|
305
|
+
try {
|
|
306
|
+
const res = await undoProviderChange(c._id);
|
|
307
|
+
say(
|
|
308
|
+
res?.doorAccessToReEnrol
|
|
309
|
+
? `Undone. ${res.doorAccessToReEnrol} face or card enrolment(s) on the door readers must be added again.`
|
|
310
|
+
: "Undone. Everything is back as it was.",
|
|
311
|
+
);
|
|
312
|
+
emit("changed");
|
|
313
|
+
await loadHistory();
|
|
314
|
+
} catch (e: any) {
|
|
315
|
+
say(message(e), "error");
|
|
316
|
+
} finally {
|
|
317
|
+
busy.value = "";
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function cancelChange(c: Record<string, any>) {
|
|
322
|
+
busy.value = c._id;
|
|
323
|
+
try {
|
|
324
|
+
await cancelProviderChange(c._id);
|
|
325
|
+
say("The planned change is cancelled. Nothing changed.");
|
|
326
|
+
await loadHistory();
|
|
327
|
+
} catch (e: any) {
|
|
328
|
+
say(message(e), "error");
|
|
329
|
+
} finally {
|
|
330
|
+
busy.value = "";
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function title(c: Record<string, any>) {
|
|
335
|
+
if (c.kind === "owner") return `Owner: ${c.fromName} to ${c.toName}`;
|
|
336
|
+
return c.toName ? `${c.fromName} replaced by ${c.toName}` : `${c.fromName} ended`;
|
|
337
|
+
}
|
|
338
|
+
function day(v: unknown) {
|
|
339
|
+
if (!v) return "";
|
|
340
|
+
return new Date(String(v)).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
|
341
|
+
}
|
|
342
|
+
function statusText(c: Record<string, any>) {
|
|
343
|
+
return (
|
|
344
|
+
{ scheduled: "Planned", applying: "In progress", applied: "Done", undoing: "Undoing", undone: "Undone", cancelled: "Cancelled", failed: "Failed" } as Record<string, string>
|
|
345
|
+
)[c.status] ?? c.status;
|
|
346
|
+
}
|
|
347
|
+
function whenText(c: Record<string, any>) {
|
|
348
|
+
if (c.status === "scheduled") return `Planned for ${day(c.endAt)}`;
|
|
349
|
+
if (c.status === "undone") return `Done ${day(c.appliedAt)}, undone ${day(c.undoneAt)}`;
|
|
350
|
+
if (c.status === "cancelled") return `Cancelled ${day(c.cancelledAt)}`;
|
|
351
|
+
return c.appliedAt ? `Done ${day(c.appliedAt)}` : `Asked ${day(c.createdAt)}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
defineExpose({ openChange, openOwnerChange, openHistory });
|
|
355
|
+
</script>
|
|
356
|
+
|
|
357
|
+
<style scoped>
|
|
358
|
+
.spc-intro { margin: 0 0 12px; color: var(--muted, inherit); }
|
|
359
|
+
.spc-label { display: block; font-weight: 600; margin: 8px 0 4px; }
|
|
360
|
+
.spc-date { border: 1px solid var(--line, #ccc); border-radius: 6px; padding: 8px 10px; color: inherit; background: transparent; margin-top: 4px; }
|
|
361
|
+
.spc-sub { color: var(--muted, inherit); font-size: 13px; margin-top: 4px; }
|
|
362
|
+
.spc-error { color: var(--err, #b00020); margin: 4px 0; }
|
|
363
|
+
.spc-preview { margin-top: 16px; padding: 12px 14px; border: 1px solid var(--line, #ddd); border-radius: 8px; }
|
|
364
|
+
.spc-preview__title { font-weight: 600; margin-bottom: 6px; }
|
|
365
|
+
.spc-list { margin: 4px 0 0 18px; padding: 0; }
|
|
366
|
+
.spc-list li { margin: 3px 0; }
|
|
367
|
+
.spc-item { padding: 12px 0; border-bottom: 1px solid var(--line, #eee); }
|
|
368
|
+
.spc-item__title { font-weight: 600; }
|
|
369
|
+
.spc-chip { font-size: 12px; padding: 2px 10px; border-radius: 999px; border: 1px solid currentColor; }
|
|
370
|
+
.spc-chip--failed { color: var(--err, #b00020); }
|
|
371
|
+
</style>
|
package/components/TableMain.vue
CHANGED
|
@@ -59,17 +59,32 @@
|
|
|
59
59
|
>
|
|
60
60
|
{{ createLabel }}
|
|
61
61
|
</v-btn>
|
|
62
|
-
|
|
62
|
+
<!--
|
|
63
|
+
The design's search field. This was a `v-text-field` with only a
|
|
64
|
+
`max-width`, so inside the actions row it shrank to its minimum
|
|
65
|
+
and showed "Se" and a magnifier, at a different height and
|
|
66
|
+
border from the button beside it. Same `search` model; clearing
|
|
67
|
+
now sets "" instead of Vuetify's null.
|
|
68
|
+
-->
|
|
69
|
+
<AppField
|
|
63
70
|
v-if="canSearch"
|
|
64
71
|
v-model="searchInput"
|
|
65
|
-
|
|
72
|
+
search
|
|
66
73
|
placeholder="Search"
|
|
67
|
-
|
|
68
|
-
max-width="300"
|
|
69
|
-
append-inner-icon="mdi-magnify"
|
|
70
|
-
hide-details
|
|
74
|
+
aria-label="Search"
|
|
71
75
|
class="table-card__search"
|
|
72
|
-
|
|
76
|
+
>
|
|
77
|
+
<template v-if="searchInput" #trailing>
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
class="app-field__trailing"
|
|
81
|
+
aria-label="Clear search"
|
|
82
|
+
@click="searchInput = ''"
|
|
83
|
+
>
|
|
84
|
+
<v-icon icon="mdi-close" size="16" />
|
|
85
|
+
</button>
|
|
86
|
+
</template>
|
|
87
|
+
</AppField>
|
|
73
88
|
<!-- Ghost/secondary, as the design specifies for Scan QR Code. -->
|
|
74
89
|
<v-btn
|
|
75
90
|
v-if="canScanVisitorQRCode"
|
|
@@ -92,9 +92,6 @@
|
|
|
92
92
|
<v-select v-model="design.theme" :items="STICKER_THEMES" item-value="id" label="Colours" density="compact"
|
|
93
93
|
hide-details />
|
|
94
94
|
</v-col>
|
|
95
|
-
<v-col cols="12" sm="6" md="4" lg>
|
|
96
|
-
<v-select v-model="design.frame" :items="FRAME_OPTIONS" label="Sticker shape" density="compact" hide-details />
|
|
97
|
-
</v-col>
|
|
98
95
|
<v-col cols="12" sm="6" md="4" lg>
|
|
99
96
|
<v-select v-model="design.label" :items="LABEL_OPTIONS" label="Words under the plate" density="compact"
|
|
100
97
|
hide-details />
|
|
@@ -103,6 +100,26 @@
|
|
|
103
100
|
<v-btn variant="text" size="small" class="text-none" @click="resetDesign">Back to the standard look</v-btn>
|
|
104
101
|
</v-col>
|
|
105
102
|
</v-row>
|
|
103
|
+
<div class="text-body-2 mt-3 mb-1">Sticker shape</div>
|
|
104
|
+
<div class="shape-picker" role="radiogroup" aria-label="Sticker shape" data-test="vehicle-qr-shapes">
|
|
105
|
+
<button v-for="s in shapeThumbs" :key="s.value" type="button" role="radio" class="shape-option"
|
|
106
|
+
:class="{ 'shape-option--on': design.frame === s.value }" :aria-checked="design.frame === s.value"
|
|
107
|
+
:data-shape="s.value" @click="design.frame = s.value">
|
|
108
|
+
<img class="shape-thumb" :src="s.src" alt="" />
|
|
109
|
+
<span class="text-caption">{{ s.title }}</span>
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
<v-switch v-model="design.cutLines" label="Show cut lines" color="primary" density="compact" hide-details inset
|
|
113
|
+
class="mt-1" data-test="vehicle-qr-cut-lines" />
|
|
114
|
+
<v-alert type="info" variant="tonal" density="compact" class="mt-1" data-test="vehicle-qr-cut-guide">
|
|
115
|
+
<div class="font-weight-medium">{{ sheetSummary(design.frame) }}.</div>
|
|
116
|
+
<div>Print at 100% ("Actual size" in the print window), not "Fit to page", so the QR code stays full size.</div>
|
|
117
|
+
<div>
|
|
118
|
+
How to cut: first cut the sheet into pieces through the middle of the gaps (the small + marks show where),
|
|
119
|
+
then cut round each sticker {{ design.cutLines ? "on the light-grey line" : "along its edge" }} - with scissors
|
|
120
|
+
or a cutting machine. Cut off the strip with the unit and owner before pasting.
|
|
121
|
+
</div>
|
|
122
|
+
</v-alert>
|
|
106
123
|
<iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
|
|
107
124
|
</template>
|
|
108
125
|
</v-card-text>
|
|
@@ -136,6 +153,7 @@ import {
|
|
|
136
153
|
idsForRequest,
|
|
137
154
|
installerLine,
|
|
138
155
|
setIds,
|
|
156
|
+
sheetSummary,
|
|
139
157
|
stickerPlateRows,
|
|
140
158
|
stickerWarning,
|
|
141
159
|
type StickerItem,
|
|
@@ -145,8 +163,8 @@ import {
|
|
|
145
163
|
DEFAULT_STICKER_DESIGN,
|
|
146
164
|
DOT_OPTIONS,
|
|
147
165
|
EYE_OPTIONS,
|
|
148
|
-
FRAME_OPTIONS,
|
|
149
166
|
LABEL_OPTIONS,
|
|
167
|
+
SHAPE_OPTIONS,
|
|
150
168
|
QR_ECC,
|
|
151
169
|
STICKER_THEMES,
|
|
152
170
|
normalizeDesign,
|
|
@@ -206,6 +224,18 @@ function resetDesign() {
|
|
|
206
224
|
Object.assign(design, DEFAULT_STICKER_DESIGN);
|
|
207
225
|
}
|
|
208
226
|
|
|
227
|
+
// A small live picture of every shape in the current dots / corners / colours.
|
|
228
|
+
const sampleCode = QRCode.create("S365V:SamplePreviewToken0000", {
|
|
229
|
+
errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel,
|
|
230
|
+
}).modules;
|
|
231
|
+
const shapeThumbs = computed(() => {
|
|
232
|
+
const look = normalizeDesign(design);
|
|
233
|
+
return SHAPE_OPTIONS.map((o) => ({
|
|
234
|
+
...o,
|
|
235
|
+
src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(stickerFaceSvg(sampleCode, { ...look, frame: o.value, cutLines: false }))}`,
|
|
236
|
+
}));
|
|
237
|
+
});
|
|
238
|
+
|
|
209
239
|
const allOnPageTicked = computed(() => plates.value.length > 0 && plates.value.every((p) => selected.value.includes(p.id)));
|
|
210
240
|
const someOnPageTicked = computed(() => plates.value.some((p) => selected.value.includes(p.id)));
|
|
211
241
|
|
|
@@ -306,6 +336,36 @@ function print() {
|
|
|
306
336
|
</script>
|
|
307
337
|
|
|
308
338
|
<style scoped>
|
|
339
|
+
.shape-picker {
|
|
340
|
+
display: grid;
|
|
341
|
+
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
|
342
|
+
gap: 8px;
|
|
343
|
+
}
|
|
344
|
+
.shape-option {
|
|
345
|
+
display: flex;
|
|
346
|
+
flex-direction: column;
|
|
347
|
+
align-items: center;
|
|
348
|
+
gap: 2px;
|
|
349
|
+
padding: 6px 4px;
|
|
350
|
+
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
351
|
+
border-radius: 8px;
|
|
352
|
+
background: #f4f4f4;
|
|
353
|
+
cursor: pointer;
|
|
354
|
+
}
|
|
355
|
+
.shape-option--on {
|
|
356
|
+
border: 2px solid rgb(var(--v-theme-primary));
|
|
357
|
+
background: rgba(var(--v-theme-primary), 0.08);
|
|
358
|
+
}
|
|
359
|
+
.shape-option:focus-visible {
|
|
360
|
+
outline: 2px solid rgb(var(--v-theme-primary));
|
|
361
|
+
outline-offset: 2px;
|
|
362
|
+
}
|
|
363
|
+
.shape-thumb {
|
|
364
|
+
display: block;
|
|
365
|
+
width: 64px;
|
|
366
|
+
height: 64px;
|
|
367
|
+
object-fit: contain;
|
|
368
|
+
}
|
|
309
369
|
.sticker-preview {
|
|
310
370
|
width: 100%;
|
|
311
371
|
height: 460px;
|
|
@@ -943,8 +943,20 @@ const hidQrReaderId = computed(() => (
|
|
|
943
943
|
hidQrConfiguredReaderId.value || String(hidQrReader.value?._id || "")
|
|
944
944
|
));
|
|
945
945
|
|
|
946
|
+
/*
|
|
947
|
+
* Visitor QR passes are hidden at the manager's request (2026-09-23).
|
|
948
|
+
*
|
|
949
|
+
* A flag rather than a deletion: issuing, the preview dialog, printing and the
|
|
950
|
+
* regenerate handler below are all intact and untouched, so bringing the
|
|
951
|
+
* feature back is this one line. Every surface that offers a Visitor QR reads
|
|
952
|
+
* `hidQrCodePassAllowed`, which is why the switch belongs here and not in each
|
|
953
|
+
* of them.
|
|
954
|
+
*/
|
|
955
|
+
const HID_VISITOR_QR_ENABLED = false;
|
|
956
|
+
|
|
946
957
|
const hidQrCodePassAllowed = computed(() => {
|
|
947
958
|
return (
|
|
959
|
+
HID_VISITOR_QR_ENABLED &&
|
|
948
960
|
hidQrCodePassEnabled.value &&
|
|
949
961
|
["guest", "contractor", "walk-in"].includes(prop.type)
|
|
950
962
|
);
|