@7365admin1/layer-common 4.84.0 → 4.84.1-staging.482

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/assets/css/primitives.css +107 -0
  3. package/assets/css/screens.css +8 -4
  4. package/components/AccessCardDetailsDialog.vue +57 -4
  5. package/components/AccessCardPreviewDialog.vue +291 -50
  6. package/components/AppSelect.vue +143 -4
  7. package/components/AttendanceDetailsDialog.vue +48 -1
  8. package/components/AttendanceMain.vue +104 -10
  9. package/components/AttendanceProxyDialog.vue +391 -0
  10. package/components/AttendanceSettingsDialog.vue +53 -1
  11. package/components/BuildingUnitFormEdit.vue +6 -0
  12. package/components/BuildingUnitManagement.vue +298 -0
  13. package/components/BulletinBoardView.vue +1 -1
  14. package/components/DashboardEmptyState.vue +8 -0
  15. package/components/DashboardMain.vue +14 -0
  16. package/components/DashboardPanel.vue +10 -0
  17. package/components/Dialog/UpdateMoreAction.vue +11 -1
  18. package/components/DocumentForm.vue +407 -112
  19. package/components/DocumentManagement.vue +213 -12
  20. package/components/Facility/BookingSetup.vue +63 -0
  21. package/components/HidAccessLogDashboard.vue +74 -17
  22. package/components/HidAccessPermissions.vue +424 -0
  23. package/components/HidIntercomManagement.vue +183 -14
  24. package/components/HidProfileQrCode.vue +332 -0
  25. package/components/HidQrCodeConfiguration.vue +17 -195
  26. package/components/HidReaderManagement.vue +398 -0
  27. package/components/HidReaderUserRoster.vue +44 -3
  28. package/components/HidUserEnrollment.vue +2239 -276
  29. package/components/InventoryMain.vue +11 -2
  30. package/components/InvitationClientForm.vue +19 -1
  31. package/components/MemberServiceProviders.vue +43 -45
  32. package/components/MyAttendanceMain.vue +60 -2
  33. package/components/NotificationBell.vue +14 -2
  34. package/components/TableMain.vue +22 -7
  35. package/components/VisitorForm.vue +12 -0
  36. package/components/VisitorManagement.vue +279 -7
  37. package/composables/useAttendance.ts +53 -0
  38. package/composables/useAttendancePermission.ts +25 -0
  39. package/composables/useCleaningPermission.ts +5 -0
  40. package/composables/useDocument.ts +7 -0
  41. package/composables/useFacility.ts +11 -0
  42. package/composables/useHidAmico.ts +165 -0
  43. package/composables/useHidNavigation.ts +25 -1
  44. package/composables/useHidReaderSelection.ts +50 -0
  45. package/composables/useLandscapingPermission.ts +5 -0
  46. package/composables/useMember.ts +12 -0
  47. package/composables/useNFCPatrolReportFilters.ts +21 -3
  48. package/composables/useNFCPatrolRoute.ts +8 -1
  49. package/composables/usePeople.ts +19 -0
  50. package/composables/usePestControlPermission.ts +5 -0
  51. package/composables/usePoolMaintenancePermission.ts +5 -0
  52. package/composables/useSecurityPermission.ts +5 -0
  53. package/composables/useServiceProvider.ts +45 -2
  54. package/composables/useSiteActions.ts +29 -0
  55. package/composables/useSiteCategory.ts +45 -0
  56. package/constants/permissions.ts +1 -0
  57. package/nuxt.config.ts +18 -0
  58. package/package.json +4 -2
  59. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  60. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  61. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
  62. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  63. package/plugins/vuetify.ts +6 -0
  64. package/server/plugins/no-powered-by.ts +7 -0
  65. package/types/attendance.d.ts +13 -1
  66. package/types/document.d.ts +14 -0
  67. package/types/facility.d.ts +4 -0
  68. package/types/member.d.ts +4 -0
  69. package/types/people.d.ts +28 -1
  70. package/types/service-provider.d.ts +5 -0
  71. package/types/site.d.ts +7 -1
  72. package/utils/attendance-proxy.ts +110 -0
  73. package/utils/hid-enrolment-subject.ts +344 -0
  74. package/utils/hid-permission-assignments.ts +198 -0
  75. package/utils/hid-reader-selection.ts +52 -0
  76. package/utils/notification-target.ts +43 -4
  77. package/utils/occupancy-role.ts +109 -0
  78. package/utils/sanitize-html.ts +16 -0
  79. package/utils/security-headers.ts +52 -0
  80. package/utils/service-type.ts +30 -0
  81. package/utils/site-actions.ts +18 -0
  82. package/utils/site-provider-members.ts +37 -0
