@7365admin1/layer-common 3.2.2-staging.106 → 3.2.2-staging.107

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.
@@ -1,22 +1,12 @@
1
1
  <template>
2
- <v-card class="pa-2">
2
+ <v-card class="pa-2 pb-0">
3
3
  <!-- Header -->
4
- <v-card-title class="d-flex align-center justify-space-between pb-2">
4
+ <v-card-title class="pb-2">
5
5
  <span class="text-h6 font-weight-bold">
6
6
  {{ isEditing ? "Edit Plan" : "Create New Plan" }}
7
7
  </span>
8
-
9
- <v-btn
10
- icon="mdi-close"
11
- variant="text"
12
- density="comfortable"
13
- aria-label="Close"
14
- @click="$emit('cancel')"
15
- />
16
8
  </v-card-title>
17
9
 
18
- <v-divider />
19
-
20
10
  <!-- Form Content -->
21
11
  <v-form ref="formRef" class="my-4" @submit.prevent="submit">
22
12
  <v-card-text class="pa-2 max-h-70vh overflow-y-auto">
@@ -68,8 +58,6 @@
68
58
  </template>
69
59
  </v-text-field>
70
60
 
71
- <v-divider class="my-4" />
72
-
73
61
  <!-- Pricing Section -->
74
62
  <div class="text-subtitle-2 font-weight-bold mb-3 text-grey-darken-3">
75
63
  Pricing
@@ -116,14 +104,14 @@
116
104
  selected-class="bg-grey-darken-3 text-white"
117
105
  >
118
106
  <v-chip
119
- v-for="app in AVAILABLE_APPLICATIONS"
120
- :key="app.id"
121
- :value="app.id"
107
+ v-for="app in APP_LIST"
108
+ :key="app.value"
109
+ :value="app.value"
122
110
  filter
123
111
  variant="outlined"
124
112
  size="small"
125
113
  >
126
- {{ app.name }}
114
+ {{ app.title }}
127
115
  </v-chip>
128
116
  </v-chip-group>
129
117
  </div>
@@ -155,26 +143,37 @@
155
143
  <v-card variant="outlined" class="pa-1 mb-3">
156
144
  <v-list density="compact" class="py-0">
157
145
  <v-list-item
158
- v-for="app in AVAILABLE_APPLICATIONS"
159
- :key="app.id"
146
+ v-for="app in APP_LIST"
147
+ :key="app.value"
160
148
  class="px-2"
161
149
  >
162
150
  <template #prepend>
163
151
  <v-checkbox-btn
164
- :model-value="form.customApps.includes(app.id)"
152
+ :model-value="form.customApps.includes(app.value)"
165
153
  density="compact"
166
- @update:model-value="toggleApp(form.customApps, app.id)"
154
+ @update:model-value="toggleApp(form.customApps, app.value)"
167
155
  />
168
156
  </template>
169
157
 
170
158
  <v-list-item-title class="text-body-2">
171
- {{ app.name }}
159
+ {{ app.title }}
172
160
  </v-list-item-title>
173
161
 
174
162
  <template #append>
175
- <span class="text-caption text-grey">
176
- ${{ form.billingCycle === 'annually' ? app.yearlyPrice : app.monthlyPrice }}/{{ form.billingCycle === 'annually' ? 'yr' : 'mo' }}
177
- </span>
163
+ <div class="d-flex align-center" style="gap: 4px">
164
+ <span class="text-caption text-grey">$</span>
165
+ <v-text-field
166
+ v-model.number="form.customAppPrices[app.value]"
167
+ type="number"
168
+ min="0"
169
+ density="compact"
170
+ variant="outlined"
171
+ hide-details
172
+ style="width: 84px"
173
+ :disabled="!form.customApps.includes(app.value)"
174
+ />
175
+ <span class="text-caption text-grey">/{{ form.billingCycle === 'annually' ? 'yr' : 'mo' }}</span>
176
+ </div>
178
177
  </template>
179
178
  </v-list-item>
180
179
  </v-list>
@@ -211,28 +210,21 @@
211
210
  </v-alert>
212
211
  </v-card-text>
213
212
 
214
- <v-divider />
215
-
216
213
  <!-- Footer Buttons -->
