@7365admin1/layer-common 3.2.2-staging.105 → 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="
|
|
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
|
|
120
|
-
:key="app.
|
|
121
|
-
:value="app.
|
|
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.
|
|
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
|
|
159
|
-
:key="app.
|
|
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.
|
|
152
|
+
:model-value="form.customApps.includes(app.value)"
|
|
165
153
|
density="compact"
|
|
166
|
-
@update:model-value="toggleApp(form.customApps, app.
|
|
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.
|
|
159
|
+
{{ app.title }}
|
|
172
160
|
</v-list-item-title>
|
|
173
161
|
|
|
174
162
|
<template #append>
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
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-
|
|
218
|
-
<v-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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(
|
|
324
|
-
|
|
325
|
-
|
|
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.
|
|
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>
|
|
@@ -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
package/types/subscription.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
-
//
|
|
42
|
+
// Catalogue of applications that can be bundled into a plan.
|
|
44
43
|
// Replace with data fetched from your backend.
|
|
45
|
-
export const
|
|
46
|
-
{
|
|
47
|
-
{
|
|
48
|
-
{
|
|
49
|
-
{
|
|
50
|
-
{
|
|
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;
|