@7365admin1/layer-common 3.0.18 → 3.0.20
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/CHANGELOG.md +12 -0
- package/components/BuildingManagement/buildings.vue +7 -4
- package/components/BuildingManagement/units.vue +187 -9
- package/components/CleaningScheduleMain.vue +4 -0
- package/components/ClientMain.vue +189 -39
- package/components/DashboardMain.vue +1 -7
- package/components/HidAccessLogDashboard.vue +184 -21
- package/components/HidReaderForm.vue +20 -12
- package/components/HidReaderManagement.vue +113 -80
- package/components/Input/DateTimePicker.vue +152 -137
- package/components/MemberInformation.vue +240 -163
- package/components/PassInformation.vue +6 -4
- package/components/ServiceProviderMain.vue +43 -20
- package/components/VisitorForm.vue +41 -18
- package/composables/useBuilding.ts +14 -0
- package/composables/useServiceProvider.ts +2 -1
- package/composables/useVisitor.ts +19 -15
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/access-logs/index.vue +3 -1
- package/types/visitor.d.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 3.0.20
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ea81bba: add dialog confirm for client and update service provider
|
|
8
|
+
|
|
9
|
+
## 3.0.19
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 96c813f: Update Layer Common version
|
|
14
|
+
|
|
3
15
|
## 3.0.18
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
<v-btn fab icon density="comfortable" @click="getBuildings()">
|
|
16
16
|
<v-icon>mdi-refresh</v-icon>
|
|
17
17
|
</v-btn>
|
|
18
|
-
<v-text-field v-model="searchInput" autocomplete="off" placeholder="Search" density="compact" hide-details
|
|
19
|
-
class="mx-2" style="width: 200px;" />
|
|
18
|
+
<v-text-field v-model="searchInput" autocomplete="off" placeholder="Search" density="compact" hide-details
|
|
19
|
+
dense clearable class="mx-2" style="width: 200px;" />
|
|
20
20
|
</template>
|
|
21
21
|
|
|
22
22
|
<template #append>
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
<v-dialog v-model="createDialog" width="450" persistent>
|
|
46
46
|
<BuildingForm :site="site" mode="add" :blocks="items" @cancel="createDialog = false" @success="successCreate()"
|
|
47
47
|
@success:create-more="getBuildings()" />
|
|
48
|
-
</v-dialog>
|
|
48
|
+
</v-dialog>
|
|
49
49
|
|
|
50
50
|
<!-- Edit Dialog -->
|
|
51
51
|
<v-dialog v-model="editDialog" width="450" persistent>
|
|
@@ -222,7 +222,10 @@ const site = (useRoute().params.site as string) ?? "";
|
|
|
222
222
|
const status = (useRoute().params.status as string) ?? "active";
|
|
223
223
|
|
|
224
224
|
const { debounce } = useUtils();
|
|
225
|
-
const {
|
|
225
|
+
const {
|
|
226
|
+
getAll: _getBuildings,
|
|
227
|
+
deleteById,
|
|
228
|
+
} = useBuilding();
|
|
226
229
|
|
|
227
230
|
const searchInput = ref("");
|
|
228
231
|
const headerSearch = ref("");
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
v-if="canCreate && canCreateUnit">
|
|
7
7
|
Add Unit
|
|
8
8
|
</v-btn>
|
|
9
|
+
<v-btn class="text-none ml-2" rounded="pill" variant="tonal" @click="openBulkUploadFilePicker()" size="large"
|
|
10
|
+
:loading="bulkUploadLoading" v-if="canCreate && canCreateUnit">
|
|
11
|
+
<v-icon start>mdi-upload</v-icon>
|
|
12
|
+
Bulk Upload
|
|
13
|
+
</v-btn>
|
|
14
|
+
<input ref="bulkUploadFileInput" type="file" accept=".xlsx,.xls,.csv" class="d-none"
|
|
15
|
+
@change="handleBulkUploadFileChange" />
|
|
9
16
|
</v-row>
|
|
10
17
|
</v-col>
|
|
11
18
|
<v-col cols="12">
|
|
@@ -30,8 +37,7 @@
|
|
|
30
37
|
</v-toolbar>
|
|
31
38
|
|
|
32
39
|
<v-data-table :headers="props.headers" :items="items" item-value="_id" items-per-page="20" fixed-header
|
|
33
|
-
hide-default-footer
|
|
34
|
-
style="max-height: calc(100vh - (200px))">
|
|
40
|
+
hide-default-footer @click:row="tableRowClickHandler" style="max-height: calc(100vh - (200px))">
|
|
35
41
|
<template #item.block="{ value, item }">
|
|
36
42
|
{{ value ? `Block ${value}` : "" }}
|
|
37
43
|
</template>
|
|
@@ -44,10 +50,10 @@
|
|
|
44
50
|
<BuildingUnitFormAdd :site="site" @cancel="createDialog = false" @success="successCreate()"
|
|
45
51
|
@success:create-more="getBuildingUnit()" />
|
|
46
52
|
</v-dialog>
|
|
47
|
-
|
|
48
|
-
<v-dialog v-if="canViewUnitDetails"
|
|
49
|
-
|
|
50
|
-
:roomFacility="selectedBuildingUnit" />
|
|
53
|
+
|
|
54
|
+
<v-dialog v-if="canViewUnitDetails" v-model="dialogPreview" :width="smAndUp ? '70svw' : '100%'" persistent>
|
|
55
|
+
<BuildingUnitFormEdit v-if="selectedBuildingUnit" @cancel="dialogPreview = false" @success="successUpdate()"
|
|
56
|
+
@delete-unit="openDeleteDialog()" :roomFacility="selectedBuildingUnit" />
|
|
51
57
|
</v-dialog>
|
|
52
58
|
|
|
53
59
|
<v-dialog v-model="dialogDelete" :loading="deleteLoading" width="450" persistent>
|
|
@@ -88,6 +94,67 @@
|
|
|
88
94
|
</v-card>
|
|
89
95
|
</v-dialog>
|
|
90
96
|
|
|
97
|
+
<v-dialog v-model="bulkUploadSkippedDialog" :width="smAndUp ? '720' : '100%'" persistent>
|
|
98
|
+
<v-card width="100%">
|
|
99
|
+
<v-toolbar density="compact" class="pl-4">
|
|
100
|
+
<span class="font-weight-medium text-h5">Skipped Rows</span>
|
|
101
|
+
</v-toolbar>
|
|
102
|
+
|
|
103
|
+
<v-card-text style="max-height: 70vh; overflow-y: auto">
|
|
104
|
+
<p class="text-subtitle-2 mb-4">
|
|
105
|
+
Some rows were not imported. Review the details below and update the sheet if needed.
|
|
106
|
+
</p>
|
|
107
|
+
|
|
108
|
+
<v-alert v-if="bulkUploadSkippedSummary.length" type="warning" variant="tonal" density="compact"
|
|
109
|
+
class="mb-4">
|
|
110
|
+
{{ bulkUploadSkippedSummary.length }} row(s) skipped during bulk upload.
|
|
111
|
+
</v-alert>
|
|
112
|
+
|
|
113
|
+
<!-- <v-list v-if="bulkUploadSkippedSummary.length" density="compact" class="pa-0 mb-4">
|
|
114
|
+
<v-list-item v-for="(item, index) in bulkUploadSkippedSummary" :key="`summary-${index}`"
|
|
115
|
+
class="px-0">
|
|
116
|
+
<v-list-item-title class="text-subtitle-2">
|
|
117
|
+
Row {{ item.rowNumber ?? item.row ?? "N/A" }}
|
|
118
|
+
</v-list-item-title>
|
|
119
|
+
<v-list-item-subtitle class="text-wrap">
|
|
120
|
+
{{ formatSkippedSummaryReason(item) }}
|
|
121
|
+
</v-list-item-subtitle>
|
|
122
|
+
</v-list-item>
|
|
123
|
+
</v-list> -->
|
|
124
|
+
|
|
125
|
+
<template v-if="bulkUploadSkippedErrors.length">
|
|
126
|
+
<!-- <v-divider class="mb-4" /> -->
|
|
127
|
+
<p class="text-subtitle-2 mb-2">Details</p>
|
|
128
|
+
<v-table density="compact">
|
|
129
|
+
<thead>
|
|
130
|
+
<tr>
|
|
131
|
+
<th class="text-left">Row</th>
|
|
132
|
+
<th class="text-left">Block</th>
|
|
133
|
+
<th class="text-left">Building</th>
|
|
134
|
+
<th class="text-left">Reason</th>
|
|
135
|
+
</tr>
|
|
136
|
+
</thead>
|
|
137
|
+
<tbody>
|
|
138
|
+
<tr v-for="(item, index) in bulkUploadSkippedErrors" :key="`error-${index}`">
|
|
139
|
+
<td>{{ item.row ?? "N/A" }}</td>
|
|
140
|
+
<td>{{ item.block ?? "N/A" }}</td>
|
|
141
|
+
<td>{{ item.name ?? "N/A" }}</td>
|
|
142
|
+
<td>{{ item.reason ?? "Skipped" }}</td>
|
|
143
|
+
</tr>
|
|
144
|
+
</tbody>
|
|
145
|
+
</v-table>
|
|
146
|
+
</template>
|
|
147
|
+
</v-card-text>
|
|
148
|
+
|
|
149
|
+
<v-toolbar density="compact">
|
|
150
|
+
<v-btn tile block size="48" color="black" variant="flat" class="text-none"
|
|
151
|
+
@click="bulkUploadSkippedDialog = false">
|
|
152
|
+
Close
|
|
153
|
+
</v-btn>
|
|
154
|
+
</v-toolbar>
|
|
155
|
+
</v-card>
|
|
156
|
+
</v-dialog>
|
|
157
|
+
|
|
91
158
|
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
92
159
|
</v-row>
|
|
93
160
|
</template>
|
|
@@ -103,7 +170,7 @@ const props = defineProps({
|
|
|
103
170
|
title: "Unit Name",
|
|
104
171
|
value: "name",
|
|
105
172
|
},
|
|
106
|
-
|
|
173
|
+
{
|
|
107
174
|
title: "Level",
|
|
108
175
|
value: "level.name",
|
|
109
176
|
},
|
|
@@ -115,7 +182,7 @@ const props = defineProps({
|
|
|
115
182
|
title: "Block Name",
|
|
116
183
|
value: "buildingName",
|
|
117
184
|
},
|
|
118
|
-
|
|
185
|
+
|
|
119
186
|
// {
|
|
120
187
|
// title: "Type",
|
|
121
188
|
// value: "type",
|
|
@@ -162,6 +229,8 @@ const { debounce, formatDateDDMMYYYYLocal, toOrdinal } = useUtils();
|
|
|
162
229
|
|
|
163
230
|
const { getAll, deleteById } = useBuildingUnit();
|
|
164
231
|
|
|
232
|
+
const { uploadSpreadsheetBuilding } = useBuilding();
|
|
233
|
+
|
|
165
234
|
const searchInput = ref("");
|
|
166
235
|
const headerSearch = ref("");
|
|
167
236
|
const page = ref(1);
|
|
@@ -243,6 +312,12 @@ function openEditDialog() {
|
|
|
243
312
|
|
|
244
313
|
const dialogDelete = ref(false);
|
|
245
314
|
const deleteLoading = ref(false);
|
|
315
|
+
const bulkUploadFile = ref<File | null>(null);
|
|
316
|
+
const bulkUploadFileInput = ref<HTMLInputElement | null>(null);
|
|
317
|
+
const bulkUploadLoading = ref(false);
|
|
318
|
+
const bulkUploadSkippedDialog = ref(false);
|
|
319
|
+
const bulkUploadSkippedErrors = ref<Array<Record<string, any>>>([]);
|
|
320
|
+
const bulkUploadSkippedSummary = ref<Array<Record<string, any>>>([]);
|
|
246
321
|
|
|
247
322
|
function openDeleteDialog() {
|
|
248
323
|
dialogDelete.value = true;
|
|
@@ -272,6 +347,109 @@ async function handleDeleteBuildingUnit() {
|
|
|
272
347
|
}
|
|
273
348
|
}
|
|
274
349
|
|
|
350
|
+
function openBulkUploadFilePicker() {
|
|
351
|
+
if (bulkUploadLoading.value) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
bulkUploadFile.value = null;
|
|
356
|
+
bulkUploadSkippedErrors.value = [];
|
|
357
|
+
bulkUploadSkippedSummary.value = [];
|
|
358
|
+
bulkUploadSkippedDialog.value = false;
|
|
359
|
+
if (bulkUploadFileInput.value) {
|
|
360
|
+
bulkUploadFileInput.value.value = "";
|
|
361
|
+
bulkUploadFileInput.value.click();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function formatSkippedSummaryReason(item: Record<string, any>) {
|
|
366
|
+
if (Array.isArray(item.errors) && item.errors.length) {
|
|
367
|
+
return item.errors.join(", ");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return item.reason ?? "Skipped";
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function getSkippedRowsCount(result: Record<string, any> | null | undefined) {
|
|
374
|
+
if (typeof result?.skippedRows === "number") {
|
|
375
|
+
return result.skippedRows;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (Array.isArray(result?.skippedRows)) {
|
|
379
|
+
return result.skippedRows.length;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function getBulkUploadSkippedErrors(result: Record<string, any> | null | undefined) {
|
|
386
|
+
if (Array.isArray(result?.skippedErrors)) {
|
|
387
|
+
return result.skippedErrors;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (Array.isArray(result?.skippedRows)) {
|
|
391
|
+
return result.skippedRows;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function getBulkUploadSkippedSummary(result: Record<string, any> | null | undefined) {
|
|
398
|
+
if (Array.isArray(result?.skippedItemsSummary)) {
|
|
399
|
+
return result.skippedItemsSummary;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (Array.isArray(result?.skippedRows)) {
|
|
403
|
+
return result.skippedRows.map((item: Record<string, any>) => ({
|
|
404
|
+
rowNumber: item.row,
|
|
405
|
+
reason: item.reason,
|
|
406
|
+
block: item.block,
|
|
407
|
+
name: item.name,
|
|
408
|
+
data: item,
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function handleBulkUploadFileChange(event: Event) {
|
|
416
|
+
const input = event.target as HTMLInputElement;
|
|
417
|
+
const file = input.files?.[0] ?? null;
|
|
418
|
+
|
|
419
|
+
if (!file) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
bulkUploadFile.value = file;
|
|
424
|
+
bulkUploadLoading.value = true;
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const result = await uploadSpreadsheetBuilding({
|
|
428
|
+
site,
|
|
429
|
+
file,
|
|
430
|
+
});
|
|
431
|
+
await getBuildingUnit();
|
|
432
|
+
bulkUploadSkippedErrors.value = getBulkUploadSkippedErrors(result);
|
|
433
|
+
bulkUploadSkippedSummary.value = getBulkUploadSkippedSummary(result);
|
|
434
|
+
bulkUploadSkippedDialog.value =
|
|
435
|
+
bulkUploadSkippedErrors.value.length > 0 ||
|
|
436
|
+
bulkUploadSkippedSummary.value.length > 0;
|
|
437
|
+
showMessage(
|
|
438
|
+
`Bulk upload completed. Inserted ${result?.insertedUnits ?? 0} unit(s). Skipped ${getSkippedRowsCount(result)} row(s).`,
|
|
439
|
+
"success"
|
|
440
|
+
);
|
|
441
|
+
} catch (error: any) {
|
|
442
|
+
showMessage(
|
|
443
|
+
error?.response?._data?.message || "Failed to upload spreadsheet.",
|
|
444
|
+
"error"
|
|
445
|
+
);
|
|
446
|
+
} finally {
|
|
447
|
+
bulkUploadLoading.value = false;
|
|
448
|
+
bulkUploadFile.value = null;
|
|
449
|
+
input.value = "";
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
275
453
|
function setBuilding({
|
|
276
454
|
mode = "create",
|
|
277
455
|
dialog = true,
|
|
@@ -290,7 +468,7 @@ function setBuilding({
|
|
|
290
468
|
</script>
|
|
291
469
|
|
|
292
470
|
<style scoped>
|
|
293
|
-
|
|
471
|
+
.v-data-table :deep(thead th) {
|
|
294
472
|
font-weight: bold !important;
|
|
295
473
|
}
|
|
296
474
|
</style>
|
|
@@ -26,11 +26,15 @@
|
|
|
26
26
|
v-model:utc="startDate"
|
|
27
27
|
density="compact"
|
|
28
28
|
hide-details
|
|
29
|
+
:date-only="true"
|
|
30
|
+
placeholder="Start Date"
|
|
29
31
|
/>
|
|
30
32
|
<InputDateTimePicker
|
|
31
33
|
v-model:utc="endDate"
|
|
32
34
|
density="compact"
|
|
33
35
|
hide-details
|
|
36
|
+
:date-only="true"
|
|
37
|
+
placeholder="End Date"
|
|
34
38
|
/>
|
|
35
39
|
<v-select
|
|
36
40
|
v-model="status"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="page-wrap">
|
|
3
3
|
|
|
4
|
-
<!-- ===== TABLE VIEW
|
|
4
|
+
<!-- ===== TABLE VIEW ===== -->
|
|
5
5
|
<template v-if="viewMode === 'table'">
|
|
6
6
|
|
|
7
7
|
<!-- ===== TOP BAR ===== -->
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
</svg>
|
|
25
25
|
</div>
|
|
26
26
|
|
|
27
|
-
<!-- Filters
|
|
27
|
+
<!-- Filters -->
|
|
28
28
|
<div v-if="tab !== 'pending'" class="filter-wrap">
|
|
29
29
|
<div class="filter-field">
|
|
30
30
|
<label>Filter by status</label>
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
<div v-if="loading" class="loading-bar"><div class="loading-bar-fill" /></div>
|
|
105
105
|
|
|
106
106
|
<!-- TABLE: active / suspended -->
|
|
107
|
-
<div v-if="tab !== 'pending'" class="table-scroll">
|
|
107
|
+
<div v-if="tab !== 'pending'" ref="tableScrollEl" class="table-scroll" @scroll="closeMenu">
|
|
108
108
|
<table>
|
|
109
109
|
<thead>
|
|
110
110
|
<tr>
|
|
@@ -156,32 +156,9 @@
|
|
|
156
156
|
</td>
|
|
157
157
|
<td>
|
|
158
158
|
<div class="action-wrap">
|
|
159
|
-
<button class="dots-btn" @click.stop="toggleMenu(item._id)">
|
|
159
|
+
<button class="dots-btn" @click.stop="toggleMenu(item._id, $event)">
|
|
160
160
|
<span /><span /><span />
|
|
161
161
|
</button>
|
|
162
|
-
<div v-if="openMenuId === item._id" class="dropdown">
|
|
163
|
-
<button
|
|
164
|
-
class="dd-item"
|
|
165
|
-
@click="viewClient(item)"
|
|
166
|
-
>
|
|
167
|
-
View Organization
|
|
168
|
-
</button>
|
|
169
|
-
<button
|
|
170
|
-
v-if="item.status === 'suspended'"
|
|
171
|
-
class="dd-item"
|
|
172
|
-
@click="activateClient(item)"
|
|
173
|
-
>
|
|
174
|
-
Activate
|
|
175
|
-
</button>
|
|
176
|
-
|
|
177
|
-
<button
|
|
178
|
-
v-else
|
|
179
|
-
class="dd-item danger"
|
|
180
|
-
@click="suspendClient(item)"
|
|
181
|
-
>
|
|
182
|
-
Suspend
|
|
183
|
-
</button>
|
|
184
|
-
</div>
|
|
185
162
|
</div>
|
|
186
163
|
</td>
|
|
187
164
|
</tr>
|
|
@@ -228,7 +205,6 @@
|
|
|
228
205
|
|
|
229
206
|
</template>
|
|
230
207
|
|
|
231
|
-
<!-- ===== DETAIL VIEW: chiếm toàn bộ page-wrap, không còn topbar/table ===== -->
|
|
232
208
|
<div v-else-if="viewMode === 'detail'" class="client-detail">
|
|
233
209
|
<ClientDetailForm
|
|
234
210
|
:client="selectedClient"
|
|
@@ -237,17 +213,59 @@
|
|
|
237
213
|
/>
|
|
238
214
|
</div>
|
|
239
215
|
|
|
240
|
-
<!-- ===== DIALOG (luôn nằm ngoài, có thể mở ở bất kỳ view nào) ===== -->
|
|
241
216
|
<div v-if="dialog" class="overlay" @click.self="dialog = false">
|
|
242
217
|
<div class="dialog-box">
|
|
243
218
|
<InvitationClientForm title="Invite Client" @success="handleSuccess" @cancel="dialog = false" />
|
|
244
219
|
</div>
|
|
245
220
|
</div>
|
|
221
|
+
|
|
222
|
+
<!-- ===== SUSPEND CONFIRMATION DIALOG ===== -->
|
|
223
|
+
<div v-if="confirmSuspendItem" class="overlay" @click.self="cancelSuspend">
|
|
224
|
+
<div class="confirm-box">
|
|
225
|
+
<h3 class="confirm-title">Suspend account</h3>
|
|
226
|
+
<p class="confirm-text">
|
|
227
|
+
Are you sure you want to suspend <strong>{{ confirmSuspendItem?.name }}</strong>'s account?
|
|
228
|
+
</p>
|
|
229
|
+
<div class="confirm-actions">
|
|
230
|
+
<button class="btn-cancel" @click="cancelSuspend">Cancel</button>
|
|
231
|
+
<button class="btn-confirm-danger" :disabled="confirmSuspendLoading" @click="confirmSuspend">
|
|
232
|
+
<span v-if="confirmSuspendLoading" class="spinner spinner-light" />
|
|
233
|
+
<span v-else>Suspend</span>
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<Teleport to="body">
|
|
240
|
+
<div
|
|
241
|
+
v-if="openMenuId"
|
|
242
|
+
class="dropdown"
|
|
243
|
+
:style="{ top: menuPos.top + 'px', left: menuPos.left + 'px' }"
|
|
244
|
+
>
|
|
245
|
+
<button class="dd-item" @click="viewClient(activeMenuItem)">
|
|
246
|
+
View Organization
|
|
247
|
+
</button>
|
|
248
|
+
<button
|
|
249
|
+
v-if="activeMenuItem?.status === 'suspended'"
|
|
250
|
+
class="dd-item"
|
|
251
|
+
@click="activateClient(activeMenuItem)"
|
|
252
|
+
>
|
|
253
|
+
Activate
|
|
254
|
+
</button>
|
|
255
|
+
<button
|
|
256
|
+
v-else
|
|
257
|
+
class="dd-item danger"
|
|
258
|
+
@click="askSuspend(activeMenuItem)"
|
|
259
|
+
>
|
|
260
|
+
Suspend
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
</Teleport>
|
|
246
264
|
</div>
|
|
247
265
|
</template>
|
|
248
266
|
|
|
249
267
|
<script setup lang="ts">
|
|
250
|
-
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
268
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
251
269
|
import useOrg from '../composables/useOrg'
|
|
252
270
|
import useVerification from '../composables/useVerification'
|
|
253
271
|
import useRole from '../composables/useRole'
|
|
@@ -283,6 +301,9 @@ const APP_LIST = [
|
|
|
283
301
|
const tab = ref<TabStatus>('active')
|
|
284
302
|
const dialog = ref(false)
|
|
285
303
|
const openMenuId = ref<string | null>(null)
|
|
304
|
+
const menuPos = ref({ top: 0, left: 0 })
|
|
305
|
+
const tableScrollEl = ref<HTMLElement | null>(null)
|
|
306
|
+
|
|
286
307
|
// filterStatus
|
|
287
308
|
const filterStatus = ref<'active' | 'suspended'>('active')
|
|
288
309
|
const filterPlan = ref('') // '' = All | any APP_LIST value
|
|
@@ -300,6 +321,10 @@ const roleCache = ref<Record<string, string>>({})
|
|
|
300
321
|
const viewMode = ref<'table' | 'detail'>('table')
|
|
301
322
|
const selectedClient = ref<any>(null)
|
|
302
323
|
|
|
324
|
+
/* suspend confirmation */
|
|
325
|
+
const confirmSuspendItem = ref<any>(null)
|
|
326
|
+
const confirmSuspendLoading = ref(false)
|
|
327
|
+
|
|
303
328
|
/* ── COMPUTED ── */
|
|
304
329
|
const pageRange = computed(() => {
|
|
305
330
|
if (!totalItems.value) return '0-0 of 0'
|
|
@@ -308,6 +333,10 @@ const pageRange = computed(() => {
|
|
|
308
333
|
return `${start}-${end} of ${totalItems.value}`
|
|
309
334
|
})
|
|
310
335
|
|
|
336
|
+
const activeMenuItem = computed(() =>
|
|
337
|
+
items.value.find(i => i._id === openMenuId.value) ?? null
|
|
338
|
+
)
|
|
339
|
+
|
|
311
340
|
/* ── HELPERS ── */
|
|
312
341
|
function formatDate(date?: string | Date) {
|
|
313
342
|
if (date == null || date === '') return '-'
|
|
@@ -358,6 +387,28 @@ async function activateClient(item: any) {
|
|
|
358
387
|
}
|
|
359
388
|
}
|
|
360
389
|
|
|
390
|
+
/* ── SUSPEND CONFIRMATION ── */
|
|
391
|
+
function askSuspend(item: any) {
|
|
392
|
+
confirmSuspendItem.value = item
|
|
393
|
+
closeMenu()
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function cancelSuspend() {
|
|
397
|
+
if (confirmSuspendLoading.value) return
|
|
398
|
+
confirmSuspendItem.value = null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function confirmSuspend() {
|
|
402
|
+
if (!confirmSuspendItem.value) return
|
|
403
|
+
confirmSuspendLoading.value = true
|
|
404
|
+
try {
|
|
405
|
+
await suspendClient(confirmSuspendItem.value)
|
|
406
|
+
confirmSuspendItem.value = null
|
|
407
|
+
} finally {
|
|
408
|
+
confirmSuspendLoading.value = false
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
361
412
|
async function fetchOrgs(status: 'active' | 'suspended') {
|
|
362
413
|
loading.value = true
|
|
363
414
|
|
|
@@ -484,7 +535,6 @@ async function handleUpdatePage(p: number) {
|
|
|
484
535
|
|
|
485
536
|
async function onTabChange(value: TabStatus) {
|
|
486
537
|
tab.value = value
|
|
487
|
-
// sync filterStatus ( active/suspended)
|
|
488
538
|
if (value !== 'pending') filterStatus.value = value
|
|
489
539
|
page.value = 1
|
|
490
540
|
await fetchData()
|
|
@@ -509,14 +559,44 @@ function handleFilterChange() {
|
|
|
509
559
|
fetchData()
|
|
510
560
|
}
|
|
511
561
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
562
|
+
const DROPDOWN_WIDTH = 130
|
|
563
|
+
|
|
564
|
+
function toggleMenu(id: string, event: MouseEvent) {
|
|
565
|
+
if (openMenuId.value === id) {
|
|
566
|
+
closeMenu()
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
const btn = event.currentTarget as HTMLElement
|
|
570
|
+
const rect = btn.getBoundingClientRect()
|
|
571
|
+
|
|
572
|
+
let left = rect.right - DROPDOWN_WIDTH
|
|
573
|
+
if (left < 8) left = 8
|
|
574
|
+
if (left + DROPDOWN_WIDTH > window.innerWidth - 8) {
|
|
575
|
+
left = window.innerWidth - DROPDOWN_WIDTH - 8
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
menuPos.value = {
|
|
579
|
+
top: rect.bottom + 4,
|
|
580
|
+
left,
|
|
581
|
+
}
|
|
582
|
+
openMenuId.value = id
|
|
515
583
|
}
|
|
584
|
+
|
|
516
585
|
function closeMenu() {
|
|
517
586
|
openMenuId.value = null
|
|
518
587
|
}
|
|
519
588
|
|
|
589
|
+
function handleOutsideClick(e: MouseEvent) {
|
|
590
|
+
const target = e.target as HTMLElement
|
|
591
|
+
if (!target.closest('.dropdown') && !target.closest('.dots-btn')) {
|
|
592
|
+
closeMenu()
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function handleWindowResize() {
|
|
597
|
+
closeMenu()
|
|
598
|
+
}
|
|
599
|
+
|
|
520
600
|
/* ── DIALOG ── */
|
|
521
601
|
function openInviteDialog() { dialog.value = true }
|
|
522
602
|
function handleSuccess() { dialog.value = false; fetchData() }
|
|
@@ -532,7 +612,18 @@ async function cancelInvite(item: any) {
|
|
|
532
612
|
}
|
|
533
613
|
}
|
|
534
614
|
|
|
535
|
-
onMounted(() =>
|
|
615
|
+
onMounted(() => {
|
|
616
|
+
fetchData()
|
|
617
|
+
document.addEventListener('click', handleOutsideClick)
|
|
618
|
+
document.addEventListener('scroll', closeMenu, true)
|
|
619
|
+
window.addEventListener('resize', handleWindowResize)
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
onUnmounted(() => {
|
|
623
|
+
document.removeEventListener('click', handleOutsideClick)
|
|
624
|
+
document.removeEventListener('scroll', closeMenu, true)
|
|
625
|
+
window.removeEventListener('resize', handleWindowResize)
|
|
626
|
+
})
|
|
536
627
|
</script>
|
|
537
628
|
|
|
538
629
|
<style scoped>
|
|
@@ -823,15 +914,13 @@ tbody td { padding: 14px 16px; vertical-align: middle; }
|
|
|
823
914
|
.dots-btn span { display: block; width: 4px; height: 4px; border-radius: 50%; background: currentColor; }
|
|
824
915
|
|
|
825
916
|
.dropdown {
|
|
826
|
-
position:
|
|
827
|
-
right: 0;
|
|
828
|
-
top: calc(100% + 4px);
|
|
917
|
+
position: fixed;
|
|
829
918
|
background: #fff;
|
|
830
919
|
border: 1px solid #E5E7EB;
|
|
831
920
|
border-radius: 8px;
|
|
832
921
|
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
|
833
922
|
min-width: 130px;
|
|
834
|
-
z-index:
|
|
923
|
+
z-index: 1100;
|
|
835
924
|
overflow: hidden;
|
|
836
925
|
}
|
|
837
926
|
.dd-item {
|
|
@@ -874,6 +963,10 @@ tbody td { padding: 14px 16px; vertical-align: middle; }
|
|
|
874
963
|
border-radius: 50%;
|
|
875
964
|
animation: spin 0.7s linear infinite;
|
|
876
965
|
}
|
|
966
|
+
.spinner-light {
|
|
967
|
+
border: 1.5px solid rgba(255,255,255,0.5);
|
|
968
|
+
border-top-color: #fff;
|
|
969
|
+
}
|
|
877
970
|
|
|
878
971
|
.client-detail {
|
|
879
972
|
margin: 12px 0 0 0;
|
|
@@ -896,4 +989,61 @@ tbody td { padding: 14px 16px; vertical-align: middle; }
|
|
|
896
989
|
box-shadow: 0 16px 48px rgba(0,0,0,0.12);
|
|
897
990
|
overflow: hidden;
|
|
898
991
|
}
|
|
992
|
+
|
|
993
|
+
/* ===== SUSPEND CONFIRMATION DIALOG ===== */
|
|
994
|
+
.confirm-box {
|
|
995
|
+
background: #fff;
|
|
996
|
+
border-radius: 12px;
|
|
997
|
+
max-width: 380px;
|
|
998
|
+
width: 90%;
|
|
999
|
+
box-shadow: 0 16px 48px rgba(0,0,0,0.12);
|
|
1000
|
+
padding: 24px;
|
|
1001
|
+
}
|
|
1002
|
+
.confirm-title {
|
|
1003
|
+
font-size: 16px;
|
|
1004
|
+
font-weight: 600;
|
|
1005
|
+
color: #111827;
|
|
1006
|
+
margin-bottom: 8px;
|
|
1007
|
+
}
|
|
1008
|
+
.confirm-text {
|
|
1009
|
+
font-size: 13.5px;
|
|
1010
|
+
color: #4B5563;
|
|
1011
|
+
line-height: 1.5;
|
|
1012
|
+
margin-bottom: 20px;
|
|
1013
|
+
}
|
|
1014
|
+
.confirm-actions {
|
|
1015
|
+
display: flex;
|
|
1016
|
+
justify-content: flex-end;
|
|
1017
|
+
gap: 10px;
|
|
1018
|
+
}
|
|
1019
|
+
.btn-cancel {
|
|
1020
|
+
padding: 8px 16px;
|
|
1021
|
+
font-size: 13px;
|
|
1022
|
+
font-weight: 500;
|
|
1023
|
+
border: 1px solid #D1D5DB;
|
|
1024
|
+
border-radius: 8px;
|
|
1025
|
+
background: #fff;
|
|
1026
|
+
color: #374151;
|
|
1027
|
+
cursor: pointer;
|
|
1028
|
+
transition: background 0.15s;
|
|
1029
|
+
}
|
|
1030
|
+
.btn-cancel:hover { background: #F9FAFB; }
|
|
1031
|
+
.btn-confirm-danger {
|
|
1032
|
+
display: inline-flex;
|
|
1033
|
+
align-items: center;
|
|
1034
|
+
justify-content: center;
|
|
1035
|
+
gap: 6px;
|
|
1036
|
+
min-width: 78px;
|
|
1037
|
+
padding: 8px 16px;
|
|
1038
|
+
font-size: 13px;
|
|
1039
|
+
font-weight: 500;
|
|
1040
|
+
border: none;
|
|
1041
|
+
border-radius: 8px;
|
|
1042
|
+
background: #DC2626;
|
|
1043
|
+
color: #fff;
|
|
1044
|
+
cursor: pointer;
|
|
1045
|
+
transition: background 0.15s;
|
|
1046
|
+
}
|
|
1047
|
+
.btn-confirm-danger:hover:not(:disabled) { background: #B91C1C; }
|
|
1048
|
+
.btn-confirm-danger:disabled { opacity: 0.7; cursor: not-allowed; }
|
|
899
1049
|
</style>
|
|
@@ -824,13 +824,7 @@
|
|
|
824
824
|
<div class="dashboard-card dashboard-panel figma-panel">
|
|
825
825
|
<div class="figma-panel-header">
|
|
826
826
|
<h2>Today's Attentions</h2>
|
|
827
|
-
|
|
828
|
-
class="figma-panel-action"
|
|
829
|
-
type="button"
|
|
830
|
-
@click="goToDashboardRoute('incident-reports')"
|
|
831
|
-
>
|
|
832
|
-
View All
|
|
833
|
-
</button>
|
|
827
|
+
|
|
834
828
|
</div>
|
|
835
829
|
<div class="figma-panel-body">
|
|
836
830
|
<div v-if="todayAttentions.length" class="figma-list">
|