@@ -0,0 +1,391 @@
1
+ <template>
2
+ <v-dialog v-model="showDialog" max-width="520" persistent>
3
+ <v-card class="screen-modal" data-test="attendance-proxy-dialog">
4
+ <v-card-title>
5
+ {{ action === "checkIn" ? "Check in for someone" : "Check out for someone" }}
6
+ </v-card-title>
7
+
8
+ <v-card-text class="screen-modal__body">
9
+ <!-- 1. What are we doing? Two big choices. -->
10
+ <v-btn-toggle
11
+ v-model="action"
12
+ mandatory
13
+ divided
14
+ class="w-100 mb-4 proxy__toggle"
15
+ :disabled="submitting"
16
+ >
17
+ <v-btn value="checkIn" class="flex-grow-1 text-none" prepend-icon="mdi-login">Check in</v-btn>
18
+ <v-btn value="checkOut" class="flex-grow-1 text-none" prepend-icon="mdi-logout">Check out</v-btn>
19
+ </v-btn-toggle>
20
+
21
+ <!-- Check-out: today's open check-ins on this device, tap to pick. -->
22
+ <div v-if="action === 'checkOut'" class="mb-4">
23
+ <InputLabel title="Checked in today on this device" />
24
+ <div v-if="openLoading" class="screen-hint">Loading...</div>
25
+ <div v-else-if="openItems.length === 0" class="screen-hint">
26
+ Nobody is checked in on this device today. You can still type the name and NRIC below.
27
+ </div>
28
+ <v-list v-else density="compact" class="proxy__open" data-test="attendance-proxy-open">
29
+ <v-list-item
30
+ v-for="item in openItems"
31
+ :key="item._id"
32
+ :active="picked === item._id"
33
+ @click="pick(item)"
34
+ >
35
+ <v-list-item-title>{{ item.name }} · {{ item.nricMasked }}</v-list-item-title>
36
+ <v-list-item-subtitle>
37
+ In at {{ formatTime(item.checkInTimestamp) }}<span v-if="item.post"> · {{ item.post }}</span>
38
+ </v-list-item-subtitle>
39
+ </v-list-item>
40
+ </v-list>
41
+ </div>
42
+
43
+ <!-- 2. Live photo (required). -->
44
+ <InputLabel title="Photo of the person" required />
45
+ <v-card variant="outlined" class="mb-1 proxy__stage">
46
+ <video
47
+ v-show="cameraOn && !photoPreview"
48
+ ref="videoEl"
49
+ autoplay
50
+ playsinline
51
+ muted
52
+ class="proxy__media"
53
+ />
54
+ <img v-if="photoPreview" :src="photoPreview" class="proxy__media" alt="Captured photo" />
55
+ <div v-if="!cameraOn && !photoPreview" class="proxy__placeholder">
56
+ <v-icon size="48" class="mb-2">mdi-camera</v-icon>
57
+ <span>{{ cameraError || "Starting the camera..." }}</span>
58
+ </div>
59
+ </v-card>
60
+ <div class="d-flex ga-2 mb-4">
61
+ <v-btn
62
+ v-if="!photoPreview"
63
+ class="text-none screen-btn-primary flex-grow-1"
64
+ variant="flat"
65
+ prepend-icon="mdi-camera"
66
+ :disabled="!cameraOn || submitting"
67
+ data-test="attendance-proxy-capture"
68
+ @click="capture"
69
+ >
70
+ Take photo
71
+ </v-btn>
72
+ <v-btn
73
+ v-else
74
+ class="text-none screen-btn-ghost flex-grow-1"
75
+ variant="flat"
76
+ prepend-icon="mdi-camera-retake"
77
+ :disabled="submitting"
78
+ @click="retake"
79
+ >
80
+ Retake
81
+ </v-btn>
82
+ </div>
83
+
84
+ <!-- 3. Who. -->
85
+ <InputLabel title="Name" required />
86
+ <v-text-field
87
+ v-model="name"
88
+ density="comfortable"
89
+ placeholder="e.g. Tan Ah Kow"
90
+ :error-messages="touched ? nameError(name) : ''"
91
+ :disabled="submitting"
92
+ data-test="attendance-proxy-name"
93
+ />
94
+
95
+ <InputLabel title="NRIC / FIN" required />
96
+ <v-text-field
97
+ v-model="nric"
98
+ density="comfortable"
99
+ placeholder="Full number or just the last 4 (e.g. 932P)"
100
+ autocomplete="off"
101
+ :error-messages="touched && !parsed.ok ? parsed.error : ''"
102
+ :hint="parsed.ok ? `Will be saved as ${parsed.masked}` : ''"
103
+ persistent-hint
104
+ :disabled="submitting"
105
+ data-test="attendance-proxy-nric"
106
+ />
107
+
108
+ <template v-if="action === 'checkIn'">
109
+ <InputLabel title="Shift / post (optional)" class="mt-2" />
110
+ <v-text-field
111
+ v-model="post"
112
+ density="comfortable"
113
+ placeholder="e.g. Main gate, night shift"
114
+ :error-messages="touched ? postError(post) : ''"
115
+ :disabled="submitting"
116
+ />
117
+ </template>
118
+
119
+ <v-alert type="info" variant="tonal" density="compact" icon="mdi-shield-lock-outline" class="mt-2">
120
+ Only the last 4 characters of the NRIC are saved. The photo is kept private and
121
+ only people allowed to see attendance can open it.
122
+ </v-alert>
123
+
124
+ <v-alert v-if="error" type="error" variant="tonal" density="compact" class="mt-3" closable @click:close="error = ''">
125
+ {{ error }}
126
+ </v-alert>
127
+ </v-card-text>
128
+
129
+ <div class="screen-modal__footer">
130
+ <v-btn class="text-none screen-btn-ghost" variant="flat" :disabled="submitting" @click="close">
131
+ Cancel
132
+ </v-btn>
133
+ <v-btn
134
+ class="text-none screen-btn-primary"
135
+ variant="flat"
136
+ :prepend-icon="action === 'checkIn' ? 'mdi-login' : 'mdi-logout'"
137
+ :loading="submitting"
138
+ data-test="attendance-proxy-submit"
139
+ @click="submit"
140
+ >
141
+ {{ action === "checkIn" ? "Check in" : "Check out" }}
142
+ </v-btn>
143
+ </div>
144
+ </v-card>
145
+ </v-dialog>
146
+ </template>
147
+
148
+ <script setup lang="ts">
149
+ import useAttendance from "../composables/useAttendance";
150
+ import { nameError, parseNric, postError, proxyPayload } from "../utils/attendance-proxy";
151
+
152
+ const showDialog = defineModel({ type: Boolean, default: false });
153
+ const props = defineProps({
154
+ site: { type: String, required: true },
155
+ serviceType: { type: String, default: "" },
156
+ initialAction: { type: String as () => "checkIn" | "checkOut", default: "checkIn" },
157
+ });
158
+ const emit = defineEmits<{ (e: "saved", message: string): void; (e: "close"): void }>();
159
+
160
+ const { uploadAttendancePhoto, checkIn, proxyCheckOut, getOpenProxyCheckIns } = useAttendance();
161
+
162
+ const action = ref<"checkIn" | "checkOut">(props.initialAction);
163
+ const name = ref("");
164
+ const nric = ref("");
165
+ const post = ref("");
166
+ const touched = ref(false);
167
+ const submitting = ref(false);
168
+ const error = ref("");
169
+ const parsed = computed(() => parseNric(nric.value));
170
+
171
+ const openItems = ref<Array<Record<string, any>>>([]);
172
+ const openLoading = ref(false);
173
+ const picked = ref("");
174
+
175
+ const videoEl = ref<HTMLVideoElement | null>(null);
176
+ const stream = ref<MediaStream | null>(null);
177
+ const cameraOn = ref(false);
178
+ const cameraError = ref("");
179
+ const photoFile = ref<File | null>(null);
180
+ const photoPreview = ref("");
181
+
182
+ function formatTime(ts: unknown) {
183
+ return ts ? new Date(String(ts)).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "-";
184
+ }
185
+
186
+ function pick(item: Record<string, any>) {
187
+ picked.value = item._id;
188
+ name.value = item.name ?? "";
189
+ nric.value = item.nricLast4 ?? "";
190
+ }
191
+
192
+ async function loadOpen() {
193
+ openLoading.value = true;
194
+ try {
195
+ const res = await getOpenProxyCheckIns(props.site, props.serviceType);
196
+ openItems.value = res?.items ?? [];
197
+ } catch {
198
+ openItems.value = [];
199
+ } finally {
200
+ openLoading.value = false;
201
+ }
202
+ }
203
+
204
+ async function startCamera() {
205
+ cameraError.value = "";
206
+ try {
207
+ if (!navigator.mediaDevices?.getUserMedia) throw new Error("unsupported");
208
+ stream.value = await navigator.mediaDevices.getUserMedia({
209
+ video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } },
210
+ audio: false,
211
+ });
212
+ cameraOn.value = true;
213
+ await nextTick();
214
+ if (videoEl.value) {
215
+ videoEl.value.srcObject = stream.value;
216
+ await videoEl.value.play().catch(() => undefined);
217
+ }
218
+ } catch (err: any) {
219
+ cameraOn.value = false;
220
+ cameraError.value =
221
+ err?.name === "NotAllowedError"
222
+ ? "Camera access is blocked. Allow the camera in your browser settings, then reopen this window."
223
+ : "No camera could be started on this device.";
224
+ }
225
+ }
226
+
227
+ function stopCamera() {
228
+ stream.value?.getTracks().forEach((t) => t.stop());
229
+ stream.value = null;
230
+ cameraOn.value = false;
231
+ }
232
+
233
+ async function capture() {
234
+ const video = videoEl.value;
235
+ if (!video) return;
236
+ const canvas = document.createElement("canvas");
237
+ canvas.width = video.videoWidth;
238
+ canvas.height = video.videoHeight;
239
+ canvas.getContext("2d")?.drawImage(video, 0, 0);
240
+ const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/jpeg", 0.8));
241
+ if (!blob) return;
242
+ photoFile.value = new File([blob], "attendance.jpg", { type: "image/jpeg" });
243
+ photoPreview.value = URL.createObjectURL(blob);
244
+ stopCamera();
245
+ }
246
+
247
+ function retake() {
248
+ if (photoPreview.value) URL.revokeObjectURL(photoPreview.value);
249
+ photoPreview.value = "";
250
+ photoFile.value = null;
251
+ void startCamera();
252
+ }
253
+
254
+ /** Location if the browser gives one within 4 s; never blocks the check-in. */
255
+ function bestEffortLocation(): Promise<{ latitude: number; longitude: number } | undefined> {
256
+ return new Promise((resolve) => {
257
+ if (!navigator.geolocation) return resolve(undefined);
258
+ const timer = setTimeout(() => resolve(undefined), 4000);
259
+ navigator.geolocation.getCurrentPosition(
260
+ (p) => {
261
+ clearTimeout(timer);
262
+ resolve({ latitude: p.coords.latitude, longitude: p.coords.longitude });
263
+ },
264
+ () => {
265
+ clearTimeout(timer);
266
+ resolve(undefined);
267
+ },
268
+ { enableHighAccuracy: true, timeout: 4000, maximumAge: 60000 }
269
+ );
270
+ });
271
+ }
272
+
273
+ async function submit() {
274
+ touched.value = true;
275
+ error.value = "";
276
+ if (!photoFile.value) return void (error.value = "Take the person's photo first.");
277
+ if (nameError(name.value) || !parsed.value.ok || (action.value === "checkIn" && postError(post.value))) return;
278
+
279
+ // Masked HERE: from this line on only the last 4 (+ prefix) exist.
280
+ const proxy = proxyPayload({ name: name.value, nric: nric.value, post: post.value });
281
+ const shown = parsed.value.ok ? parsed.value.masked : "";
282
+ nric.value = "";
283
+
284
+ submitting.value = true;
285
+ try {
286
+ const [{ id: privatePhoto }, location] = await Promise.all([
287
+ uploadAttendancePhoto(props.site, photoFile.value, props.serviceType),
288
+ bestEffortLocation(),
289
+ ]);
290
+ const timestamp = new Date().toISOString();
291
+ if (action.value === "checkIn") {
292
+ await checkIn(
293
+ props.site,
294
+ { checkIn: { timestamp, privatePhoto, ...(location ? { location } : {}) }, proxy } as any,
295
+ props.serviceType
296
+ );
297
+ } else {
298
+ await proxyCheckOut(
299
+ props.site,
300
+ {
301
+ proxy: { name: proxy.name, nricLast4: proxy.nricLast4 },
302
+ checkOut: { timestamp, privatePhoto, ...(location ? { location } : {}) },
303
+ },
304
+ props.serviceType
305
+ );
306
+ }
307
+ emit(
308
+ "saved",
309
+ `${proxy.name} (${shown}) ${action.value === "checkIn" ? "checked in" : "checked out"} at ${formatTime(timestamp)}.`
310
+ );
311
+ close();
312
+ } catch (err: any) {
313
+ error.value = err?.data?.message || "Could not save. Please check the connection and try again.";
314
+ } finally {
315
+ submitting.value = false;
316
+ }
317
+ }
318
+
319
+ function reset() {
320
+ name.value = "";
321
+ nric.value = "";
322
+ post.value = "";
323
+ touched.value = false;
324
+ error.value = "";
325
+ picked.value = "";
326
+ if (photoPreview.value) URL.revokeObjectURL(photoPreview.value);
327
+ photoPreview.value = "";
328
+ photoFile.value = null;
329
+ }
330
+
331
+ function close() {
332
+ stopCamera();
333
+ reset();
334
+ showDialog.value = false;
335
+ emit("close");
336
+ }
337
+
338
+ watch(showDialog, (open) => {
339
+ if (open) {
340
+ action.value = props.initialAction;
341
+ void startCamera();
342
+ void loadOpen();
343
+ } else {
344
+ stopCamera();
345
+ }
346
+ });
347
+
348
+ watch(action, (next) => {
349
+ if (next === "checkOut") void loadOpen();
350
+ });
351
+
352
+ onBeforeUnmount(stopCamera);
353
+ </script>
354
+
355
+ <style scoped>
356
+ .proxy__stage {
357
+ position: relative;
358
+ height: 260px;
359
+ overflow: hidden;
360
+ border-radius: var(--r-inner);
361
+ border-color: var(--border);
362
+ }
363
+ .proxy__media {
364
+ width: 100%;
365
+ height: 100%;
366
+ object-fit: cover;
367
+ display: block;
368
+ background: #000;
369
+ }
370
+ .proxy__placeholder {
371
+ height: 100%;
372
+ display: flex;
373
+ flex-direction: column;
374
+ align-items: center;
375
+ justify-content: center;
376
+ text-align: center;
377
+ padding: 16px;
378
+ background: var(--thead);
379
+ color: var(--muted);
380
+ font-size: var(--fs-cell);
381
+ }
382
+ .proxy__open {
383
+ border: 1px solid var(--border);
384
+ border-radius: var(--r-inner);
385
+ max-height: 200px;
386
+ overflow-y: auto;
387
+ }
388
+ .proxy__toggle .v-btn {
389
+ height: 48px;
390
+ }
391
+ </style>
@@ -18,6 +18,29 @@
18
18
  {{ error }}
