@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.
@@ -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 &amp; 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 > 1"
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. Was a hand-rolled flex row with a `text-h5`
5
- heading and a pill button; it is the same title and the same button on
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 were a hand-built `bg-grey-lighten-4` strip -
58
- a hard-coded light grey that stayed light in the dark theme. They are
59
- the card's own toolbar row now, which is where the design draws tabs,
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">Deactive</v-tab>
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="filteredPlans"
85
- :page="page"
86
- :items-per-page="pageSize"
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 === 'free_bundle' ? 'Free' : 'Paid' }}
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{{ item.applicationIds.length > 1 ? 's' : '' }}
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 "Deactive" is unmapped, so it comes out neutral -
123
- which is the grey it already was. Same two words, same two
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' : 'Deactive'" />
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="item.status === 'active' ? 'mdi-trash-can-outline' : 'mdi-check-circle-outline'"
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="item.status === 'active' ? 'text-error' : 'text-success'"
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, reactive, computed, watch } from "vue";
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
- { title: "Actions", key: "actions", align: "center" as const, sortable: false },
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 plans = reactive<SubscriptionPlan[]>([
229
- {
230
- id: "1",
231
- name: "Standard",
232
- description: "",
233
- maxSeats: 10,
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 pageSize = 20;
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 filteredPlans = computed(() =>
265
- plans.filter((plan) => {
266
- if (plan.status !== activeTab.value) return false;
267
-
268
- if (
269
- search.value &&
270
- !plan.name.toLowerCase().includes(search.value.toLowerCase())
271
- ) {
272
- return false;
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
- if (categoryFilter.value !== "all" && plan.planType !== categoryFilter.value) {
276
- return false;
277
- }
294
+ const loading = computed(() => getAllReqStatus.value === "pending");
278
295
 
279
- if (billingFilter.value !== "all" && plan.billingCycle !== billingFilter.value) {
280
- return false;
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
- return true;
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([search, categoryFilter, billingFilter, activeTab], () => {
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 handleSubmit(payload: PlanFormState) {
327
- const applicationIds =
328
- payload.planType === "custom_apps"
329
- ? payload.customApps
330
- : payload.bundleApps;
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
- ? null
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
- if (editingPlan.value) {
340
- Object.assign(editingPlan.value, {
341
- name: payload.name,
342
- description: payload.description,
343
- maxSeats: payload.maxSeats,
344
- planType: payload.planType,
345
- billingCycle: payload.billingCycle,
346
- price,
347
- applicationIds,
348
- });
349
- } else {
350
- plans.push({
351
- id: crypto.randomUUID(),
352
- name: payload.name,
353
- description: payload.description,
354
- maxSeats: payload.maxSeats,
355
- planType: payload.planType,
356
- billingCycle: payload.billingCycle,
357
- price,
358
- applicationIds,
359
- status: "active",
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
- closeModal();
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>