@7365admin1/layer-common 3.2.2-staging.106 → 3.2.2-staging.108
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/CreateSubsciptionPlan.vue +82 -57
- package/components/EntryPassInformation.vue +29 -1
- package/components/HidAccessLogDashboard.vue +42 -29
- package/components/HidIntercomManagement.vue +43 -9
- package/components/HidQrCodeConfiguration.vue +1 -2
- package/components/HidReaderForm.vue +102 -11
- package/components/HidReaderManagement.vue +1 -0
- package/components/HidUserEnrollment.vue +21 -2
- package/components/MemberInformation.vue +9 -0
- package/components/SubscriptionPlanTable.vue +168 -104
- package/components/VisitorForm.vue +30 -1
- package/composables/useHidAmico.ts +13 -0
- package/composables/useSubscriptionPlan.ts +62 -0
- package/package.json +1 -1
- package/types/subscription.ts +18 -11
|
@@ -28,17 +28,6 @@
|
|
|
28
28
|
class="mb-3"
|
|
29
29
|
/>
|
|
30
30
|
|
|
31
|
-
<div class="field-label">Device ID <span>*</span></div>
|
|
32
|
-
<v-text-field
|
|
33
|
-
v-model="draft.deviceId"
|
|
34
|
-
aria-label="Device ID"
|
|
35
|
-
placeholder="Enter device id"
|
|
36
|
-
variant="outlined"
|
|
37
|
-
density="compact"
|
|
38
|
-
hide-details="auto"
|
|
39
|
-
class="mb-3"
|
|
40
|
-
/>
|
|
41
|
-
|
|
42
31
|
<div class="field-label">Location <span>*</span></div>
|
|
43
32
|
<v-text-field
|
|
44
33
|
v-model="draft.location"
|
|
@@ -79,6 +68,49 @@
|
|
|
79
68
|
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
|
80
69
|
@click:append-inner="showPassword = !showPassword"
|
|
81
70
|
/>
|
|
71
|
+
|
|
72
|
+
<v-btn
|
|
73
|
+
v-if="mode === 'add'"
|
|
74
|
+
block
|
|
75
|
+
variant="tonal"
|
|
76
|
+
color="primary"
|
|
77
|
+
prepend-icon="mdi-lan-connect"
|
|
78
|
+
class="text-none mt-3"
|
|
79
|
+
:loading="discovering"
|
|
80
|
+
:disabled="!canDiscover"
|
|
81
|
+
@click="connectReader"
|
|
82
|
+
>
|
|
83
|
+
Connect & discover reader
|
|
84
|
+
</v-btn>
|
|
85
|
+
<v-alert v-if="discoveryError" type="error" density="compact" variant="tonal" class="mt-3">
|
|
86
|
+
{{ discoveryError }}
|
|
87
|
+
</v-alert>
|
|
88
|
+
|
|
89
|
+
<div class="field-label mt-3">Device ID <span>*</span></div>
|
|
90
|
+
<v-text-field
|
|
91
|
+
v-model="draft.deviceId"
|
|
92
|
+
aria-label="Device ID"
|
|
93
|
+
placeholder="Connect to detect device ID"
|
|
94
|
+
variant="outlined"
|
|
95
|
+
density="compact"
|
|
96
|
+
hide-details="auto"
|
|
97
|
+
class="mb-3"
|
|
98
|
+
readonly
|
|
99
|
+
/>
|
|
100
|
+
|
|
101
|
+
<div class="field-label">HID Portal <span>*</span></div>
|
|
102
|
+
<v-select
|
|
103
|
+
v-model="draft.portalId"
|
|
104
|
+
:items="portals"
|
|
105
|
+
item-title="name"
|
|
106
|
+
item-value="id"
|
|
107
|
+
aria-label="HID Portal"
|
|
108
|
+
placeholder="Connect to load portals"
|
|
109
|
+
variant="outlined"
|
|
110
|
+
density="compact"
|
|
111
|
+
hide-details="auto"
|
|
112
|
+
:readonly="mode === 'edit'"
|
|
113
|
+
/>
|
|
82
114
|
</v-card-text>
|
|
83
115
|
|
|
84
116
|
<v-card-actions class="form-actions pa-0">
|
|
@@ -95,6 +127,7 @@
|
|
|
95
127
|
variant="flat"
|
|
96
128
|
height="48"
|
|
97
129
|
:loading="loading"
|
|
130
|
+
:disabled="mode === 'add' && (!draft.deviceId || !draft.portalId)"
|
|
98
131
|
@click="submit"
|
|
99
132
|
>
|
|
100
133
|
Submit
|
|
@@ -135,11 +168,18 @@ const dialogModel = computed({
|
|
|
135
168
|
});
|
|
136
169
|
|
|
137
170
|
const showPassword = ref(false);
|
|
171
|
+
const discovering = ref(false);
|
|
172
|
+
const discoveryError = ref("");
|
|
173
|
+
const portals = ref<Array<{ id: number; name: string }>>([]);
|
|
174
|
+
const discoveredConnection = ref("");
|
|
175
|
+
const { discoverReader } = useHidAmico();
|
|
138
176
|
const monitorEnabled = ref(true);
|
|
139
177
|
const draft = reactive({
|
|
140
178
|
name: "",
|
|
141
179
|
baseUrl: "",
|
|
142
180
|
deviceId: "",
|
|
181
|
+
portalId: null as number | null,
|
|
182
|
+
portalName: "",
|
|
143
183
|
location: "",
|
|
144
184
|
username: "",
|
|
145
185
|
password: "",
|
|
@@ -153,6 +193,13 @@ watch(
|
|
|
153
193
|
draft.name = props.reader?.name ?? "";
|
|
154
194
|
draft.baseUrl = props.reader?.baseUrl ?? "";
|
|
155
195
|
draft.deviceId = props.reader?.deviceId ?? "";
|
|
196
|
+
draft.portalId = props.reader?.portalId ?? null;
|
|
197
|
+
draft.portalName = props.reader?.portalName ?? "";
|
|
198
|
+
portals.value = draft.portalId
|
|
199
|
+
? [{ id: draft.portalId, name: draft.portalName || `Portal ${draft.portalId}` }]
|
|
200
|
+
: [];
|
|
201
|
+
discoveryError.value = "";
|
|
202
|
+
discoveredConnection.value = "";
|
|
156
203
|
draft.location = props.reader?.location ?? "";
|
|
157
204
|
draft.username = props.reader?.username ?? "";
|
|
158
205
|
draft.password = props.reader?.password ?? "";
|
|
@@ -165,9 +212,53 @@ function close() {
|
|
|
165
212
|
emit("update:modelValue", false);
|
|
166
213
|
}
|
|
167
214
|
|
|
215
|
+
const canDiscover = computed(() => Boolean(
|
|
216
|
+
draft.baseUrl.trim() && draft.username.trim() && draft.password,
|
|
217
|
+
));
|
|
218
|
+
|
|
219
|
+
watch(
|
|
220
|
+
() => [draft.baseUrl, draft.username, draft.password],
|
|
221
|
+
() => {
|
|
222
|
+
if (props.mode !== "add" || !discoveredConnection.value) return;
|
|
223
|
+
const current = [draft.baseUrl.trim(), draft.username.trim(), draft.password].join("\n");
|
|
224
|
+
if (current !== discoveredConnection.value) {
|
|
225
|
+
draft.deviceId = "";
|
|
226
|
+
draft.portalId = null;
|
|
227
|
+
portals.value = [];
|
|
228
|
+
discoveredConnection.value = "";
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
async function connectReader() {
|
|
234
|
+
discovering.value = true;
|
|
235
|
+
discoveryError.value = "";
|
|
236
|
+
try {
|
|
237
|
+
const response = await discoverReader({
|
|
238
|
+
baseUrl: draft.baseUrl.trim(),
|
|
239
|
+
username: draft.username.trim(),
|
|
240
|
+
password: draft.password,
|
|
241
|
+
});
|
|
242
|
+
const data = response?.data ?? response;
|
|
243
|
+
draft.deviceId = String(data?.deviceId || "");
|
|
244
|
+
portals.value = Array.isArray(data?.portals) ? data.portals : [];
|
|
245
|
+
draft.portalId = portals.value.length === 1 ? portals.value[0].id : null;
|
|
246
|
+
discoveredConnection.value = [draft.baseUrl.trim(), draft.username.trim(), draft.password].join("\n");
|
|
247
|
+
} catch (error: any) {
|
|
248
|
+
draft.deviceId = "";
|
|
249
|
+
draft.portalId = null;
|
|
250
|
+
portals.value = [];
|
|
251
|
+
discoveryError.value = error?.data?.message || error?.message || "Unable to connect to the HID reader.";
|
|
252
|
+
} finally {
|
|
253
|
+
discovering.value = false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
168
257
|
function submit() {
|
|
258
|
+
const portal = portals.value.find((item) => item.id === Number(draft.portalId));
|
|
169
259
|
const payload: Record<string, any> = {
|
|
170
260
|
...draft,
|
|
261
|
+
portalName: portal?.name || draft.portalName,
|
|
171
262
|
monitorPath: monitorEnabled.value ? "api/notifications" : "",
|
|
172
263
|
enabled: monitorEnabled.value,
|
|
173
264
|
status: monitorEnabled.value ? "active" : "inactive",
|
|
@@ -119,6 +119,7 @@
|
|
|
119
119
|
<span>HID reader name</span><strong>{{ selectedReader?.name || "N/A" }}</strong>
|
|
120
120
|
<span>Device ID</span><strong>{{ selectedReader?.deviceId || "N/A" }}</strong>
|
|
121
121
|
<span>Location</span><strong>{{ selectedReader?.location || "N/A" }}</strong>
|
|
122
|
+
<span>HID Portal</span><strong>{{ selectedReader?.portalName || selectedReader?.portalId || "N/A" }}</strong>
|
|
122
123
|
<span>Monitor Path</span><strong>{{ selectedReader?.monitorPath || "N/A" }}</strong>
|
|
123
124
|
<span>Status</span><strong>{{ formatReaderStatus(resolveReaderStatus(selectedReader)) }}</strong>
|
|
124
125
|
<span>Username</span><strong>{{ selectedReader?.username || "N/A" }}</strong>
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
</v-btn>
|
|
13
13
|
|
|
14
14
|
<v-select
|
|
15
|
-
v-if="readers.length
|
|
15
|
+
v-if="readers.length"
|
|
16
16
|
v-model="selectedReaderId"
|
|
17
17
|
:items="readerOptions"
|
|
18
18
|
item-title="title"
|
|
@@ -48,6 +48,18 @@
|
|
|
48
48
|
/>
|
|
49
49
|
</div>
|
|
50
50
|
|
|
51
|
+
<v-alert
|
|
52
|
+
v-if="selectedReader"
|
|
53
|
+
:type="selectedReader.portalId && selectedReader.portalName ? 'info' : 'warning'"
|
|
54
|
+
variant="tonal"
|
|
55
|
+
density="compact"
|
|
56
|
+
class="mb-4"
|
|
57
|
+
icon="mdi-door-open"
|
|
58
|
+
>
|
|
59
|
+
Access Reader: {{ selectedReader.name || "HID Reader" }} · Access Portal:
|
|
60
|
+
{{ selectedReader.portalName || "Not configured" }}
|
|
61
|
+
</v-alert>
|
|
62
|
+
|
|
51
63
|
<v-card flat border class="table-card">
|
|
52
64
|
<div class="table-refresh">
|
|
53
65
|
<v-btn
|
|
@@ -429,10 +441,13 @@ const statusOptions = ["Status", "Mapped", "Unmapped"];
|
|
|
429
441
|
|
|
430
442
|
const readerOptions = computed(() =>
|
|
431
443
|
readers.value.map((reader) => ({
|
|
432
|
-
title: reader.name || reader.deviceId || reader._id
|
|
444
|
+
title: `${reader.name || reader.deviceId || reader._id} — ${reader.portalName || "Portal not configured"}`,
|
|
433
445
|
value: reader._id,
|
|
434
446
|
}))
|
|
435
447
|
);
|
|
448
|
+
const selectedReader = computed(() =>
|
|
449
|
+
readers.value.find((reader) => reader._id === (selectedReaderId.value || form.reader)) || null,
|
|
450
|
+
);
|
|
436
451
|
|
|
437
452
|
const pageRange = computed(() => {
|
|
438
453
|
if (serverPageRange.value) return serverPageRange.value;
|
|
@@ -689,6 +704,10 @@ async function saveUser() {
|
|
|
689
704
|
showToast("Access PIN must contain numbers only.", "error");
|
|
690
705
|
return;
|
|
691
706
|
}
|
|
707
|
+
if (selectedPhotoFile.value && (!selectedReader.value?.portalId || !selectedReader.value?.portalName)) {
|
|
708
|
+
showToast("Reconnect this HID reader and select a portal before enrolling facial recognition.", "error");
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
692
711
|
|
|
693
712
|
saving.value = true;
|
|
694
713
|
try {
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
v-model:cards="memberPassCards" :settings="props.settings" :loading="props.settingsLoading"
|
|
76
76
|
:site-id="props.site" :unit-id="props.unitId" :visitor-type="props.type"
|
|
77
77
|
:hid-qr-code-enabled="props.hidQrCodeEnabled" :hid-qr-printer-configured="props.hidQrPrinterConfigured"
|
|
78
|
+
:hid-reader-name="props.hidReaderName" :hid-portal-name="props.hidPortalName"
|
|
78
79
|
:excluded-card-ids="props.selectedNfcCards.map((c) => c._id)" />
|
|
79
80
|
</v-col>
|
|
80
81
|
|
|
@@ -192,6 +193,14 @@ const props = defineProps({
|
|
|
192
193
|
type: Boolean,
|
|
193
194
|
default: false,
|
|
194
195
|
},
|
|
196
|
+
hidReaderName: {
|
|
197
|
+
type: String,
|
|
198
|
+
default: "",
|
|
199
|
+
},
|
|
200
|
+
hidPortalName: {
|
|
201
|
+
type: String,
|
|
202
|
+
default: "",
|
|
203
|
+
},
|
|
195
204
|
selectedNfcCards: {
|
|
196
205
|
type: Array as PropType<{ _id: string; cardNo: string }[]>,
|
|
197
206
|
default: () => [],
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-container fluid class="pa-6">
|
|
3
3
|
<!--
|
|
4
|
-
THE DESIGN'S TITLE ROW.
|
|
5
|
-
|
|
6
|
-
the same line, drawn by the shared `PageHeader` at 22px/800 so this
|
|
7
|
-
screen's heading matches every other screen's.
|
|
4
|
+
THE DESIGN'S TITLE ROW. Drawn by the shared `PageHeader` at 22px/800 so
|
|
5
|
+
this screen's heading matches every other screen's.
|
|
8
6
|
-->
|
|
9
7
|
<PageHeader title="Subscription Plans">
|
|
10
8
|
<template #actions>
|
|
@@ -19,13 +17,6 @@
|
|
|
19
17
|
</PageHeader>
|
|
20
18
|
|
|
21
19
|
<!-- Search & Filters Bar -->
|
|
22
|
-
<!--
|
|
23
|
-
The primitives carry these exactly: the search is a string ref, and both
|
|
24
|
-
filters are string refs over `{ title, value }` options whose values are
|
|
25
|
-
already strings, so `AppSelect`'s native branch emits the same string
|
|
26
|
-
this screen has always stored ("all", "monthly", ...). No filter's
|
|
27
|
-
meaning, order or default changed.
|
|
28
|
-
-->
|
|
29
20
|
<v-row class="mb-2" density="compact">
|
|
30
21
|
<!-- Search Input -->
|
|
31
22
|
<v-col cols="12" sm="4" md="3">
|
|
@@ -54,16 +45,15 @@
|
|
|
54
45
|
<!-- Main Table Card -->
|
|
55
46
|
<AppCard class="table-card mt-4">
|
|
56
47
|
<!--
|
|
57
|
-
The tabs and the refresh
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
and it follows the theme because it is tokenised.
|
|
48
|
+
The tabs and the refresh/pagination are the card's own toolbar row,
|
|
49
|
+
which is where the design draws tabs - it follows the theme because
|
|
50
|
+
it is tokenised, unlike the old hard-coded `bg-grey-lighten-4` strip.
|
|
61
51
|
-->
|
|
62
|
-
<CardToolbar>
|
|
52
|
+
<CardToolbar :count="pageRange">
|
|
63
53
|
<template #tabs>
|
|
64
54
|
<v-tabs v-model="activeTab" color="primary" density="compact">
|
|
65
55
|
<v-tab value="active" class="text-none font-weight-bold">Active</v-tab>
|
|
66
|
-
<v-tab value="deactive" class="text-none font-weight-bold">
|
|
56
|
+
<v-tab value="deactive" class="text-none font-weight-bold">Deactivated</v-tab>
|
|
67
57
|
</v-tabs>
|
|
68
58
|
</template>
|
|
69
59
|
|
|
@@ -75,15 +65,22 @@
|
|
|
75
65
|
aria-label="Refresh"
|
|
76
66
|
@click="refresh"
|
|
77
67
|
/>
|
|
68
|
+
<local-pagination
|
|
69
|
+
v-model="page"
|
|
70
|
+
:length="pages"
|
|
71
|
+
@update:value="getSubscriptionPlans"
|
|
72
|
+
/>
|
|
78
73
|
</template>
|
|
79
74
|
</CardToolbar>
|
|
80
75
|
|
|
81
76
|
<!-- Table View -->
|
|
82
77
|
<v-data-table
|
|
83
78
|
:headers="headers"
|
|
84
|
-
:items="
|
|
85
|
-
:
|
|
86
|
-
|
|
79
|
+
:items="items"
|
|
80
|
+
:loading="loading"
|
|
81
|
+
item-value="id"
|
|
82
|
+
items-per-page="20"
|
|
83
|
+
hide-default-footer
|
|
87
84
|
class="elevation-0"
|
|
88
85
|
>
|
|
89
86
|
<!-- Plan Name -->
|
|
@@ -93,7 +90,7 @@
|
|
|
93
90
|
|
|
94
91
|
<!-- Plan Type -->
|
|
95
92
|
<template #item.planType="{ item }">
|
|
96
|
-
{{ item.planType ===
|
|
93
|
+
{{ item.planType === "free_bundle" ? "Free" : "Paid" }}
|
|
97
94
|
</template>
|
|
98
95
|
|
|
99
96
|
<!-- Billing Cycle -->
|
|
@@ -113,17 +110,18 @@
|
|
|
113
110
|
shape (no leading dot) rather than a colour it has not earned. -->
|
|
114
111
|
<template #item.applicationIds="{ item }">
|
|
115
112
|
<StatusChip tone="neutral" :dot="false">
|
|
116
|
-
{{ item.applicationIds.length }} Application{{
|
|
113
|
+
{{ item.applicationIds.length }} Application{{
|
|
114
|
+
item.applicationIds.length > 1 ? "s" : ""
|
|
115
|
+
}}
|
|
117
116
|
</StatusChip>
|
|
118
117
|
</template>
|
|
119
118
|
|
|
120
119
|
<!-- Status Badge -->
|
|
121
120
|
<!-- The shared word -> tone map: "Active" is `ok` (was Vuetify
|
|
122
|
-
`success`) and "
|
|
123
|
-
which is the grey it already was.
|
|
124
|
-
meanings. -->
|
|
121
|
+
`success`) and "Deactivated" is unmapped, so it comes out
|
|
122
|
+
neutral - which is the grey it already was. -->
|
|
125
123
|
<template #item.status="{ item }">
|
|
126
|
-
<StatusChip :status="item.status === 'active' ? 'Active' : '
|
|
124
|
+
<StatusChip :status="item.status === 'active' ? 'Active' : 'Deactivated'" />
|
|
127
125
|
</template>
|
|
128
126
|
|
|
129
127
|
<!-- Actions -->
|
|
@@ -154,9 +152,15 @@
|
|
|
154
152
|
|
|
155
153
|
<!-- Toggle Status (Deactivate / Activate) -->
|
|
156
154
|
<v-list-item
|
|
157
|
-
:prepend-icon="
|
|
155
|
+
:prepend-icon="
|
|
156
|
+
item.status === 'active'
|
|
157
|
+
? 'mdi-trash-can-outline'
|
|
158
|
+
: 'mdi-check-circle-outline'
|
|
159
|
+
"
|
|
158
160
|
:title="item.status === 'active' ? 'Deactivate' : 'Activate'"
|
|
159
|
-
:class="
|
|
161
|
+
:class="
|
|
162
|
+
item.status === 'active' ? 'text-error' : 'text-success'
|
|
163
|
+
"
|
|
160
164
|
value="toggle-status"
|
|
161
165
|
class="text-body-2"
|
|
162
166
|
@click="togglePlanStatus(item)"
|
|
@@ -187,12 +191,15 @@
|
|
|
187
191
|
/>
|
|
188
192
|
</v-card>
|
|
189
193
|
</v-dialog>
|
|
194
|
+
|
|
195
|
+
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
190
196
|
</v-container>
|
|
191
197
|
</template>
|
|
192
198
|
|
|
193
199
|
<script setup lang="ts">
|
|
194
|
-
import { ref,
|
|
200
|
+
import { ref, computed, watch, watchEffect } from "vue";
|
|
195
201
|
import CreateSubscriptionPlan from "./CreateSubsciptionPlan.vue";
|
|
202
|
+
import useSubscriptionPlan from "../composables/useSubscriptionPlan";
|
|
196
203
|
|
|
197
204
|
import type {
|
|
198
205
|
SubscriptionPlan,
|
|
@@ -208,7 +215,12 @@ const headers = [
|
|
|
208
215
|
{ title: "Price", key: "price", align: "start" as const },
|
|
209
216
|
{ title: "Applications", key: "applicationIds", align: "center" as const },
|
|
210
217
|
{ title: "Status", key: "status", align: "center" as const },
|
|
211
|
-
{
|
|
218
|
+
{
|
|
219
|
+
title: "Actions",
|
|
220
|
+
key: "actions",
|
|
221
|
+
align: "center" as const,
|
|
222
|
+
sortable: false,
|
|
223
|
+
},
|
|
212
224
|
];
|
|
213
225
|
|
|
214
226
|
// Select Options
|
|
@@ -225,30 +237,12 @@ const billingOptions = [
|
|
|
225
237
|
{ title: "Yearly", value: "annually" },
|
|
226
238
|
];
|
|
227
239
|
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
planType: "paid_bundle",
|
|
235
|
-
billingCycle: "monthly",
|
|
236
|
-
price: 299,
|
|
237
|
-
applicationIds: ["crm"],
|
|
238
|
-
status: "active",
|
|
239
|
-
},
|
|
240
|
-
{
|
|
241
|
-
id: "2",
|
|
242
|
-
name: "Premium",
|
|
243
|
-
description: "",
|
|
244
|
-
maxSeats: 25,
|
|
245
|
-
planType: "paid_bundle",
|
|
246
|
-
billingCycle: "annually",
|
|
247
|
-
price: 299,
|
|
248
|
-
applicationIds: ["crm", "hrm"],
|
|
249
|
-
status: "active",
|
|
250
|
-
},
|
|
251
|
-
]);
|
|
240
|
+
const {
|
|
241
|
+
getAll: _getAll,
|
|
242
|
+
add: _add,
|
|
243
|
+
updateById: _updateById,
|
|
244
|
+
updateStatusById: _updateStatusById,
|
|
245
|
+
} = useSubscriptionPlan();
|
|
252
246
|
|
|
253
247
|
const search = ref("");
|
|
254
248
|
const categoryFilter = ref("all");
|
|
@@ -256,38 +250,79 @@ const billingFilter = ref<"all" | BillingCycle>("all");
|
|
|
256
250
|
const activeTab = ref<"active" | "deactive">("active");
|
|
257
251
|
|
|
258
252
|
const page = ref(1);
|
|
259
|
-
const
|
|
253
|
+
const pages = ref(0);
|
|
254
|
+
const pageRange = ref("-- - -- of --");
|
|
255
|
+
|
|
256
|
+
const message = ref("");
|
|
257
|
+
const messageSnackbar = ref(false);
|
|
258
|
+
const messageColor = ref("");
|
|
260
259
|
|
|
261
260
|
const showModal = ref(false);
|
|
262
261
|
const editingPlan = ref<SubscriptionPlan | null>(null);
|
|
262
|
+
const items = ref<SubscriptionPlan[]>([]);
|
|
263
|
+
|
|
264
|
+
function normalizePlan(raw: Record<string, any>): SubscriptionPlan {
|
|
265
|
+
return {
|
|
266
|
+
id: String(raw._id ?? raw.id),
|
|
267
|
+
name: raw.name,
|
|
268
|
+
description: raw.description ?? "",
|
|
269
|
+
maxSeats: raw.maxSeats,
|
|
270
|
+
planType: raw.planType,
|
|
271
|
+
billingCycle: raw.billingCycle ?? null,
|
|
272
|
+
price: raw.price ?? null,
|
|
273
|
+
applicationIds: (raw.applications ?? []).map(
|
|
274
|
+
(app: Record<string, any>) => app.applicationId
|
|
275
|
+
),
|
|
276
|
+
status: raw.status === "active" ? "active" : "deactive",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
263
279
|
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
280
|
+
const {
|
|
281
|
+
data: getSubscriptionPlansReq,
|
|
282
|
+
refresh: getSubscriptionPlans,
|
|
283
|
+
status: getAllReqStatus,
|
|
284
|
+
} = useLazyAsyncData("get-all-subscription-plans", () =>
|
|
285
|
+
_getAll({
|
|
286
|
+
page: page.value,
|
|
287
|
+
search: search.value,
|
|
288
|
+
status: activeTab.value,
|
|
289
|
+
planType: categoryFilter.value === "all" ? "" : categoryFilter.value,
|
|
290
|
+
billingCycle: billingFilter.value === "all" ? "" : billingFilter.value,
|
|
291
|
+
})
|
|
292
|
+
);
|
|
274
293
|
|
|
275
|
-
|
|
276
|
-
return false;
|
|
277
|
-
}
|
|
294
|
+
const loading = computed(() => getAllReqStatus.value === "pending");
|
|
278
295
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
296
|
+
watchEffect(() => {
|
|
297
|
+
if (getSubscriptionPlansReq.value) {
|
|
298
|
+
items.value = (getSubscriptionPlansReq.value.items ?? []).map(
|
|
299
|
+
normalizePlan
|
|
300
|
+
);
|
|
301
|
+
pages.value = getSubscriptionPlansReq.value.pages;
|
|
302
|
+
pageRange.value = getSubscriptionPlansReq.value.pageRange;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
282
305
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
);
|
|
306
|
+
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
307
|
+
watch(search, () => {
|
|
308
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
309
|
+
searchDebounce = setTimeout(() => {
|
|
310
|
+
page.value = 1;
|
|
311
|
+
getSubscriptionPlans();
|
|
312
|
+
}, 400);
|
|
313
|
+
});
|
|
286
314
|
|
|
287
|
-
watch([
|
|
315
|
+
watch([categoryFilter, billingFilter, activeTab], () => {
|
|
288
316
|
page.value = 1;
|
|
317
|
+
getSubscriptionPlans();
|
|
289
318
|
});
|
|
290
319
|
|
|
320
|
+
function showMessage(msg: string, color: string) {
|
|
321
|
+
message.value = msg;
|
|
322
|
+
messageColor.value = color;
|
|
323
|
+
messageSnackbar.value = true;
|
|
324
|
+
}
|
|
325
|
+
|
|
291
326
|
function formatBillingCycle(cycle?: string) {
|
|
292
327
|
if (!cycle) return "—";
|
|
293
328
|
if (cycle === "annually" || cycle === "yearly") return "Yearly";
|
|
@@ -306,6 +341,7 @@ function refresh() {
|
|
|
306
341
|
search.value = "";
|
|
307
342
|
categoryFilter.value = "all";
|
|
308
343
|
billingFilter.value = "all";
|
|
344
|
+
getSubscriptionPlans();
|
|
309
345
|
}
|
|
310
346
|
|
|
311
347
|
function openCreateModal() {
|
|
@@ -323,43 +359,71 @@ function closeModal() {
|
|
|
323
359
|
editingPlan.value = null;
|
|
324
360
|
}
|
|
325
361
|
|
|
326
|
-
function
|
|
327
|
-
|
|
328
|
-
payload.
|
|
329
|
-
|
|
330
|
-
: payload.
|
|
362
|
+
function buildApplications(payload: PlanFormState) {
|
|
363
|
+
if (payload.planType === "custom_apps") {
|
|
364
|
+
return payload.customApps.map((applicationId) => ({
|
|
365
|
+
applicationId,
|
|
366
|
+
price: payload.customAppPrices[applicationId] ?? 0,
|
|
367
|
+
}));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return payload.bundleApps.map((applicationId) => ({ applicationId }));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function handleSubmit(payload: PlanFormState) {
|
|
374
|
+
const applications = buildApplications(payload);
|
|
331
375
|
|
|
332
376
|
const price =
|
|
333
377
|
payload.planType === "custom_apps"
|
|
334
|
-
?
|
|
378
|
+
? payload.customApps.reduce(
|
|
379
|
+
(total, value) => total + (payload.customAppPrices[value] ?? 0),
|
|
380
|
+
0
|
|
381
|
+
)
|
|
335
382
|
: payload.planType === "paid_bundle"
|
|
336
383
|
? payload.bundlePrice
|
|
337
384
|
: null;
|
|
338
385
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
id
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
386
|
+
const body = {
|
|
387
|
+
name: payload.name,
|
|
388
|
+
description: payload.description,
|
|
389
|
+
maxSeats: payload.maxSeats,
|
|
390
|
+
planType: payload.planType,
|
|
391
|
+
billingCycle: payload.billingCycle,
|
|
392
|
+
price,
|
|
393
|
+
applications,
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
if (editingPlan.value) {
|
|
398
|
+
await _updateById(editingPlan.value.id, body);
|
|
399
|
+
showMessage("Subscription plan updated successfully!", "success");
|
|
400
|
+
} else {
|
|
401
|
+
await _add(body);
|
|
402
|
+
showMessage("Subscription plan created successfully!", "success");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
await getSubscriptionPlans();
|
|
406
|
+
closeModal();
|
|
407
|
+
} catch (error: any) {
|
|
408
|
+
showMessage(
|
|
409
|
+
error?.response?._data?.message || "Failed to save subscription plan.",
|
|
410
|
+
"error"
|
|
411
|
+
);
|
|
361
412
|
}
|
|
413
|
+
}
|
|
362
414
|
|
|
363
|
-
|
|
415
|
+
async function togglePlanStatus(plan: SubscriptionPlan) {
|
|
416
|
+
const nextStatus = plan.status === "active" ? "deactive" : "active";
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
await _updateStatusById(plan.id, nextStatus);
|
|
420
|
+
await getSubscriptionPlans();
|
|
421
|
+
showMessage("Subscription plan status updated successfully!", "success");
|
|
422
|
+
} catch (error: any) {
|
|
423
|
+
showMessage(
|
|
424
|
+
error?.response?._data?.message || "Failed to update plan status.",
|
|
425
|
+
"error"
|
|
426
|
+
);
|
|
427
|
+
}
|
|
364
428
|
}
|
|
365
429
|
</script>
|