19
19
  </v-alert>
20
20
 
21
+ <!-- Site device mode (owner request 2026-10-01). Saved on its own,
22
+ straight away, through its own endpoint - the Update button below
23
+ never touches it. -->
24
+ <div v-if="canManageSiteDeviceMode" class="attendance-settings__device mb-4">
25
+ <v-switch
26
+ v-model="siteDeviceMode"
27
+ label="Site device mode"
28
+ color="accent"
29
+ inset
30
+ hide-details
31
+ :loading="deviceModeSaving"
32
+ :disabled="deviceModeSaving || loading"
33
+ data-test="site-device-mode-switch"
34
+ @update:model-value="onDeviceModeToggle"
35
+ />
36
+ <span class="attendance-settings__hint">
37
+ {{ siteDeviceMode ? "ON" : "OFF" }} for this site. When on, one shared
38
+ phone at this site can check staff in and out: each person takes a photo
39
+ and types their name and NRIC. Only the last 4 characters of the NRIC are
40
+ saved. This switch saves straight away.
41
+ </span>
42
+ </div>
43
+
21
44
  <v-form ref="formRef" v-model="valid" @submit.prevent="onSubmit">
22
45
  <v-row dense>
23
46
  <v-col cols="12">
@@ -158,7 +181,28 @@ const isMapSearchVisible = ref(false);
158
181
 