217
- <v-card-actions class="pt-3 px-2">
218
- <v-btn
219
- variant="outlined"
220
- color="grey-darken-1"
221
- class="flex-1 text-none"
222
- @click="$emit('cancel')"
223
- >
224
- Cancel
225
- </v-btn>
226
-
227
- <v-btn
228
- type="submit"
229
- color="grey-darken-4"
230
- variant="flat"
231
- class="flex-1 text-none"
232
- >
233
- {{ isEditing ? "Save Changes" : "Create Plan" }}
234
- </v-btn>
235
- </v-card-actions>
214
+ <v-toolbar density="compact">
215
+ <v-row no-gutters>
216
+ <v-col cols="6">
217
+ <v-btn tile block variant="text" class="text-none" size="48" @click="$emit('cancel')">
218
+ Cancel
219
+ </v-btn>
220
+ </v-col>
221
+ <v-col cols="6">
222
+ <v-btn tile block type="submit" variant="flat" color="primary" class="text-none" size="48">
223
+ {{ isEditing ? "Save Changes" : "Create Plan" }}
224
+ </v-btn>
225
+ </v-col>
226
+ </v-row>
227
+ </v-toolbar>
236
228
  </v-form>
237
229
  </v-card>
238
230
  </template>
@@ -241,7 +233,9 @@
241
233
  import { reactive, computed, watch } from "vue";
242
234
 
243
235
  import {
244
- AVAILABLE_APPLICATIONS,
236
+ APP_LIST,
237
+ STATIC_APP_MONTHLY_PRICE,
238
+ STATIC_APP_YEARLY_PRICE,
245
239
  type PlanFormState,
246
240
  type SubscriptionPlan,
247
241
  } from "../types/subscription";
@@ -284,6 +278,14 @@ const form = reactive<PlanFormState>({
284
278
  props.initialPlan?.planType === "custom_apps"
285
279
  ? [...(props.initialPlan?.applicationIds ?? [])]
286
280
  : [],
281
+ customAppPrices: Object.fromEntries(
282
+ APP_LIST.map((app) => [
283
+ app.value,
284
+ props.initialPlan?.billingCycle === "annually"
285
+ ? STATIC_APP_YEARLY_PRICE
286
+ : STATIC_APP_MONTHLY_PRICE,
287
+ ])
288
+ ),
287
289
  });
288
290
 
