@7365admin1/layer-common 3.2.2-staging.180 → 3.2.2-staging.182
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/assets/css/primitives.css +33 -14
- package/assets/css/screens.css +12 -0
- package/components/AppSelect.vue +80 -36
- package/components/FileInputWithList.vue +14 -0
- package/components/HidAccessLogDashboard.vue +24 -9
- package/components/HidEnabledGate.vue +5 -1
- package/components/HidIntercomManagement.vue +172 -26
- package/components/HidQrCodeConfiguration.vue +17 -2
- package/components/HidReaderManagement.vue +13 -4
- package/components/HidUserEnrollment.vue +549 -33
- package/components/TableMain.vue +7 -1
- package/composables/useHidAmico.ts +71 -0
- package/composables/useHidNavigation.ts +2 -2
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +2 -2
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +23 -0
|
@@ -631,19 +631,10 @@ button.app-btn--err {
|
|
|
631
631
|
color: var(--text2);
|
|
632
632
|
}
|
|
633
633
|
|
|
634
|
-
/* A `<select>` keeps the native menu; only the chrome is ours. */
|
|
635
|
-
select.app-field__input {
|
|
636
|
-
font-weight: 600;
|
|
637
|
-
font-size: var(--fs-nav);
|
|
638
|
-
appearance: none;
|
|
639
|
-
cursor: pointer;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
634
|
/*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
* soft pill the design already uses and the one whose contrast is measured.
|
|
635
|
+
* AppSelect wears the same field chrome as a button and opens a themed
|
|
636
|
+
* popover. Multi values are `StatusChip pill tone-neutral`, the soft pill the
|
|
637
|
+
* design already uses and the one whose contrast is measured.
|
|
647
638
|
*/
|
|
648
639
|
.app-select {
|
|
649
640
|
cursor: pointer;
|
|
@@ -655,6 +646,23 @@ select.app-field__input {
|
|
|
655
646
|
opacity: 0.55;
|
|
656
647
|
}
|
|
657
648
|
|
|
649
|
+
.app-select--single {
|
|
650
|
+
min-width: 180px;
|
|
651
|
+
font-family: inherit;
|
|
652
|
+
text-align: left;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.app-select__single-value {
|
|
656
|
+
flex: 1;
|
|
657
|
+
min-width: 0;
|
|
658
|
+
overflow: hidden;
|
|
659
|
+
font-size: var(--fs-nav);
|
|
660
|
+
font-weight: 600;
|
|
661
|
+
color: var(--text);
|
|
662
|
+
text-overflow: ellipsis;
|
|
663
|
+
white-space: nowrap;
|
|
664
|
+
}
|
|
665
|
+
|
|
658
666
|
/*
|
|
659
667
|
* The SAME step for the typed field, which never had one. `AppField` and
|
|
660
668
|
* `AppDateField` both take a `disabled` prop and pass it to the native
|
|
@@ -666,8 +674,8 @@ select.app-field__input {
|
|
|
666
674
|
* filter, on BOTH themes. The field was genuinely dead and read as live.
|
|
667
675
|
*
|
|
668
676
|
* Keyed off the input's own `:disabled` rather than a new modifier class, so
|
|
669
|
-
* it covers `AppField
|
|
670
|
-
*
|
|
677
|
+
* it covers `AppField` and `AppDateField` without two templates having to
|
|
678
|
+
* remember to add it. 0.55 is
|
|
671
679
|
* `.app-select--disabled`'s number, not a second one.
|
|
672
680
|
*/
|
|
673
681
|
.app-field:has(.app-field__input:disabled) {
|
|
@@ -746,6 +754,16 @@ select.app-field__input {
|
|
|
746
754
|
background: var(--hover);
|
|
747
755
|
}
|
|
748
756
|
|
|
757
|
+
.app-select__option--selected {
|
|
758
|
+
background: var(--accent-soft);
|
|
759
|
+
color: var(--accent-text);
|
|
760
|
+
font-weight: 600;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
.app-select__option--selected:hover {
|
|
764
|
+
background: var(--accent-soft);
|
|
765
|
+
}
|
|
766
|
+
|
|
749
767
|
.app-select__box {
|
|
750
768
|
color: var(--muted);
|
|
751
769
|
flex: 0 0 auto;
|
|
@@ -756,6 +774,7 @@ select.app-field__input {
|
|
|
756
774
|
}
|
|
757
775
|
|
|
758
776
|
.app-select__option-text {
|
|
777
|
+
flex: 1;
|
|
759
778
|
min-width: 0;
|
|
760
779
|
overflow: hidden;
|
|
761
780
|
text-overflow: ellipsis;
|
package/assets/css/screens.css
CHANGED
|
@@ -657,6 +657,18 @@
|
|
|
657
657
|
white-space: nowrap;
|
|
658
658
|
}
|
|
659
659
|
|
|
660
|
+
/*
|
|
661
|
+
* `--thead` is intentionally translucent. A fixed header, however, sits above
|
|
662
|
+
* scrolling rows, so using the token by itself lets the first row show through
|
|
663
|
+
* the header. Layer the same tint over the card surface to keep the rendered
|
|
664
|
+
* colour unchanged while making the sticky header fully opaque.
|
|
665
|
+
*/
|
|
666
|
+
.table-card .v-table--fixed-header thead th {
|
|
667
|
+
background:
|
|
668
|
+
linear-gradient(var(--thead), var(--thead)),
|
|
669
|
+
var(--card) !important;
|
|
670
|
+
}
|
|
671
|
+
|
|
660
672
|
/**
|
|
661
673
|
* ROW HEIGHT IS THE LINE BOX, NOT THE PADDING.
|
|
662
674
|
*
|
package/components/AppSelect.vue
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
THE SELECT.
|
|
3
3
|
|
|
4
4
|
The handoff draws it as the text field with the filter's name in muted 12px
|
|
5
|
-
on the left, the chosen value next to it and a chevron on the right.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
wrong on a phone.
|
|
5
|
+
on the left, the chosen value next to it and a chevron on the right. Both
|
|
6
|
+
single and multiple values use the same themed popover, so the option list
|
|
7
|
+
does not fall back to the browser/OS styling.
|
|
9
8
|
|
|
10
9
|
`multiple` is the ONE case where native is the wrong answer. A native
|
|
11
10
|
`<select multiple>` is not a dropdown at all - it is an always-open scrolling
|
|
@@ -13,32 +12,62 @@
|
|
|
13
12
|
and change how the staff work. So multi opens a POPOVER in the same field
|
|
14
13
|
chrome, with the chosen values as the design's soft removable pills.
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
exactly the `<select>` element's own string.
|
|
15
|
+
A single-value consumer that does not use `return-object` still emits the
|
|
16
|
+
same string that the old native `<select>` emitted.
|
|
19
17
|
-->
|
|
20
18
|
<template>
|
|
21
|
-
<
|
|
19
|
+
<v-menu
|
|
22
20
|
v-if="!multiple"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
:close-on-content-click="true"
|
|
22
|
+
:disabled="disabled"
|
|
23
|
+
location="bottom start"
|
|
24
|
+
offset="4"
|
|
26
25
|
>
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
26
|
+
<template #activator="{ props: activator }">
|
|
27
|
+
<div
|
|
28
|
+
v-bind="{ ...activator, ...$attrs }"
|
|
29
|
+
class="app-field app-select app-select--single"
|
|
30
|
+
:class="{ 'app-field--compact': compact, 'app-select--disabled': disabled }"
|
|
31
|
+
role="button"
|
|
32
|
+
:aria-disabled="disabled"
|
|
33
|
+
:tabindex="disabled ? -1 : 0"
|
|
34
|
+
>
|
|
35
|
+
<span v-if="label" class="app-field__label">{{ label }}</span>
|
|
36
|
+
<span
|
|
37
|
+
class="app-select__single-value"
|
|
38
|
+
:class="{ 'app-select__placeholder': !hasSingleValue }"
|
|
39
|
+
>
|
|
40
|
+
{{ singleDisplay }}
|
|
41
|
+
</span>
|
|
42
|
+
<button
|
|
43
|
+
v-if="clearable && hasSingleValue"
|
|
44
|
+
type="button"
|
|
45
|
+
class="app-select__clear"
|
|
46
|
+
aria-label="Clear"
|
|
47
|
+
@click.stop="clearSingle"
|
|
48
|
+
>
|
|
49
|
+
<v-icon icon="mdi-close-circle" size="15" />
|
|
50
|
+
</button>
|
|
51
|
+
<v-icon class="app-field__icon" icon="mdi-chevron-down" size="17" />
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<div class="app-select__menu" role="listbox">
|
|
56
|
+
<button
|
|
57
|
+
v-for="opt in normalized"
|
|
58
|
+
:key="String(opt.value)"
|
|
59
|
+
type="button"
|
|
60
|
+
class="app-select__option"
|
|
61
|
+
:class="{ 'app-select__option--selected': isSingleOn(opt) }"
|
|
62
|
+
role="option"
|
|
63
|
+
:aria-selected="isSingleOn(opt)"
|
|
64
|
+
@click="selectSingle(opt)"
|
|
65
|
+
>
|
|
66
|
+
<span class="app-select__option-text">{{ opt.title }}</span>
|
|
67
|
+
<v-icon v-if="isSingleOn(opt)" icon="mdi-check" size="17" />
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
</v-menu>
|
|
42
71
|
|
|
43
72
|
<v-menu
|
|
44
73
|
v-else
|
|
@@ -114,9 +143,8 @@
|
|
|
114
143
|
</template>
|
|
115
144
|
|
|
116
145
|
<script setup lang="ts">
|
|
117
|
-
/* `v-menu` has no single root node, so
|
|
118
|
-
|
|
119
|
-
did - on the `<label>`. */
|
|
146
|
+
/* `v-menu` has no single root node, so attributes and classes have to be put
|
|
147
|
+
on each activator by hand. */
|
|
120
148
|
defineOptions({ inheritAttrs: false });
|
|
121
149
|
|
|
122
150
|
/*
|
|
@@ -157,15 +185,31 @@ const keyOf = (v: any) =>
|
|
|
157
185
|
|
|
158
186
|
/* ---- single ---------------------------------------------------------- */
|
|
159
187
|
|
|
160
|
-
const
|
|
161
|
-
|
|
188
|
+
const hasSingleValue = computed(
|
|
189
|
+
() => model.value !== "" && model.value !== null && model.value !== undefined
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const selectedSingleOption = computed(() =>
|
|
193
|
+
normalized.value.find((opt) => String(opt.value) === keyOf(model.value))
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const singleDisplay = computed(() =>
|
|
197
|
+
hasSingleValue.value
|
|
198
|
+
? selectedSingleOption.value?.title ?? keyOf(model.value)
|
|
199
|
+
: props.placeholder
|
|
162
200
|
);
|
|
163
201
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
202
|
+
const isSingleOn = (opt: any) =>
|
|
203
|
+
hasSingleValue.value && String(opt.value) === keyOf(model.value);
|
|
204
|
+
|
|
205
|
+
function selectSingle(opt: any) {
|
|
206
|
+
if (props.disabled) return;
|
|
207
|
+
model.value = props.returnObject ? opt.raw : String(opt.value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function clearSingle() {
|
|
211
|
+
if (props.disabled) return;
|
|
212
|
+
model.value = "";
|
|
169
213
|
}
|
|
170
214
|
|
|
171
215
|
/* ---- multiple -------------------------------------------------------- */
|
|
@@ -334,4 +334,18 @@ onUnmounted(() => {
|
|
|
334
334
|
white-space: normal;
|
|
335
335
|
line-height: 1.2;
|
|
336
336
|
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The attach icon (mdi-paperclip) was drawing at Vuetify's default
|
|
340
|
+
* `.v-input__prepend > .v-icon { opacity: var(--v-medium-emphasis-opacity) }`
|
|
341
|
+
* (0.64) - the same field-icon dimming ~200 other call sites rely on, so it is
|
|
342
|
+
* overridden here only, not in the layer's token. Full opacity, `--text2` -
|
|
343
|
+
* the same colour `.screen-icon-btn` already uses for an icon at rest -
|
|
344
|
+
* matches a sibling icon drawn outside a field (e.g. a composer's chat icon,
|
|
345
|
+
* which is never subject to this Vuetify rule in the first place).
|
|
346
|
+
*/
|
|
347
|
+
.filter-field :deep(.v-input__prepend > .v-icon) {
|
|
348
|
+
opacity: 1;
|
|
349
|
+
color: var(--text2);
|
|
350
|
+
}
|
|
337
351
|
</style>
|
|
@@ -65,7 +65,18 @@
|
|
|
65
65
|
@update:model-value="loadDashboard"
|
|
66
66
|
/>
|
|
67
67
|
<AppField v-model="search" search placeholder="Search" />
|
|
68
|
-
<AppSelect
|
|
68
|
+
<AppSelect
|
|
69
|
+
v-model="status"
|
|
70
|
+
:items="statusOptions"
|
|
71
|
+
placeholder="Status"
|
|
72
|
+
clearable
|
|
73
|
+
/>
|
|
74
|
+
<AppSelect
|
|
75
|
+
v-model="accessMethod"
|
|
76
|
+
:items="accessMethodFilterOptions"
|
|
77
|
+
placeholder="Access Method"
|
|
78
|
+
clearable
|
|
79
|
+
/>
|
|
69
80
|
</div>
|
|
70
81
|
</template>
|
|
71
82
|
|
|
@@ -253,6 +264,7 @@
|
|
|
253
264
|
:items="statusOptions"
|
|
254
265
|
aria-label="Status"
|
|
255
266
|
placeholder="Select"
|
|
267
|
+
clearable
|
|
256
268
|
/>
|
|
257
269
|
</v-card-text>
|
|
258
270
|
|
|
@@ -323,7 +335,8 @@ const userImages = ref<Record<string, string>>({});
|
|
|
323
335
|
const selectedReaderId = ref("");
|
|
324
336
|
const activeTab = ref<AccessTab>("resident");
|
|
325
337
|
const search = ref("");
|
|
326
|
-
const status = ref("
|
|
338
|
+
const status = ref("");
|
|
339
|
+
const accessMethod = ref("");
|
|
327
340
|
const page = ref(1);
|
|
328
341
|
const limit = 10;
|
|
329
342
|
const loading = ref(false);
|
|
@@ -338,11 +351,12 @@ const exportForm = reactive({
|
|
|
338
351
|
users: [] as string[],
|
|
339
352
|
accessMethod: "All",
|
|
340
353
|
readerLocation: "All",
|
|
341
|
-
status: "
|
|
354
|
+
status: "",
|
|
342
355
|
});
|
|
343
356
|
|
|
344
|
-
const statusOptions = ["
|
|
345
|
-
const
|
|
357
|
+
const statusOptions = ["Authorized", "Not Authorized", "Unknown"];
|
|
358
|
+
const accessMethodFilterOptions = ["Facial", "QR Code", "ID and Password", "PIN", "Card"];
|
|
359
|
+
const accessMethodOptions = ["All", ...accessMethodFilterOptions];
|
|
346
360
|
|
|
347
361
|
const readerOptions = computed(() =>
|
|
348
362
|
readers.value.map((reader) => ({
|
|
@@ -442,9 +456,10 @@ const filteredRows = computed(() => {
|
|
|
442
456
|
row.status,
|
|
443
457
|
].some((value) => String(value ?? "").toLowerCase().includes(query));
|
|
444
458
|
|
|
445
|
-
const matchesStatus = status.value
|
|
459
|
+
const matchesStatus = !status.value || row.status === status.value;
|
|
460
|
+
const matchesMethod = !accessMethod.value || row.method === accessMethod.value;
|
|
446
461
|
|
|
447
|
-
return matchesSearch && matchesStatus;
|
|
462
|
+
return matchesSearch && matchesStatus && matchesMethod;
|
|
448
463
|
});
|
|
449
464
|
});
|
|
450
465
|
|
|
@@ -497,7 +512,7 @@ const exportRowsData = computed(() =>
|
|
|
497
512
|
const matchesUsers = !selectedUsers.length || selectedUsers.includes(row.key) || selectedUsers.includes(row.identityKey);
|
|
498
513
|
const matchesMethod = exportForm.accessMethod === "All" || row.method === exportForm.accessMethod;
|
|
499
514
|
const matchesLocation = exportForm.readerLocation === "All" || row.readerLocation === exportForm.readerLocation;
|
|
500
|
-
const matchesStatus = exportForm.status
|
|
515
|
+
const matchesStatus = !exportForm.status || row.status === exportForm.status;
|
|
501
516
|
const matchesDate = isWithinExportRange(row.lastAccessDate);
|
|
502
517
|
|
|
503
518
|
return matchesUsers && matchesMethod && matchesLocation && matchesStatus && matchesDate;
|
|
@@ -510,7 +525,7 @@ watch(
|
|
|
510
525
|
{ immediate: true },
|
|
511
526
|
);
|
|
512
527
|
|
|
513
|
-
watch([search, status, activeTab], () => {
|
|
528
|
+
watch([search, status, accessMethod, activeTab], () => {
|
|
514
529
|
page.value = 1;
|
|
515
530
|
});
|
|
516
531
|
|
|
@@ -45,7 +45,11 @@ const props = withDefaults(
|
|
|
45
45
|
const { getSiteById } = useSiteSettings();
|
|
46
46
|
const { getHidServiceEnabled } = useOptionalServices();
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// Do not await this request during route setup. Every HID page uses this gate;
|
|
49
|
+
// awaiting it makes Nuxt Suspense keep rendering the previous page until the
|
|
50
|
+
// site request finishes, so the URL changes while the visible module appears
|
|
51
|
+
// frozen. The lazy request updates `siteData` reactively after navigation.
|
|
52
|
+
const { data: siteData } = useLazyAsyncData(
|
|
49
53
|
"hid-enabled-gate-site-" + props.site,
|
|
50
54
|
() => getSiteById(props.site),
|
|
51
55
|
{
|