159
182
  const radiusInMeters = computed(() => radius.value * 1609.34);
160
183
 
161
- const { getAttendanceSettings, updateAttendanceSettings } = useAttendance();
184
+ const { getAttendanceSettings, updateAttendanceSettings, setSiteDeviceMode } = useAttendance();
185
+ const { canManageSiteDeviceMode } = useAttendancePermission();
186
+
187
+ const siteDeviceMode = ref(false);
188
+ const deviceModeSaving = ref(false);
189
+
190
+ async function onDeviceModeToggle(next: boolean | null) {
191
+ const enabled = next === true;
192
+ deviceModeSaving.value = true;
193
+ try {
194
+ const res = await setSiteDeviceMode(props.site, enabled, props.serviceType);
195
+ siteDeviceMode.value = res?.siteDeviceMode === true;
196
+ snackbarText.value = res?.message || (enabled ? "Site device mode is ON." : "Site device mode is OFF.");
197
+ snackbarColor.value = "success";
198
+ snackbar.value = true;
199
+ } catch (err: any) {
200
+ siteDeviceMode.value = !enabled;
201
+ error.value = err?.data?.message || "Could not change Site device mode. Please try again.";
202
+ } finally {
203
+ deviceModeSaving.value = false;
204
+ }
205
+ }
162
206
 
