@7365admin1/layer-common 4.0.3-staging.232 → 4.0.3-staging.233
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/components/HidAccessLogDashboard.vue +16 -77
- package/components/HidQrCodeConfiguration.vue +37 -38
- package/components/HidReaderManagement.vue +18 -284
- package/components/HidReaderUserRoster.vue +303 -60
- package/components/HidUserEnrollment.vue +26 -43
- package/components/VisitorForm.vue +25 -10
- package/composables/useHidAmico.ts +1 -0
- package/composables/useHidNavigation.ts +0 -7
- package/package.json +1 -1
- package/components/HidUserMapping.vue +0 -583
- package/pages/[org]/[site]/access-mgmt/hid-user-mapping/index.vue +0 -23
|
@@ -138,10 +138,6 @@
|
|
|
138
138
|
</StatusChip>
|
|
139
139
|
</template>
|
|
140
140
|
|
|
141
|
-
<template #[`item.mappingStatus`]="{ item }">
|
|
142
|
-
<StatusChip :status="item.mappingStatus" />
|
|
143
|
-
</template>
|
|
144
|
-
|
|
145
141
|
<template #[`item.action-table`]="{ item }">
|
|
146
142
|
<AppButton
|
|
147
143
|
variant="row"
|
|
@@ -307,7 +303,7 @@ const props = defineProps({
|
|
|
307
303
|
},
|
|
308
304
|
});
|
|
309
305
|
|
|
310
|
-
type AccessTab = "all" | "
|
|
306
|
+
type AccessTab = "all" | "visitor" | "administrator";
|
|
311
307
|
type IdentityCategory = "resident" | "visitor" | "administrator";
|
|
312
308
|
type AuthorizationStatus = "Authorized" | "Not Authorized" | "Unknown";
|
|
313
309
|
type AccessRow = {
|
|
@@ -374,7 +370,6 @@ type JsPdf = {
|
|
|
374
370
|
|
|
375
371
|
const {
|
|
376
372
|
getReaders,
|
|
377
|
-
getIdentities,
|
|
378
373
|
getLogs,
|
|
379
374
|
getUserImage,
|
|
380
375
|
getReaderUsers,
|
|
@@ -519,7 +514,6 @@ const filteredRows = computed(() => {
|
|
|
519
514
|
row.method,
|
|
520
515
|
row.lastAccessTime,
|
|
521
516
|
row.status,
|
|
522
|
-
row.mappingStatus,
|
|
523
517
|
].some((value) => String(value ?? "").toLowerCase().includes(query));
|
|
524
518
|
|
|
525
519
|
const matchesStatus = !status.value || row.status === status.value;
|
|
@@ -531,12 +525,10 @@ const filteredRows = computed(() => {
|
|
|
531
525
|
|
|
532
526
|
/**
|
|
533
527
|
* The source of truth is the Amico access log, so All is the initial view.
|
|
534
|
-
* The other tabs are audit filters
|
|
528
|
+
* The other tabs are Amico audit filters.
|
|
535
529
|
*/
|
|
536
530
|
const tabOptions: Array<{ label: string; value: AccessTab }> = [
|
|
537
531
|
{ label: "All", value: "all" },
|
|
538
|
-
{ label: "Unmapped", value: "unmapped" },
|
|
539
|
-
{ label: "Mapped", value: "mapped" },
|
|
540
532
|
{ label: "Visitor", value: "visitor" },
|
|
541
533
|
{ label: "Administrator", value: "administrator" },
|
|
542
534
|
];
|
|
@@ -553,7 +545,6 @@ const logHeaders = [
|
|
|
553
545
|
{ title: "Access Method", value: "method", sortable: false },
|
|
554
546
|
{ title: "Access Date and Time", value: "lastAccessTime", sortable: false },
|
|
555
547
|
{ title: "Status", value: "status", sortable: false },
|
|
556
|
-
{ title: "iService Mapping", value: "mappingStatus", sortable: false },
|
|
557
548
|
{ title: "", value: "action-table", sortable: false },
|
|
558
549
|
];
|
|
559
550
|
|
|
@@ -626,15 +617,7 @@ async function loadDashboard() {
|
|
|
626
617
|
resetDashboardData();
|
|
627
618
|
try {
|
|
628
619
|
const isVisitorTab = activeTab.value === "visitor";
|
|
629
|
-
const [
|
|
630
|
-
loadDashboardSource(
|
|
631
|
-
getIdentities(selectedReaderId.value, {
|
|
632
|
-
page: page.value,
|
|
633
|
-
limit: 100,
|
|
634
|
-
}),
|
|
635
|
-
null,
|
|
636
|
-
"identities",
|
|
637
|
-
),
|
|
620
|
+
const [logResponse, hidAccessLogResponse, visitorResponse, readerUserResponse] = await Promise.all([
|
|
638
621
|
loadDashboardSource(
|
|
639
622
|
getLogs(selectedReaderId.value, {
|
|
640
623
|
page: page.value,
|
|
@@ -693,16 +676,8 @@ async function loadDashboard() {
|
|
|
693
676
|
|
|
694
677
|
if (sequence !== loadSequence.value) return;
|
|
695
678
|
|
|
696
|
-
const loadedIdentities = identityResponse?.items ?? identityResponse?.data?.items ?? identityResponse?.data?.identities ?? [];
|
|
697
679
|
const liveItems = hidAccessLogResponse?.items ?? hidAccessLogResponse?.data?.items ?? [];
|
|
698
680
|
const readerUsers = readerUserResponse?.items ?? readerUserResponse?.data?.items ?? [];
|
|
699
|
-
const embeddedIdentities = liveItems
|
|
700
|
-
.map((item: Record<string, unknown>) => item.identity)
|
|
701
|
-
.filter(isUnknownRecord);
|
|
702
|
-
const mergedIdentities = mergeReaderUsersWithIdentities(
|
|
703
|
-
readerUsers,
|
|
704
|
-
[...loadedIdentities, ...embeddedIdentities],
|
|
705
|
-
);
|
|
706
681
|
const loadedVisitors = visitorResponse?.items ?? visitorResponse?.data?.items ?? visitorResponse?.data?.visitors ?? [];
|
|
707
682
|
visitorRecords.value = Object.fromEntries(
|
|
708
683
|
loadedVisitors.map((visitor: HidAccessRecord) => [String(visitor._id), visitor]),
|
|
@@ -713,8 +688,10 @@ async function loadDashboard() {
|
|
|
713
688
|
.map((item: Record<string, unknown>) => toHidNumericId(item.hidUserId ?? item.user_id))
|
|
714
689
|
.filter((userId: number | undefined): userId is number => Boolean(userId)),
|
|
715
690
|
);
|
|
716
|
-
|
|
717
|
-
|
|
691
|
+
// The reader is the source of HID identities. Do not merge old iService
|
|
692
|
+
// identity metadata here: it can contain a stale photo from another user.
|
|
693
|
+
allIdentities.value = readerUsers;
|
|
694
|
+
identities.value = readerUsers;
|
|
718
695
|
logs.value = liveItems.length
|
|
719
696
|
? []
|
|
720
697
|
: logResponse?.items ?? logResponse?.data?.items ?? logResponse?.data?.logs ?? [];
|
|
@@ -728,7 +705,7 @@ async function loadDashboard() {
|
|
|
728
705
|
hidUserId: String(hidUserId),
|
|
729
706
|
}));
|
|
730
707
|
await loadUserImages(
|
|
731
|
-
[...
|
|
708
|
+
[...readerUsers, ...accessLogImageCandidates],
|
|
732
709
|
accessLogUserIds,
|
|
733
710
|
);
|
|
734
711
|
} finally {
|
|
@@ -756,7 +733,6 @@ async function loadUserImages(items: HidAccessRecord[], forceUserIds = new Set<n
|
|
|
756
733
|
if (
|
|
757
734
|
!hidUserId ||
|
|
758
735
|
key in userImages.value ||
|
|
759
|
-
identity?.metadata?.photo ||
|
|
760
736
|
(!forceUserIds.has(hidUserId) && !hasHidImageHint(identity))
|
|
761
737
|
) return;
|
|
762
738
|
|
|
@@ -784,11 +760,9 @@ function hasHidImageHint(identity: HidAccessRecord) {
|
|
|
784
760
|
return Boolean(
|
|
785
761
|
identity?.metadata?.imageTimestamp ||
|
|
786
762
|
identity?.metadata?.facialData ||
|
|
787
|
-
identity?.metadata?.photo ||
|
|
788
763
|
identity?.imageTimestamp ||
|
|
789
764
|
identity?.image_timestamp ||
|
|
790
|
-
identity?.facialData
|
|
791
|
-
identity?.photo,
|
|
765
|
+
identity?.facialData,
|
|
792
766
|
);
|
|
793
767
|
}
|
|
794
768
|
|
|
@@ -802,38 +776,6 @@ async function onTabChange(tab: AccessTab) {
|
|
|
802
776
|
loadDashboard();
|
|
803
777
|
}
|
|
804
778
|
|
|
805
|
-
function mergeReaderUsersWithIdentities(readerUsers: HidAccessRecord[], identities: HidAccessRecord[]) {
|
|
806
|
-
const identityById = new Map<string, HidAccessRecord>();
|
|
807
|
-
identities.forEach((identity) => {
|
|
808
|
-
const id = toHidNumericId(identity.hidUserId);
|
|
809
|
-
if (id) identityById.set(String(id), identity);
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
const mergedReaderUsers = readerUsers.map((readerUser) => {
|
|
813
|
-
const identity = identityById.get(String(toHidNumericId(readerUser.hidUserId)));
|
|
814
|
-
if (!identity) return readerUser;
|
|
815
|
-
|
|
816
|
-
return {
|
|
817
|
-
...readerUser,
|
|
818
|
-
...identity,
|
|
819
|
-
hidUserId: identity.hidUserId || readerUser.hidUserId,
|
|
820
|
-
rawHidUserId: readerUser.rawHidUserId,
|
|
821
|
-
name: identity.metadata?.name || identity.name || readerUser.name,
|
|
822
|
-
registration: identity.registration || readerUser.registration,
|
|
823
|
-
cardNo: identity.cardNo || readerUser.cardNo,
|
|
824
|
-
metadata: {
|
|
825
|
-
...readerUser.metadata,
|
|
826
|
-
...identity.metadata,
|
|
827
|
-
imageTimestamp: identity.metadata?.imageTimestamp || readerUser.metadata?.imageTimestamp,
|
|
828
|
-
},
|
|
829
|
-
};
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
const readerUserIds = new Set(readerUsers.map((user) => String(toHidNumericId(user.hidUserId))));
|
|
833
|
-
const identitiesOnly = identities.filter((identity) => !readerUserIds.has(String(toHidNumericId(identity.hidUserId))));
|
|
834
|
-
return [...mergedReaderUsers, ...identitiesOnly];
|
|
835
|
-
}
|
|
836
|
-
|
|
837
779
|
function resetDashboardData() {
|
|
838
780
|
selectedHistoryRow.value = undefined;
|
|
839
781
|
identities.value = [];
|
|
@@ -841,6 +783,7 @@ function resetDashboardData() {
|
|
|
841
783
|
visitorRecords.value = {};
|
|
842
784
|
logs.value = [];
|
|
843
785
|
liveAccessLogs.value = [];
|
|
786
|
+
userImages.value = {};
|
|
844
787
|
administratorUserIds.value = new Set();
|
|
845
788
|
serverPages.value = 1;
|
|
846
789
|
serverPageRange.value = "";
|
|
@@ -849,8 +792,6 @@ function resetDashboardData() {
|
|
|
849
792
|
function isVisibleForActiveTab(row: AccessRow) {
|
|
850
793
|
const identity = findIdentityByKey(row.identityKey);
|
|
851
794
|
if (activeTab.value === "all") return true;
|
|
852
|
-
if (activeTab.value === "unmapped") return !isMappedIdentity(identity);
|
|
853
|
-
if (activeTab.value === "mapped") return isMappedIdentity(identity);
|
|
854
795
|
return classifyIdentity(identity, row) === activeTab.value;
|
|
855
796
|
}
|
|
856
797
|
|
|
@@ -912,12 +853,7 @@ function getName(identity: HidAccessRecord) {
|
|
|
912
853
|
}
|
|
913
854
|
|
|
914
855
|
function getFacialData(identity: HidAccessRecord) {
|
|
915
|
-
return identity.metadata?.facialData || identity.metadata?.imageTimestamp ||
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
function getUserImageSrc(identity: HidAccessRecord) {
|
|
919
|
-
const hidUserId = toHidNumericId(identity?.hidUserId);
|
|
920
|
-
return identity?.metadata?.photo || (hidUserId ? userImages.value[String(hidUserId)] : "") || "";
|
|
856
|
+
return identity.metadata?.facialData || identity.metadata?.imageTimestamp || "N/A";
|
|
921
857
|
}
|
|
922
858
|
|
|
923
859
|
function getUnit(identity: HidAccessRecord) {
|
|
@@ -953,7 +889,7 @@ function getAccessMethod(identity: HidAccessRecord, log?: HidAccessRecord) {
|
|
|
953
889
|
if (payload.confidence || payload.user_id || payload.userId || payload.hidUserId) return "Facial";
|
|
954
890
|
if (payload.password_value || payload.password || isValidIdentifierId(payload.identifier_id)) return "ID and Password";
|
|
955
891
|
if (identity.metadata?.pinPassword) return "ID and Password";
|
|
956
|
-
return identity.metadata?.
|
|
892
|
+
return identity.metadata?.facialData || identity.metadata?.imageTimestamp ? "Facial" : "N/A";
|
|
957
893
|
}
|
|
958
894
|
|
|
959
895
|
function formatDate(value?: string | number) {
|
|
@@ -1177,6 +1113,9 @@ function mapAccessLogToRow(rawLog: HidAccessRecord, event: HidAccessRecord, inde
|
|
|
1177
1113
|
const mapped = isMappedIdentity(identity);
|
|
1178
1114
|
const eventIdentity = event.payload?.identity || event.payload?.identityLookup || {};
|
|
1179
1115
|
const rawAccessTime = getAccessLogTime(rawLog, event);
|
|
1116
|
+
const logHidUserId = toHidNumericId(
|
|
1117
|
+
rawLog.user_id ?? rawLog.userId ?? rawLog.hidUserId ?? identity?.hidUserId,
|
|
1118
|
+
);
|
|
1180
1119
|
const identityKey = identity ? getIdentityKey(identity) : getLogIdentityKey({ ...event, payload: { ...event.payload, rawAccessLog: rawLog } });
|
|
1181
1120
|
|
|
1182
1121
|
return {
|
|
@@ -1189,7 +1128,7 @@ function mapAccessLogToRow(rawLog: HidAccessRecord, event: HidAccessRecord, inde
|
|
|
1189
1128
|
name: getRawLogName(rawLog, event, identity),
|
|
1190
1129
|
hidUserId: formatHidUid(rawLog.user_id ?? rawLog.userId ?? rawLog.hidUserId ?? identity?.hidUserId),
|
|
1191
1130
|
facialData: identity ? getFacialData(identity) : "N/A",
|
|
1192
|
-
faceImage:
|
|
1131
|
+
faceImage: logHidUserId ? userImages.value[String(logHidUserId)] || "" : "",
|
|
1193
1132
|
unit: identity ? getUnit(identity) : "N/A",
|
|
1194
1133
|
method: getAccessMethod(identity || {}, { ...event, payload: { ...event.payload, rawAccessLog: rawLog } }),
|
|
1195
1134
|
lastAccessTime: formatDate(rawAccessTime),
|
|
@@ -21,19 +21,26 @@
|
|
|
21
21
|
<div class="config-body">
|
|
22
22
|
<div class="section-label config-body__title">HID Configuration</div>
|
|
23
23
|
|
|
24
|
-
<div class="
|
|
25
|
-
<div>
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
<div class="form-section">
|
|
25
|
+
<div class="section-label">Visitor QR Reader</div>
|
|
26
|
+
<p class="section-description">
|
|
27
|
+
Select the HID reader and portal used when creating a Visitor QR pass.
|
|
28
|
+
</p>
|
|
29
|
+
<div class="reader-grid">
|
|
30
|
+
<AppSelect
|
|
31
|
+
v-model="form.readerId"
|
|
32
|
+
:items="visitorQrReaderOptions"
|
|
33
|
+
placeholder="Select a HID reader"
|
|
34
|
+
:disabled="Boolean(savingSection) || visitorQrReaderOptions.length === 0"
|
|
35
|
+
/>
|
|
36
|
+
<AppButton
|
|
37
|
+
icon="mdi-content-save-outline"
|
|
38
|
+
:disabled="Boolean(savingSection) || !form.readerId"
|
|
39
|
+
@click="saveSection('reader')"
|
|
40
|
+
>
|
|
41
|
+
Save Reader
|
|
42
|
+
</AppButton>
|
|
28
43
|
</div>
|
|
29
|
-
<v-switch
|
|
30
|
-
v-model="form.onlineMode"
|
|
31
|
-
class="app-switch"
|
|
32
|
-
hide-details
|
|
33
|
-
:loading="savingSection === 'online-mode'"
|
|
34
|
-
:disabled="Boolean(savingSection) || !form.readerId"
|
|
35
|
-
@update:model-value="saveOnlineMode"
|
|
36
|
-
/>
|
|
37
44
|
</div>
|
|
38
45
|
|
|
39
46
|
<v-divider />
|
|
@@ -48,7 +55,6 @@
|
|
|
48
55
|
v-for="method in identificationMethodOptions"
|
|
49
56
|
:key="method.key"
|
|
50
57
|
class="method-control"
|
|
51
|
-
:class="{ 'method-control--disabled': !form.onlineMode }"
|
|
52
58
|
>
|
|
53
59
|
<span>{{ method.label }}</span>
|
|
54
60
|
<v-switch
|
|
@@ -57,7 +63,7 @@
|
|
|
57
63
|
hide-details
|
|
58
64
|
density="compact"
|
|
59
65
|
:loading="savingSection === `method-${method.key}`"
|
|
60
|
-
:disabled="Boolean(savingSection) || !form.
|
|
66
|
+
:disabled="Boolean(savingSection) || !form.readerId"
|
|
61
67
|
@update:model-value="saveIdentificationMethod(method.key)"
|
|
62
68
|
/>
|
|
63
69
|
</div>
|
|
@@ -368,7 +374,6 @@ const {
|
|
|
368
374
|
getReaders,
|
|
369
375
|
getReaderConfiguration,
|
|
370
376
|
setReaderConfiguration,
|
|
371
|
-
setReaderOperatingMode,
|
|
372
377
|
getSitePermissions,
|
|
373
378
|
getPermissionCandidates,
|
|
374
379
|
updateSitePermissions,
|
|
@@ -436,6 +441,10 @@ const savingPermissions = ref(false);
|
|
|
436
441
|
const hasPrinterConfig = computed(() =>
|
|
437
442
|
Boolean(form.printer.vendorId && form.printer.productId),
|
|
438
443
|
);
|
|
444
|
+
const visitorQrReaderOptions = computed(() => readers.value.map((reader) => ({
|
|
445
|
+
title: String(reader.name || reader.deviceId || reader._id) + " - " + (reader.portalName || "Portal not configured"),
|
|
446
|
+
value: reader._id,
|
|
447
|
+
})));
|
|
439
448
|
const identificationMethodOptions: Array<{
|
|
440
449
|
key: HidIdentificationMethod;
|
|
441
450
|
label: string;
|
|
@@ -537,7 +546,9 @@ async function loadConfig() {
|
|
|
537
546
|
const configuredMethods = config.identificationMethods;
|
|
538
547
|
Object.assign(form, {
|
|
539
548
|
enabled: Boolean(config.enabled),
|
|
540
|
-
|
|
549
|
+
// Visitor QR is reader-local. Do not restore a legacy Online Mode value
|
|
550
|
+
// from the site record and accidentally make a scan depend on Monitor.
|
|
551
|
+
onlineMode: false,
|
|
541
552
|
readerId: String(config.readerId || readers.value[0]?._id || ""),
|
|
542
553
|
qrFormat: "0",
|
|
543
554
|
identificationMethods: {
|
|
@@ -705,7 +716,6 @@ async function loadReaderConfiguration() {
|
|
|
705
716
|
|
|
706
717
|
try {
|
|
707
718
|
const response = await getReaderConfiguration(form.readerId, {
|
|
708
|
-
general: ["online"],
|
|
709
719
|
identifier: [
|
|
710
720
|
"card_identification_enabled",
|
|
711
721
|
"pin_identification_enabled",
|
|
@@ -713,12 +723,8 @@ async function loadReaderConfiguration() {
|
|
|
713
723
|
],
|
|
714
724
|
});
|
|
715
725
|
const configuration = getConfigurationData(response);
|
|
716
|
-
const general = isUnknownRecord(configuration.general) ? configuration.general : {};
|
|
717
726
|
const identifier = isUnknownRecord(configuration.identifier) ? configuration.identifier : {};
|
|
718
727
|
|
|
719
|
-
if (general.online !== undefined) {
|
|
720
|
-
form.onlineMode = isEnabledConfiguration(general.online);
|
|
721
|
-
}
|
|
722
728
|
if (identifier.pin_identification_enabled !== undefined) {
|
|
723
729
|
const pinEnabled = isEnabledConfiguration(identifier.pin_identification_enabled);
|
|
724
730
|
form.identificationMethods.pin = pinEnabled;
|
|
@@ -739,7 +745,7 @@ function buildPayload() {
|
|
|
739
745
|
...(siteData.value?.metadata || {}),
|
|
740
746
|
hidQrCodePass: {
|
|
741
747
|
enabled: form.identificationMethods.qrCode,
|
|
742
|
-
onlineMode:
|
|
748
|
+
onlineMode: false,
|
|
743
749
|
readerId: form.readerId,
|
|
744
750
|
// Visitor QR passes always use Amico alphanumeric qrcodes.
|
|
745
751
|
qrFormat: "0",
|
|
@@ -775,22 +781,6 @@ async function saveSection(section: string) {
|
|
|
775
781
|
}
|
|
776
782
|
}
|
|
777
783
|
|
|
778
|
-
async function saveOnlineMode() {
|
|
779
|
-
if (!props.site || !form.readerId) return;
|
|
780
|
-
|
|
781
|
-
savingSection.value = "online-mode";
|
|
782
|
-
try {
|
|
783
|
-
await setReaderOperatingMode(form.readerId, form.onlineMode ? "online" : "standalone");
|
|
784
|
-
await updateSitebyId(props.site, buildPayload());
|
|
785
|
-
showMessage("Online mode updated.", "success");
|
|
786
|
-
} catch (error: unknown) {
|
|
787
|
-
await loadConfig();
|
|
788
|
-
showMessage(errorConverter(error), "error");
|
|
789
|
-
} finally {
|
|
790
|
-
savingSection.value = "";
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
784
|
async function saveIdentificationMethod(method: HidIdentificationMethod) {
|
|
795
785
|
if (!props.site) return;
|
|
796
786
|
|
|
@@ -991,6 +981,13 @@ function showMessage(text: string, color: string) {
|
|
|
991
981
|
align-items: end;
|
|
992
982
|
}
|
|
993
983
|
|
|
984
|
+
.reader-grid {
|
|
985
|
+
display: grid;
|
|
986
|
+
grid-template-columns: minmax(260px, 1fr) minmax(150px, 220px);
|
|
987
|
+
gap: 14px;
|
|
988
|
+
align-items: end;
|
|
989
|
+
}
|
|
990
|
+
|
|
994
991
|
.template-grid {
|
|
995
992
|
display: grid;
|
|
996
993
|
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr);
|
|
@@ -1337,6 +1334,7 @@ function showMessage(text: string, color: string) {
|
|
|
1337
1334
|
}
|
|
1338
1335
|
|
|
1339
1336
|
.printer-grid,
|
|
1337
|
+
.reader-grid,
|
|
1340
1338
|
.template-grid,
|
|
1341
1339
|
.validity-grid,
|
|
1342
1340
|
.permission-location,
|
|
@@ -1352,6 +1350,7 @@ function showMessage(text: string, color: string) {
|
|
|
1352
1350
|
@media (max-width: 700px) {
|
|
1353
1351
|
.online-mode-row,
|
|
1354
1352
|
.printer-grid,
|
|
1353
|
+
.reader-grid,
|
|
1355
1354
|
.template-grid,
|
|
1356
1355
|
.validity-grid,
|
|
1357
1356
|
.methods-grid,
|