@7365admin1/layer-common 3.2.2-staging.181 → 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/components/AppSelect.vue +80 -36
- 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/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/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 -------------------------------------------------------- */
|
|
@@ -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
|
{
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
class="app-tab"
|
|
77
77
|
:class="{ 'app-tab--active': activeTab === tab.value }"
|
|
78
78
|
:aria-selected="activeTab === tab.value"
|
|
79
|
-
@click="
|
|
79
|
+
@click="selectTab(tab.value)"
|
|
80
80
|
>
|
|
81
81
|
{{ tab.label }}
|
|
82
82
|
</button>
|
|
@@ -94,7 +94,12 @@
|
|
|
94
94
|
@update:model-value="page = 1"
|
|
95
95
|
/>
|
|
96
96
|
<AppField v-model="search" search placeholder="Search" />
|
|
97
|
-
<AppSelect
|
|
97
|
+
<AppSelect
|
|
98
|
+
v-model="statusFilter"
|
|
99
|
+
:items="statusOptions"
|
|
100
|
+
placeholder="Status"
|
|
101
|
+
clearable
|
|
102
|
+
/>
|
|
98
103
|
</div>
|
|
99
104
|
</template>
|
|
100
105
|
|
|
@@ -126,6 +131,18 @@
|
|
|
126
131
|
<StatusChip :status="item.status" />
|
|
127
132
|
</template>
|
|
128
133
|
|
|
134
|
+
<template #[`item.contactStatus`]="{ item }">
|
|
135
|
+
<v-btn
|
|
136
|
+
size="small"
|
|
137
|
+
variant="outlined"
|
|
138
|
+
class="text-none"
|
|
139
|
+
:loading="testingContactReaderId === item.readerId"
|
|
140
|
+
@click="testContactSipStatus(item)"
|
|
141
|
+
>
|
|
142
|
+
{{ getContactReaderStatus(item) }}
|
|
143
|
+
</v-btn>
|
|
144
|
+
</template>
|
|
145
|
+
|
|
129
146
|
<template #[`item.action-table`]="{ item }">
|
|
130
147
|
<v-menu location="bottom end">
|
|
131
148
|
<template #activator="{ props: menuProps }">
|
|
@@ -133,13 +150,19 @@
|
|
|
133
150
|
</template>
|
|
134
151
|
<v-list v-if="activeTab === 'intercom'" density="compact" class="row-menu">
|
|
135
152
|
<v-list-item title="View" @click="openViewDialog(item)" />
|
|
136
|
-
<v-list-item
|
|
153
|
+
<v-list-item
|
|
154
|
+
title="Test Connection with SIP"
|
|
155
|
+
:disabled="testingSipReaderId === item.reader._id"
|
|
156
|
+
@click="testSipConnection(item)"
|
|
157
|
+
/>
|
|
137
158
|
<v-list-item title="Call Settings" @click="openCallDialog(item)" />
|
|
138
159
|
<v-list-item title="Dial Code" @click="openDialCodeDialog(item)" />
|
|
139
160
|
<v-list-item title="Make a call" @click="openMakeCallDialog(item)" />
|
|
140
161
|
<v-list-item title="Web call" @click="openWebPhoneDialog(item)" />
|
|
141
162
|
</v-list>
|
|
142
163
|
<v-list v-else density="compact" class="row-menu">
|
|
164
|
+
<v-list-item title="Call" prepend-icon="mdi-phone" @click="openContactCall(item)" />
|
|
165
|
+
<v-list-item title="Check reader SIP status" prepend-icon="mdi-lan-check" @click="testContactSipStatus(item)" />
|
|
143
166
|
<v-list-item title="View" @click="openContactView(item)" />
|
|
144
167
|
<v-list-item title="Edit" @click="openContactDialog(item)" />
|
|
145
168
|
<v-list-item title="Delete" @click="openDeleteContact(item)" />
|
|
@@ -554,12 +577,16 @@
|
|
|
554
577
|
<v-card class="form-dialog" rounded="lg">
|
|
555
578
|
<v-card-title class="dialog-title">{{ contactForm.id ? 'Edit Contact' : 'Add Contact' }}</v-card-title>
|
|
556
579
|
<v-card-text class="dialog-body">
|
|
580
|
+
<FieldLabel text="HID Reader" required />
|
|
581
|
+
<AppSelect
|
|
582
|
+
v-model="contactForm.readerId"
|
|
583
|
+
:items="readerOptions"
|
|
584
|
+
:disabled="Boolean(contactForm.id)"
|
|
585
|
+
/>
|
|
557
586
|
<FieldLabel text="Name" required />
|
|
558
587
|
<AppField v-model="contactForm.name" placeholder="Enter name" />
|
|
559
|
-
<FieldLabel text="
|
|
560
|
-
<AppField v-model="contactForm.
|
|
561
|
-
<FieldLabel text="Server Address" required />
|
|
562
|
-
<AppField v-model="contactForm.serverAddress" placeholder="192.168.1.1" />
|
|
588
|
+
<FieldLabel text="SIP Number / Address" required />
|
|
589
|
+
<AppField v-model="contactForm.serverAddress" placeholder="7100 or user@example.com" />
|
|
563
590
|
</v-card-text>
|
|
564
591
|
<v-card-actions class="dialog-actions pa-0">
|
|
565
592
|
<v-btn class="text-none action-cancel" variant="flat" height="44" @click="contactDialog = false">Cancel</v-btn>
|
|
@@ -693,15 +720,19 @@ const {
|
|
|
693
720
|
const activeTab = ref<IntercomTab>("intercom");
|
|
694
721
|
const readers = ref<Record<string, any>[]>([]);
|
|
695
722
|
const selectedReaderId = ref("");
|
|
723
|
+
const testingSipReaderId = ref("");
|
|
724
|
+
const testingContactReaderId = ref("");
|
|
696
725
|
const intercomRows = ref<IntercomRow[]>([]);
|
|
697
726
|
const contacts = ref<ContactRow[]>([]);
|
|
727
|
+
const intercomLoaded = ref(false);
|
|
728
|
+
const contactsLoaded = ref(false);
|
|
698
729
|
const loading = ref(false);
|
|
699
730
|
const saving = ref(false);
|
|
700
731
|
const contactsSaving = ref(false);
|
|
701
732
|
const calling = ref(false);
|
|
702
733
|
const hangingUp = ref(false);
|
|
703
734
|
const search = ref("");
|
|
704
|
-
const statusFilter = ref("
|
|
735
|
+
const statusFilter = ref("");
|
|
705
736
|
const page = ref(1);
|
|
706
737
|
const limit = 20;
|
|
707
738
|
const selectedRow = ref<IntercomRow | null>(null);
|
|
@@ -728,7 +759,7 @@ const deleteContactDialog = ref(false);
|
|
|
728
759
|
|
|
729
760
|
const message = reactive<{ type: MessageType; text: string }>({ type: "info", text: "" });
|
|
730
761
|
|
|
731
|
-
const statusOptions = ["
|
|
762
|
+
const statusOptions = ["Connected", "Not connected", "Connecting", "Disabled", "Failed"];
|
|
732
763
|
|
|
733
764
|
const pjsipKeys = [
|
|
734
765
|
"enabled",
|
|
@@ -839,7 +870,7 @@ const filteredIntercomRows = computed(() => {
|
|
|
839
870
|
row.dialCode,
|
|
840
871
|
row.status,
|
|
841
872
|
].some((value) => String(value || "").toLowerCase().includes(query));
|
|
842
|
-
const matchesStatus = statusFilter.value
|
|
873
|
+
const matchesStatus = !statusFilter.value || row.status === statusFilter.value;
|
|
843
874
|
return matchesReader && matchesSearch && matchesStatus;
|
|
844
875
|
});
|
|
845
876
|
});
|
|
@@ -848,13 +879,16 @@ const filteredContacts = computed(() => {
|
|
|
848
879
|
const query = search.value.trim().toLowerCase();
|
|
849
880
|
return contacts.value.filter((contact) => {
|
|
850
881
|
const matchesReader = !selectedReaderId.value || contact.readerId === selectedReaderId.value;
|
|
882
|
+
const readerStatus = getContactReaderStatus(contact);
|
|
851
883
|
const matchesSearch = !query || [
|
|
852
884
|
contact.name,
|
|
853
885
|
contact.location,
|
|
854
886
|
contact.serverAddress,
|
|
855
887
|
contact.dialCode,
|
|
888
|
+
readerStatus,
|
|
856
889
|
].some((value) => String(value || "").toLowerCase().includes(query));
|
|
857
|
-
|
|
890
|
+
const matchesStatus = !statusFilter.value || readerStatus === statusFilter.value;
|
|
891
|
+
return matchesReader && matchesSearch && matchesStatus;
|
|
858
892
|
});
|
|
859
893
|
});
|
|
860
894
|
|
|
@@ -890,8 +924,8 @@ const tableHeaders = computed(() =>
|
|
|
890
924
|
: [
|
|
891
925
|
{ title: "Name", value: "name", sortable: false },
|
|
892
926
|
{ title: "Location", value: "location", sortable: false },
|
|
893
|
-
{ title: "SIP
|
|
894
|
-
{ title: "
|
|
927
|
+
{ title: "SIP Number / Address", value: "serverAddress", sortable: false },
|
|
928
|
+
{ title: "Reader SIP Status", value: "contactStatus", sortable: false },
|
|
895
929
|
{ title: "", value: "action-table", sortable: false },
|
|
896
930
|
]
|
|
897
931
|
);
|
|
@@ -1034,8 +1068,9 @@ async function reload() {
|
|
|
1034
1068
|
if (!readers.value.some((reader) => reader._id === selectedReaderId.value)) {
|
|
1035
1069
|
selectedReaderId.value = readers.value[0]?._id || "";
|
|
1036
1070
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1071
|
+
intercomLoaded.value = false;
|
|
1072
|
+
contactsLoaded.value = false;
|
|
1073
|
+
await loadActiveTab();
|
|
1039
1074
|
} catch (error: any) {
|
|
1040
1075
|
showMessage(getErrorMessage(error), "error");
|
|
1041
1076
|
} finally {
|
|
@@ -1043,10 +1078,36 @@ async function reload() {
|
|
|
1043
1078
|
}
|
|
1044
1079
|
}
|
|
1045
1080
|
|
|
1046
|
-
async function
|
|
1047
|
-
|
|
1081
|
+
async function loadActiveTab() {
|
|
1082
|
+
if (activeTab.value === "intercom") {
|
|
1083
|
+
await loadIntercomRows();
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
await Promise.all([
|
|
1088
|
+
intercomLoaded.value ? Promise.resolve() : loadIntercomRows(),
|
|
1089
|
+
loadContacts(),
|
|
1090
|
+
]);
|
|
1091
|
+
}
|
|
1048
1092
|
|
|
1049
|
-
|
|
1093
|
+
async function selectTab(tab: IntercomTab) {
|
|
1094
|
+
activeTab.value = tab;
|
|
1095
|
+
page.value = 1;
|
|
1096
|
+
|
|
1097
|
+
if ((tab === "intercom" && intercomLoaded.value) || (tab === "contacts" && contactsLoaded.value)) return;
|
|
1098
|
+
|
|
1099
|
+
loading.value = true;
|
|
1100
|
+
try {
|
|
1101
|
+
await loadActiveTab();
|
|
1102
|
+
} catch (error: unknown) {
|
|
1103
|
+
showMessage(getErrorMessage(error), "error");
|
|
1104
|
+
} finally {
|
|
1105
|
+
loading.value = false;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async function loadIntercomRows() {
|
|
1110
|
+
const rows = await Promise.all(readers.value.map(async (reader): Promise<IntercomRow> => {
|
|
1050
1111
|
const [configurationResult, statusResult] = await Promise.allSettled([
|
|
1051
1112
|
getReaderConfiguration(reader._id, { pjsip: pjsipKeys }),
|
|
1052
1113
|
getIntercomStatus(reader._id),
|
|
@@ -1058,16 +1119,15 @@ async function loadIntercomRows() {
|
|
|
1058
1119
|
const statusPayload = statusResponse?.data ?? statusResponse;
|
|
1059
1120
|
const pjsip = normalizePjsip(configurationResponse, statusPayload);
|
|
1060
1121
|
const status = normalizeStatus(statusPayload, pjsip);
|
|
1061
|
-
|
|
1062
|
-
}
|
|
1122
|
+
return toIntercomRow(reader, pjsip, status);
|
|
1123
|
+
}));
|
|
1063
1124
|
|
|
1064
1125
|
intercomRows.value = rows;
|
|
1126
|
+
intercomLoaded.value = true;
|
|
1065
1127
|
}
|
|
1066
1128
|
|
|
1067
1129
|
async function loadContacts() {
|
|
1068
|
-
const
|
|
1069
|
-
|
|
1070
|
-
for (const reader of readers.value) {
|
|
1130
|
+
const readerContacts = await Promise.all(readers.value.map(async (reader): Promise<ContactRow[]> => {
|
|
1071
1131
|
try {
|
|
1072
1132
|
const response = await runObjectOperation(reader._id, {
|
|
1073
1133
|
operation: "load",
|
|
@@ -1075,13 +1135,15 @@ async function loadContacts() {
|
|
|
1075
1135
|
fields: ["id", "name", "number"],
|
|
1076
1136
|
limit: 100,
|
|
1077
1137
|
});
|
|
1078
|
-
|
|
1138
|
+
return normalizeContacts(reader, response?.data ?? response);
|
|
1079
1139
|
} catch {
|
|
1080
1140
|
// Some firmware builds may not expose contacts until intercom is configured.
|
|
1141
|
+
return [];
|
|
1081
1142
|
}
|
|
1082
|
-
}
|
|
1143
|
+
}));
|
|
1083
1144
|
|
|
1084
|
-
contacts.value =
|
|
1145
|
+
contacts.value = readerContacts.flat();
|
|
1146
|
+
contactsLoaded.value = true;
|
|
1085
1147
|
}
|
|
1086
1148
|
|
|
1087
1149
|
function paginate<T>(items: T[]) {
|
|
@@ -1398,6 +1460,39 @@ function openContactView(contact: ContactRow) {
|
|
|
1398
1460
|
contactViewDialog.value = true;
|
|
1399
1461
|
}
|
|
1400
1462
|
|
|
1463
|
+
function getContactReaderStatus(contact: ContactRow) {
|
|
1464
|
+
return intercomRows.value.find((row) => row.reader._id === contact.readerId)?.status || "Check status";
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async function testContactSipStatus(contact: ContactRow) {
|
|
1468
|
+
const reader = intercomRows.value.find((row) => row.reader._id === contact.readerId);
|
|
1469
|
+
if (!reader) {
|
|
1470
|
+
showMessage("The HID reader for this contact is not available.", "error");
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
testingContactReaderId.value = contact.readerId;
|
|
1475
|
+
try {
|
|
1476
|
+
await testSipConnection(reader);
|
|
1477
|
+
} finally {
|
|
1478
|
+
testingContactReaderId.value = "";
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function openContactCall(contact: ContactRow) {
|
|
1483
|
+
const reader = intercomRows.value.find((row) => row.reader._id === contact.readerId);
|
|
1484
|
+
if (!reader) {
|
|
1485
|
+
showMessage("The HID reader for this contact is not available.", "error");
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
selectedRow.value = reader;
|
|
1490
|
+
makeCallForm.target = contact.serverAddress === "N/A" ? "" : contact.serverAddress;
|
|
1491
|
+
makeCallForm.status = "";
|
|
1492
|
+
makeCallForm.statusType = "info";
|
|
1493
|
+
makeCallDialog.value = true;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1401
1496
|
function openDeleteContact(contact: ContactRow) {
|
|
1402
1497
|
selectedContact.value = contact;
|
|
1403
1498
|
deleteContactDialog.value = true;
|
|
@@ -1517,6 +1612,52 @@ async function runHangupCall() {
|
|
|
1517
1612
|
}
|
|
1518
1613
|
}
|
|
1519
1614
|
|
|
1615
|
+
async function testSipConnection(row: IntercomRow) {
|
|
1616
|
+
const readerId = toText(row.reader._id);
|
|
1617
|
+
if (!readerId || testingSipReaderId.value) return;
|
|
1618
|
+
|
|
1619
|
+
testingSipReaderId.value = readerId;
|
|
1620
|
+
try {
|
|
1621
|
+
const response = await getIntercomStatus(readerId);
|
|
1622
|
+
const responseRecord = toRecord(response);
|
|
1623
|
+
const statusPayload = toRecord(responseRecord.data || responseRecord);
|
|
1624
|
+
const status = Number(statusPayload.status);
|
|
1625
|
+
|
|
1626
|
+
await reload();
|
|
1627
|
+
|
|
1628
|
+
if (status === 200) {
|
|
1629
|
+
showMessage(`${row.name}: SIP is connected.`, "success");
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
if (status === -1) {
|
|
1633
|
+
showMessage(`${row.name}: SIP is disabled on the HID reader.`, "warning");
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
if (status === 0 || status === 100) {
|
|
1637
|
+
showMessage(`${row.name}: SIP is still connecting. Check the SIP settings on the HID reader.`, "warning");
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
if (status === 401 || status === 403) {
|
|
1641
|
+
showMessage(`${row.name}: SIP authentication failed. Check the account configured on the HID reader.`, "error");
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
if (status === 408) {
|
|
1645
|
+
showMessage(`${row.name}: The HID reader cannot reach the SIP server.`, "error");
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
if (status === 503) {
|
|
1649
|
+
showMessage(`${row.name}: The HID reader reported a SIP network failure.`, "error");
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
showMessage(`${row.name}: SIP is not connected. Configure SIP on the HID reader and test again.`, "warning");
|
|
1654
|
+
} catch (error: unknown) {
|
|
1655
|
+
showMessage(getErrorMessage(error), "error");
|
|
1656
|
+
} finally {
|
|
1657
|
+
testingSipReaderId.value = "";
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1520
1661
|
async function savePjsip(readerId: string, payload: Record<string, string>, options: { showSuccessMessage?: boolean } = {}) {
|
|
1521
1662
|
saving.value = true;
|
|
1522
1663
|
try {
|
|
@@ -1540,6 +1681,11 @@ async function saveContact() {
|
|
|
1540
1681
|
return;
|
|
1541
1682
|
}
|
|
1542
1683
|
|
|
1684
|
+
if (!isSipCallTarget(contactForm.serverAddress)) {
|
|
1685
|
+
showMessage("Enter a valid extension, SIP address, or IPv4 address.", "warning");
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1543
1689
|
contactsSaving.value = true;
|
|
1544
1690
|
try {
|
|
1545
1691
|
if (contactForm.id) {
|