163
207
  const postalCode = ref("");
164
208
  const country = ref("");
@@ -197,6 +241,7 @@ watchEffect(() => {
197
241
  }
198
242
 
199
243
  if (getAttendanceSettingsReq.value) {
244
+ siteDeviceMode.value = getAttendanceSettingsReq.value.siteDeviceMode === true;
200
245
  isGeofencingEnabled.value =
201
246
  getAttendanceSettingsReq.value.isGeofencingEnabled || false;
202
247
 
@@ -391,6 +436,13 @@ watch(showDialog, (newVal) => {
391
436
  color: var(--text2);
392
437
  }
393
438
 
439
+ /* Site device mode: its own framed block, because it saves on its own. */
440
+ .attendance-settings__device {
441
+ padding: 12px 14px;
442
+ border: 1px solid var(--border);
443
+ border-radius: var(--r-inner);
444
+ }
445
+
394
446
  /* Was a `variant="flat"` v-card, i.e. a second card inside the modal card. It
395
447
  is a frame around the QR, so it takes the design's inner surface. */
396
448
  .attendance-settings__qr {
@@ -10,6 +10,7 @@
10
10
  <v-tab value="general">General</v-tab>
11
11
  <v-tab value="residents">Residents/Tenants</v-tab>
12
12
  <v-tab value="vehicles">Vehicles</v-tab>
13
+ <v-tab value="management">Management</v-tab>
13
14
  <v-tab value="others">Others</v-tab>
14
15
  </v-tabs>
15
16
  </v-toolbar>
@@ -170,6 +171,11 @@
170
171
  </TableMain>
171
172
  </template>
172
173
 
174
+ <template v-else-if="tab === 'management'">
175
+ <BuildingUnitManagement :unit-id="buildingUnit._id" :site-id="buildingUnit?.site?._id"
176
+ :can-update="canUpdateUnit" />
177
+ </template>
178
+
173
179
  <template v-else-if="tab === 'others'">
174
180
  <v-row>
175
181
  <!-- <v-col cols="12" md="6" class="mt-2">