289
291
  const errors = reactive({
@@ -304,6 +306,29 @@ watch(
304
306
  }
305
307
  );
306
308
 
309
+ function staticPriceForCycle(cycle: typeof form.billingCycle) {
310
+ return cycle === "annually" ? STATIC_APP_YEARLY_PRICE : STATIC_APP_MONTHLY_PRICE;
311
+ }
312
+
313
+ /*
314
+ * Keep custom app prices in sync with the billing cycle, but only for
315
+ * apps still sitting at the previous cycle's default (leave manual edits alone)
316
+ */
317
+ watch(
318
+ () => form.billingCycle,
319
+ (newCycle, oldCycle) => {
320
+ const oldDefault = staticPriceForCycle(oldCycle);
321
+ const newDefault = staticPriceForCycle(newCycle);
322
+ if (oldDefault === newDefault) return;
323
+
324
+ for (const app of APP_LIST) {
325
+ if (form.customAppPrices[app.value] === oldDefault) {
326
+ form.customAppPrices[app.value] = newDefault;
327
+ }
328
+ }
329
+ }
330
+ );
331
+
307
332
  /*
308
333
  * Toggle application selection helper
309
334
  */
@@ -320,15 +345,10 @@ function toggleApp(list: string[], id: string) {
320
345
  * Custom app total price calculation
321
346
  */
322
347
  const customTotalPrice = computed(() => {
323
- return form.customApps.reduce((total, id) => {
324
- const app = AVAILABLE_APPLICATIONS.find((item) => item.id === id);
325
- if (!app) return total;
326
-
327
- return (
328
- total +
329
- (form.billingCycle === "annually" ? app.yearlyPrice : app.monthlyPrice)
330
- );
331
- }, 0);
348
+ return form.customApps.reduce(
349
+ (total, value) => total + (form.customAppPrices[value] ?? 0),
350
+ 0
351
+ );
332
352
  });
333
353
 
334
354
  /*
@@ -340,6 +360,11 @@ function validate(): boolean {
340
360
 
341
361
  if (form.planType !== "free_bundle" && !form.billingCycle) {
342
362
  errors.pricing = "Please select a billing cycle.";
363
+ } else if (
364
+ (form.planType === "free_bundle" || form.planType === "paid_bundle") &&
365
+ form.bundleApps.length === 0
366
+ ) {
367
+ errors.pricing = "Select at least one application.";
343
368
  } else if (
344
369
  form.planType === "paid_bundle" &&
345
370
  (!form.bundlePrice || form.bundlePrice <= 0)
@@ -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>
@@ -0,0 +1,62 @@
1
+ export default function useSubscriptionPlan() {
2
+ function getAll({
3
+ page = 1,
4
+ search = "",
5
+ limit = 10,
6
+ status = "active",
7
+ planType = "",
8
+ billingCycle = "",
9
+ } = {}) {
10
+ return useNuxtApp().$api<Record<string, any>>(`/api/subscription-plans`, {
11
+ method: "GET",
12
+ query: {
13
+ page,
14
+ search,
15
+ limit,
16
+ status,
17
+ planType,
18
+ billingCycle,
19
+ },
20
+ });
21
+ }
22
+ function add(value: any) {
23
+ return useNuxtApp().$api<Record<string, any>>("/api/subscription-plans", {
24
+ method: "POST",
25
+ body: value,
26
+ });
27
+ }
28
+ function getById(id: string) {
29
+ return useNuxtApp().$api<Record<string, any>>(
30
+ `/api/subscription-plans/id/${id}`,
31
+ {
32
+ method: "GET",
33
+ }
34
+ );
35
+ }
36
+ function updateById(id: string, value: any) {
37
+ return useNuxtApp().$api<Record<string, any>>(
38
+ `/api/subscription-plans/${id}`,
39
+ {
40
+ method: "PUT",
41
+ body: value,
42
+ }
43
+ );
44
+ }
45
+ function updateStatusById(id: string, status: "active" | "deactive") {
46
+ return useNuxtApp().$api<Record<string, any>>(
47
+ `/api/subscription-plans/${id}/status`,
48
+ {
49
+ method: "PATCH",
50
+ body: { status },
51
+ }
52
+ );
53
+ }
54
+
55
+ return {
56
+ getAll,
57
+ add,
58
+ getById,
59
+ updateById,
60
+ updateStatusById,
61
+ };
62
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.2-staging.106",
5
+ "version": "3.2.2-staging.107",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -5,10 +5,8 @@ export type PlanType = "free_bundle" | "paid_bundle" | "custom_apps";
5
5
  export type BillingCycle = "monthly" | "annually";
6
6
 
7
7
  export interface AppOption {
8
- id: string;
9
- name: string;
10
- monthlyPrice: number;
11
- yearlyPrice: number;
8
+ title: string;
9
+ value: string;
12
10
  }
13
11
 
14
12
  export interface SubscriptionPlan {
@@ -32,6 +30,7 @@ export interface PlanFormState {
32
30
  bundleApps: string[];
33
31
  bundlePrice: number | null;
34
32
  customApps: string[];
33
+ customAppPrices: Record<string, number>;
35
34
  }
36
35
 
37
36
  export const PLAN_TYPE_LABEL: Record<PlanType, string> = {
@@ -40,12 +39,20 @@ export const PLAN_TYPE_LABEL: Record<PlanType, string> = {
40
39
  custom_apps: "Paid (Custom Apps)",
41
40
  };
42
41
 
43
- // Mock catalogue of applications that can be bundled into a plan.
42
+ // Catalogue of applications that can be bundled into a plan.
44
43
  // Replace with data fetched from your backend.
45
- export const AVAILABLE_APPLICATIONS: AppOption[] = [
46
- { id: "crm", name: "CRM", monthlyPrice: 49, yearlyPrice: 490 },
47
- { id: "hrm", name: "HRM", monthlyPrice: 39, yearlyPrice: 390 },
48
- { id: "accounting", name: "Accounting", monthlyPrice: 59, yearlyPrice: 590 },
49
- { id: "inventory", name: "Inventory", monthlyPrice: 45, yearlyPrice: 450 },
50
- { id: "pos", name: "POS", monthlyPrice: 69, yearlyPrice: 690 },
44
+ export const APP_LIST: AppOption[] = [
45
+ { title: "Organization", value: "organization" },
46
+ { title: "Security Agency", value: "security_agency" },
47
+ { title: "Cleaning Services", value: "cleaning_services" },
48
+ { title: "Property Management Agency", value: "property_management_agency" },
49
+ { title: "Mechanical & Electrical Services", value: "mechanical_electrical_services" },
50
+ { title: "Pest Control Services", value: "pest_control_services" },
51
+ { title: "Landscaping Services", value: "landscaping_services" },
52
+ { title: "Pool Maintenance Services", value: "pool_maintenance_services" },
51
53
  ];
54
+
55
+ // Static placeholder price applied to every app, for now.
56
+ // Replace with real per-app pricing once that's available.
57
+ export const STATIC_APP_MONTHLY_PRICE = 49;
58
+ export const STATIC_APP_YEARLY_PRICE = 490;