@7365admin1/layer-common 4.83.0 → 4.84.1-staging.481
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 +84 -0
- package/assets/css/screens.css +8 -4
- package/components/AccessCardDetailsDialog.vue +57 -4
- package/components/AccessCardPreviewDialog.vue +291 -50
- package/components/AppSelect.vue +143 -4
- package/components/AttendanceDetailsDialog.vue +48 -1
- package/components/AttendanceMain.vue +104 -10
- package/components/AttendanceProxyDialog.vue +391 -0
- package/components/AttendanceSettingsDialog.vue +53 -1
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/BulletinBoardView.vue +1 -1
- 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/InventoryMain.vue +11 -2
- package/components/InvitationClientForm.vue +19 -1
- package/components/MemberMain.vue +2 -1
- package/components/MemberServiceProviders.vue +43 -45
- package/components/MyAttendanceMain.vue +60 -2
- package/components/NotificationBell.vue +14 -2
- package/components/RolePermissionMain.vue +4 -2
- package/components/TableMain.vue +22 -7
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +279 -7
- package/composables/useAttendance.ts +53 -0
- package/composables/useAttendancePermission.ts +25 -0
- package/composables/useCleaningPermission.ts +5 -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/useLandscapingPermission.ts +5 -0
- package/composables/useMember.ts +12 -0
- package/composables/useNFCPatrolReportFilters.ts +21 -3
- package/composables/useNFCPatrolRoute.ts +8 -1
- package/composables/usePeople.ts +19 -0
- package/composables/usePestControlPermission.ts +5 -0
- package/composables/usePoolMaintenancePermission.ts +5 -0
- package/composables/useSecurityPermission.ts +5 -0
- package/composables/useServiceProvider.ts +45 -2
- package/composables/useSiteActions.ts +29 -0
- package/composables/useSiteCategory.ts +45 -0
- package/constants/permissions.ts +1 -0
- package/nuxt.config.ts +18 -0
- package/package.json +4 -2
- 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/plugins/API.ts +16 -0
- package/plugins/vuetify.ts +6 -0
- package/server/plugins/no-powered-by.ts +7 -0
- package/types/attendance.d.ts +13 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/types/site.d.ts +7 -1
- package/utils/access-suspended.ts +18 -0
- package/utils/attendance-proxy.ts +110 -0
- 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/notification-target.ts +43 -4
- package/utils/occupancy-role.ts +109 -0
- package/utils/role.ts +15 -2
- package/utils/sanitize-html.ts +16 -0
- package/utils/security-headers.ts +52 -0
- package/utils/service-type.ts +30 -0
- package/utils/site-actions.ts +18 -0
- package/utils/site-provider-members.ts +37 -0
|
@@ -149,6 +149,9 @@ const props = defineProps({
|
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
const movedFromEquipment = useRoute().query.moved === "equipment";
|
|
152
|
+
// A notification link (`?storeId=&tab=`, NotificationBell): open that store on that tab, once.
|
|
153
|
+
const linkQuery = useRoute().query;
|
|
154
|
+
const linked = { storeId: String(linkQuery.storeId ?? ""), tab: String(linkQuery.tab ?? "") };
|
|
152
155
|
|
|
153
156
|
const { getStores, createStore: apiCreateStore, getRequests, getReport } = useInventory();
|
|
154
157
|
const { getSites } = useSite();
|
|
@@ -227,7 +230,10 @@ async function loadStores() {
|
|
|
227
230
|
loadError.value = "";
|
|
228
231
|
try {
|
|
229
232
|
stores.value = (await getStores(props.siteId)) ?? [];
|
|
230
|
-
|
|
233
|
+
const fromLink = stores.value.find((s) => s._id === linked.storeId);
|
|
234
|
+
linked.storeId = "";
|
|
235
|
+
if (fromLink) storeId.value = fromLink._id;
|
|
236
|
+
else if (!stores.value.some((s) => s._id === storeId.value)) {
|
|
231
237
|
// A service provider working at a property store sees it first.
|
|
232
238
|
storeId.value = stores.value[0]?._id ?? "";
|
|
233
239
|
}
|
|
@@ -295,7 +301,10 @@ async function createStore() {
|
|
|
295
301
|
}
|
|
296
302
|
|
|
297
303
|
watch(storeId, () => {
|
|
298
|
-
tab
|
|
304
|
+
// The linked tab only when this person has it (tabs are hidden, never greyed).
|
|
305
|
+
const wanted = linked.tab;
|
|
306
|
+
linked.tab = "";
|
|
307
|
+
tab.value = wanted && tabs.value.some((t) => t.value === wanted) ? wanted : "stock";
|
|
299
308
|
refreshCounts();
|
|
300
309
|
probeCompanyUsage();
|
|
301
310
|
});
|
|
@@ -388,9 +388,27 @@ watch(
|
|
|
388
388
|
const roles = ref<any[]>([])
|
|
389
389
|
const { getRoles } = useRole()
|
|
390
390
|
|
|
391
|
+
/*
|
|
392
|
+
* `type` is the ROLE type, not the service the client subscribes to, and the
|
|
393
|
+
* only caller (`ClientMain.vue` "Invite Client") passes no `app` prop -- so
|
|
394
|
+
* this sent `type: ""` and the endpoint applied no type filter at all. The
|
|
395
|
+
* dropdown therefore listed every org-less role of every type, which includes
|
|
396
|
+
* the platform's OWN staff roles (`type: "admin"` -- the two rows the console's
|
|
397
|
+
* `/super-admin/role-permissions` screen edits). Offering a Seven365 console
|
|
398
|
+
* staff role on a CLIENT invitation is the defect.
|
|
399
|
+
*
|
|
400
|
+
* A client admin's role is `type: "organization"`, which is what the client
|
|
401
|
+
* roles screen (`org/[organization]/roles-permissions.vue`) already lists. This
|
|
402
|
+
* narrows to exactly that. It is a narrowing of this one picker only: no role
|
|
403
|
+
* changes, no permission changes, and the org-less `organization` roles that
|
|
404
|
+
* were already being picked are all still offered.
|
|
405
|
+
*
|
|
406
|
+
* Keying off the selected service instead would empty the dropdown -- no
|
|
407
|
+
* org-less role carries a service type -- so `props.app` is not the filter.
|
|
408
|
+
*/
|
|
391
409
|
const { data: roleData, refresh: refreshRoles } = await useLazyAsyncData(
|
|
392
410
|
"roles",
|
|
393
|
-
() => getRoles({ org: org.value?._id, type:
|
|
411
|
+
() => getRoles({ org: org.value?._id, type: "organization", limit: 50 }),
|
|
394
412
|
{ watch: [() => props.app] }
|
|
395
413
|
)
|
|
396
414
|
|
|
@@ -374,7 +374,7 @@
|
|
|
374
374
|
v-model="selectedRole"
|
|
375
375
|
:items="pickerRoles"
|
|
376
376
|
item-value="_id"
|
|
377
|
-
item-title="
|
|
377
|
+
:item-title="roleLabel"
|
|
378
378
|
density="comfortable"
|
|
379
379
|
:rules="[requiredRule]"
|
|
380
380
|
:error-messages="
|
|
@@ -427,6 +427,7 @@ import {
|
|
|
427
427
|
safeReturnTo,
|
|
428
428
|
limitedSites,
|
|
429
429
|
withCurrentRole,
|
|
430
|
+
roleLabel,
|
|
430
431
|
} from "../utils/role";
|
|
431
432
|
import { formatLastActive } from "../utils/last-active";
|
|
432
433
|
import { formatMemberDate, memberAccess, memberPerson } from "../utils/member-row";
|
|
@@ -22,21 +22,10 @@
|
|
|
22
22
|
</template>
|
|
23
23
|
</PageHeader>
|
|
24
24
|
|
|
25
|
-
<!--
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
:key="c.orgId"
|
|
30
|
-
type="button"
|
|
31
|
-
class="sp-chip"
|
|
32
|
-
:class="{ 'sp-chip--active': company === c.orgId }"
|
|
33
|
-
:aria-pressed="company === c.orgId"
|
|
34
|
-
@click="company = company === c.orgId ? '' : c.orgId"
|
|
35
|
-
>
|
|
36
|
-
<span class="sp-chip__name">{{ companyChipText(c.name, c.people) }}</span>
|
|
37
|
-
<span class="sp-chip__type">{{ c.typeLabel }}<template v-if="c.invitedVia"> · via {{ c.invitedVia }}</template></span>
|
|
38
|
-
</button>
|
|
39
|
-
</div>
|
|
25
|
+
<!-- Who is on the list at a glance; pick one company with the Company filter. -->
|
|
26
|
+
<p v-if="companies.length" class="sp-summary mb-3" data-test="sp-members-summary">
|
|
27
|
+
{{ summaryText }}
|
|
28
|
+
</p>
|
|
40
29
|
|
|
41
30
|
<AppCard class="table-card">
|
|
42
31
|
<CardToolbar :count="countText">
|
|
@@ -75,6 +64,25 @@
|
|
|
75
64
|
aria-label="Service"
|
|
76
65
|
data-test="sp-members-type"
|
|
77
66
|
/>
|
|
67
|
+
<v-autocomplete
|
|
68
|
+
v-model="company"
|
|
69
|
+
:items="companyItems"
|
|
70
|
+
item-title="title"
|
|
71
|
+
item-value="value"
|
|
72
|
+
density="compact"
|
|
73
|
+
variant="outlined"
|
|
74
|
+
hide-details
|
|
75
|
+
clearable
|
|
76
|
+
placeholder="All companies"
|
|
77
|
+
no-data-text="No company for this service"
|
|
78
|
+
class="sp-company-filter"
|
|
79
|
+
aria-label="Company"
|
|
80
|
+
data-test="sp-members-company"
|
|
81
|
+
>
|
|
82
|
+
<template #item="{ props: itemProps, item }">
|
|
83
|
+
<v-list-item v-bind="itemProps" :subtitle="item.raw.subtitle" />
|
|
84
|
+
</template>
|
|
85
|
+
</v-autocomplete>
|
|
78
86
|
<AppButton
|
|
79
87
|
variant="icon"
|
|
80
88
|
icon="mdi-refresh"
|
|
@@ -151,10 +159,11 @@ import type {
|
|
|
151
159
|
TSiteProviderMember,
|
|
152
160
|
} from "../composables/useServiceProvider";
|
|
153
161
|
import {
|
|
154
|
-
|
|
162
|
+
companyFilterItems,
|
|
155
163
|
concernMailto,
|
|
156
164
|
filterProviderMembers,
|
|
157
165
|
providerMembersEmptyText,
|
|
166
|
+
providerSummaryText,
|
|
158
167
|
serviceTypeOptions,
|
|
159
168
|
} from "../utils/site-provider-members";
|
|
160
169
|
import { spDay } from "../utils/sp-inviter-row";
|
|
@@ -189,7 +198,7 @@ const loading = ref(false);
|
|
|
189
198
|
const exporting = ref(false);
|
|
190
199
|
const search = ref("");
|
|
191
200
|
const type = ref("");
|
|
192
|
-
const company = ref("");
|
|
201
|
+
const company = ref<string | null>("");
|
|
193
202
|
const snackbar = ref(false);
|
|
194
203
|
const message = ref("");
|
|
195
204
|
|
|
@@ -209,9 +218,18 @@ const typeItems = computed(() => [
|
|
|
209
218
|
...serviceTypeOptions(items.value),
|
|
210
219
|
]);
|
|
211
220
|
|
|
221
|
+
const companyItems = computed(() => companyFilterItems(companies.value, type.value));
|
|
222
|
+
const summaryText = computed(() => providerSummaryText(companies.value));
|
|
223
|
+
// A company of another service cannot match; drop it when the service changes.
|
|
224
|
+
watch(type, () => {
|
|
225
|
+
if (company.value && !companyItems.value.some((c) => c.value === company.value)) {
|
|
226
|
+
company.value = "";
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
212
230
|
const filters = computed(() => ({
|
|
213
231
|
type: type.value,
|
|
214
|
-
company: company.value,
|
|
232
|
+
company: company.value || "",
|
|
215
233
|
search: search.value,
|
|
216
234
|
}));
|
|
217
235
|
|
|
@@ -294,41 +312,21 @@ onMounted(async () => {
|
|
|
294
312
|
</script>
|
|
295
313
|
|
|
296
314
|
<style scoped>
|
|
297
|
-
.sp-
|
|
298
|
-
display: flex;
|
|
299
|
-
flex-wrap: wrap;
|
|
300
|
-
gap: 8px;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
.sp-chip {
|
|
304
|
-
display: flex;
|
|
305
|
-
flex-direction: column;
|
|
306
|
-
align-items: flex-start;
|
|
307
|
-
padding: 8px 12px;
|
|
308
|
-
border: 1px solid var(--border);
|
|
309
|
-
border-radius: var(--r-inner);
|
|
310
|
-
background: var(--card);
|
|
311
|
-
color: var(--text);
|
|
312
|
-
text-align: left;
|
|
313
|
-
cursor: pointer;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
.sp-chip--active {
|
|
317
|
-
border-color: var(--accent-strong);
|
|
318
|
-
box-shadow: inset 0 0 0 1px var(--accent-strong);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
.sp-chip__name {
|
|
322
|
-
font-weight: 600;
|
|
315
|
+
.sp-summary {
|
|
323
316
|
font-size: 14px;
|
|
317
|
+
color: var(--muted);
|
|
324
318
|
}
|
|
325
319
|
|
|
326
|
-
.sp-chip__type,
|
|
327
320
|
.sp-sub {
|
|
328
321
|
font-size: 12px;
|
|
329
322
|
color: var(--muted);
|
|
330
323
|
}
|
|
331
324
|
|
|
325
|
+
.sp-company-filter {
|
|
326
|
+
min-width: 220px;
|
|
327
|
+
max-width: 300px;
|
|
328
|
+
}
|
|
329
|
+
|
|
332
330
|
.sp-type-filter {
|
|
333
331
|
min-width: 170px;
|
|
334
332
|
max-width: 220px;
|
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-row no-gutters align="center" justify="center">
|
|
3
|
+
<!-- Site device mode is ON for this site: this shared phone can check other
|
|
4
|
+
people in and out (owner request 2026-10-01). -->
|
|
5
|
+
<v-col v-if="siteDeviceMode && canCheckInOut" cols="12" class="mb-4">
|
|
6
|
+
<v-alert
|
|
7
|
+
type="info"
|
|
8
|
+
variant="tonal"
|
|
9
|
+
icon="mdi-cellphone-check"
|
|
10
|
+
data-test="site-device-mode-banner"
|
|
11
|
+
>
|
|
12
|
+
<div class="font-weight-medium">Site device mode is on</div>
|
|
13
|
+
<div class="mb-3">
|
|
14
|
+
This phone can check staff in and out for them: take their photo, type their
|
|
15
|
+
name and NRIC. Only the last 4 characters of the NRIC are saved.
|
|
16
|
+
</div>
|
|
17
|
+
<v-btn
|
|
18
|
+
class="text-none screen-btn-primary"
|
|
19
|
+
size="large"
|
|
20
|
+
variant="flat"
|
|
21
|
+
prepend-icon="mdi-account-clock"
|
|
22
|
+
data-test="attendance-proxy-open-btn"
|
|
23
|
+
@click="dialogShowProxy = true"
|
|
24
|
+
>
|
|
25
|
+
Check in / Check out for someone
|
|
26
|
+
</v-btn>
|
|
27
|
+
</v-alert>
|
|
28
|
+
</v-col>
|
|
3
29
|
<v-col cols="12" lg="12">
|
|
4
30
|
<TableMain
|
|
5
31
|
title="Attendance Records"
|
|
@@ -71,6 +97,14 @@
|
|
|
71
97
|
@close="dialogShowMoreActions = false"
|
|
72
98
|
/>
|
|
73
99
|
|
|
100
|
+
<AttendanceProxyDialog
|
|
101
|
+
v-if="siteDeviceMode"
|
|
102
|
+
v-model="dialogShowProxy"
|
|
103
|
+
:site="props.site"
|
|
104
|
+
:service-type="props.serviceType"
|
|
105
|
+
@saved="onProxySaved"
|
|
106
|
+
/>
|
|
107
|
+
|
|
74
108
|
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
75
109
|
</template>
|
|
76
110
|
|
|
@@ -107,7 +141,31 @@ const headers = [
|
|
|
107
141
|
{ title: "Total Hours", value: "totalHours" },
|
|
108
142
|
];
|
|
109
143
|
|
|
110
|
-
const { getMyAttendances, checkIn, checkOut } = useAttendance();
|
|
144
|
+
const { getMyAttendances, checkIn, checkOut, getAttendanceSettings } = useAttendance();
|
|
145
|
+
|
|
146
|
+
// Site device mode: read once per site. A failed read means OFF (today's flow).
|
|
147
|
+
const siteDeviceMode = ref(false);
|
|
148
|
+
const dialogShowProxy = ref(false);
|
|
149
|
+
watch(
|
|
150
|
+
() => [props.site, props.serviceType],
|
|
151
|
+
async () => {
|
|
152
|
+
siteDeviceMode.value = false;
|
|
153
|
+
if (!props.site) return;
|
|
154
|
+
try {
|
|
155
|
+
const res: any = await getAttendanceSettings(props.site, props.serviceType);
|
|
156
|
+
siteDeviceMode.value = res?.siteDeviceMode === true;
|
|
157
|
+
} catch {
|
|
158
|
+
siteDeviceMode.value = false;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{ immediate: true }
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
function onProxySaved(text: string) {
|
|
165
|
+
message.value = text;
|
|
166
|
+
messageColor.value = "success";
|
|
167
|
+
messageSnackbar.value = true;
|
|
168
|
+
}
|
|
111
169
|
const { formatDate } = useUtils();
|
|
112
170
|
|
|
113
171
|
const dialogShowMoreActions = ref(false);
|
|
@@ -170,7 +228,7 @@ watchEffect(() => {
|
|
|
170
228
|
const today = new Date().toISOString().split("T")[0];
|
|
171
229
|
todayAttendance.value =
|
|
172
230
|
getMyAttendancesReq.value.items.find((item: any) => {
|
|
173
|
-
if (!item.checkInTimestamp) return false;
|
|
231
|
+
if (!item.checkInTimestamp || item.isProxy) return false;
|
|
174
232
|
const itemDate = new Date(item.checkInTimestamp)
|
|
175
233
|
.toISOString()
|
|
176
234
|
.split("T")[0];
|
|
@@ -127,6 +127,13 @@
|
|
|
127
127
|
<v-btn variant="text" class="text-none" @click="viewFromToast">View</v-btn>
|
|
128
128
|
</template>
|
|
129
129
|
</v-snackbar>
|
|
130
|
+
|
|
131
|
+
<v-snackbar v-model="nowhere.show" location="top right" :timeout="6000" color="surface">
|
|
132
|
+
<div class="d-flex align-center ga-3">
|
|
133
|
+
<v-icon icon="mdi-information-outline" color="primary" />
|
|
134
|
+
<div class="text-body-2">{{ nowhere.text }}</div>
|
|
135
|
+
</div>
|
|
136
|
+
</v-snackbar>
|
|
130
137
|
</div>
|
|
131
138
|
</template>
|
|
132
139
|
|
|
@@ -137,7 +144,7 @@ import {
|
|
|
137
144
|
timeAgo,
|
|
138
145
|
type TInboxItem,
|
|
139
146
|
} from "../utils/notification-inbox";
|
|
140
|
-
import { appUrlsFrom, chooseNavigation, notificationTarget } from "../utils/notification-target";
|
|
147
|
+
import { appUrlsFrom, chooseNavigation, noTargetMessage, notificationTarget } from "../utils/notification-target";
|
|
141
148
|
|
|
142
149
|
/**
|
|
143
150
|
* The top-bar notification feed for the web, in every signed-in app.
|
|
@@ -180,6 +187,8 @@ const markingAll = ref(false);
|
|
|
180
187
|
const items = ref<Array<Record<string, any>>>([]);
|
|
181
188
|
const unread = ref(0);
|
|
182
189
|
const toast = reactive({ show: false, title: "", body: "" });
|
|
190
|
+
/** Said when a clicked row has no page to open - a click is never a silent nothing. */
|
|
191
|
+
const nowhere = reactive({ show: false, text: "" });
|
|
183
192
|
|
|
184
193
|
const userId = computed(() => String(currentUser.value?._id ?? ""));
|
|
185
194
|
const badgeLabel = computed(() => badgeText(unread.value));
|
|
@@ -341,7 +350,10 @@ async function goToSubject(item: Record<string, any>) {
|
|
|
341
350
|
hasPage: hasLocalPage,
|
|
342
351
|
appUrls: appUrls(),
|
|
343
352
|
});
|
|
344
|
-
if (!nav)
|
|
353
|
+
if (!nav) {
|
|
354
|
+
Object.assign(nowhere, { show: true, text: noTargetMessage(item as any) });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
345
357
|
|
|
346
358
|
open.value = false;
|
|
347
359
|
if (nav.type === "href") {
|
|
@@ -58,6 +58,8 @@
|
|
|
58
58
|
else. Two kinds share that list and they are governed differently,
|
|
59
59
|
so the row has to say which it is.
|
|
60
60
|
-->
|
|
61
|
+
<!-- "Admin · St. Regis Residences": names are unique per site (2026-10-01) -->
|
|
62
|
+
<template #item.name="{ item }">{{ roleLabel(item) }}</template>
|
|
61
63
|
<template #item.scope="{ item }">
|
|
62
64
|
<StatusChip
|
|
63
65
|
:dot="false"
|
|
@@ -164,7 +166,7 @@
|
|
|
164
166
|
<v-select
|
|
165
167
|
v-model="memberReassignments[member._id]"
|
|
166
168
|
:items="deletionPreview.replacementRoles"
|
|
167
|
-
item-title="
|
|
169
|
+
:item-title="roleLabel"
|
|
168
170
|
item-value="_id"
|
|
169
171
|
label="Reassign role"
|
|
170
172
|
density="comfortable"
|
|
@@ -204,7 +206,7 @@
|
|
|
204
206
|
|
|
205
207
|
<script setup lang="ts">
|
|
206
208
|
// Relative import: this layer's `utils/` is not auto-imported into consumers.
|
|
207
|
-
import { safeReturnTo, showSiteField } from "../utils/role";
|
|
209
|
+
import { roleLabel, safeReturnTo, showSiteField } from "../utils/role";
|
|
208
210
|
import { holdsPermission } from "../utils/permission-spellings";
|
|
209
211
|
import {
|
|
210
212
|
catalogueForOrg,
|
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"
|
|
@@ -910,8 +910,20 @@ const hidQrReaderId = computed(() => (
|
|
|
910
910
|
hidQrConfiguredReaderId.value || String(hidQrReader.value?._id || "")
|
|
911
911
|
));
|
|
912
912
|
|
|
913
|
+
/*
|
|
914
|
+
* Visitor QR passes are hidden at the manager's request (2026-09-23).
|
|
915
|
+
*
|
|
916
|
+
* A flag rather than a deletion: issuing, the preview dialog, printing and the
|
|
917
|
+
* regenerate handler below are all intact and untouched, so bringing the
|
|
918
|
+
* feature back is this one line. Every surface that offers a Visitor QR reads
|
|
919
|
+
* `hidQrCodePassAllowed`, which is why the switch belongs here and not in each
|
|
920
|
+
* of them.
|
|
921
|
+
*/
|
|
922
|
+
const HID_VISITOR_QR_ENABLED = false;
|
|
923
|
+
|
|
913
924
|
const hidQrCodePassAllowed = computed(() => {
|
|
914
925
|
return (
|
|
926
|
+
HID_VISITOR_QR_ENABLED &&
|
|
915
927
|
hidQrCodePassEnabled.value &&
|
|
916
928
|
["guest", "contractor", "walk-in"].includes(prop.type)
|
|
917
929
|
);
|