@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.
@@ -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
- * The MULTI select. Native has no dropdown for `multiple` - only an
644
- * always-open list box - so multi wears the same field chrome as a button and
645
- * opens a popover. The chosen values are `StatusChip pill tone-neutral`, the
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`, `AppDateField` and the native path of `AppSelect`
670
- * without three templates having to remember to add it. 0.55 is
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;
@@ -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. A native
6
- `<select>` under our chrome: the option list, the keyboard and the mobile
7
- wheel are the browser's, so there is no menu to build and nothing to get
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
- The single-value path below is byte-for-byte the one it has always been: a
17
- consumer that passes neither `multiple` nor `return-object` still emits
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
- <label
19
+ <v-menu
22
20
  v-if="!multiple"
23
- v-bind="$attrs"
24
- class="app-field"
25
- :class="{ 'app-field--compact': compact }"
21
+ :close-on-content-click="true"
22
+ :disabled="disabled"
23
+ location="bottom start"
24
+ offset="4"
26
25
  >
27
- <span v-if="label" class="app-field__label">{{ label }}</span>
28
- <select
29
- class="app-field__input"
30
- :value="singleValue"
31
- :disabled="disabled"
32
- @change="onNativeChange"
33
- >
34
- <!-- `clearable` on a native select is the empty option, not an x. -->
35
- <option v-if="clearable" value="">{{ placeholder }}</option>
36
- <option v-for="opt in normalized" :key="String(opt.value)" :value="opt.value">
37
- {{ opt.title }}
38
- </option>
39
- </select>
40
- <v-icon class="app-field__icon" icon="mdi-chevron-down" size="17" />
41
- </label>
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 a class on `<AppSelect multiple>` has
118
- to be put on the activator by hand. The single branch lands where it always
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 singleValue = computed(() =>
161
- props.returnObject ? keyOf(model.value) : model.value
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
- function onNativeChange(event: Event) {
165
- const value = (event.target as HTMLSelectElement).value;
166
- model.value = props.returnObject
167
- ? normalized.value.find((o) => String(o.value) === value)?.raw ?? value
168
- : value;
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 v-model="status" :items="statusOptions" />
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("Status");
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: "Status",
354
+ status: "",
342
355
  });
343
356
 
344
- const statusOptions = ["Status", "Authorized", "Not Authorized", "Unknown"];
345
- const accessMethodOptions = ["All", "Facial", "QR Code", "ID and Password", "PIN", "Card"];
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 === "Status" || row.status === 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 === "Status" || row.status === 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
- const { data: siteData } = await useLazyAsyncData(
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="activeTab = tab.value"
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 v-model="statusFilter" :items="statusOptions" />
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 title="SIP Settings" @click="openSipDialog(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="Location" />
560
- <AppField v-model="contactForm.location" placeholder="Location" />
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("Status");
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 = ["Status", "Connected", "Not connected", "Connecting", "Disabled", "Failed"];
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 === "Status" || row.status === 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
- return matchesReader && matchesSearch;
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 Server Address", value: "serverAddress", sortable: false },
894
- { title: "Dial Code", value: "dialCode", sortable: false },
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
- await loadIntercomRows();
1038
- await loadContacts();
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 loadIntercomRows() {
1047
- const rows: IntercomRow[] = [];
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
- for (const reader of readers.value) {
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
- rows.push(toIntercomRow(reader, pjsip, status));
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 rows: ContactRow[] = [];
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
- rows.push(...normalizeContacts(reader, response?.data ?? response));
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 = rows;
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) {