refactor: coupon list and form
This commit is contained in:
@@ -1,230 +0,0 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ title: dialogTitle, size: '3xl' }">
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-4 pt-4">
|
||||
<FormControl
|
||||
v-model="doc.code"
|
||||
:label="__('Coupon Code')"
|
||||
:required="true"
|
||||
pattern="^[A-Za-z0-9]+$"
|
||||
minlength="6"
|
||||
@beforeinput="handleCodeInput"
|
||||
@input="doc.code = $event.target.value.toUpperCase()"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="doc.discount_type"
|
||||
:label="__('Discount Type')"
|
||||
:required="true"
|
||||
type="select"
|
||||
:options="['Percent', 'Amount']"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="doc.discount_type === 'Percent'"
|
||||
v-model="doc.percent_off"
|
||||
:required="true"
|
||||
:label="__('Discount Percentage')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
v-model="doc.amount_off"
|
||||
:required="true"
|
||||
:label="__('Discount Amount')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="doc.expires_on"
|
||||
:label="__('Expires On')"
|
||||
type="date"
|
||||
:description="__('Leave blank for no expiry')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="doc.usage_limit"
|
||||
:label="__('Usage Limit')"
|
||||
type="number"
|
||||
:placeholder="__('Unlimited')"
|
||||
/>
|
||||
<Switch v-model="doc.active" :label="__('Active')" />
|
||||
<div class="col-span-2">
|
||||
<div class="text-md font-medium text-ink-gray-7 mb-1 mt-2">
|
||||
{{ __('Select Courses/Batches')
|
||||
}}<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(row, idx) in doc.applicable_items"
|
||||
:key="idx"
|
||||
class="flex gap-2 items-end"
|
||||
>
|
||||
<FormControl
|
||||
class="w-28"
|
||||
v-model="row.reference_doctype"
|
||||
:label="__('Type')"
|
||||
type="select"
|
||||
:options="[
|
||||
{ label: 'Course ', value: 'LMS Course' },
|
||||
{ label: 'Batch ', value: 'LMS Batch' },
|
||||
]"
|
||||
@change="(val) => (row.reference_name = null)"
|
||||
/>
|
||||
<Link
|
||||
class="min-w-40"
|
||||
:doctype="row.reference_doctype || 'LMS Course'"
|
||||
:filters="getFilters(idx)"
|
||||
:label="__('Name')"
|
||||
:value="row.reference_name"
|
||||
@change="(opt) => (row.reference_name = opt)"
|
||||
/>
|
||||
<Button variant="subtle" @click="removeRow(idx)">
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="mt-2" @click="addRow">
|
||||
<template #prefix><Plus class="h-3 w-3" /></template>
|
||||
{{ __('Add Item') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="pb-5 float-right space-x-2">
|
||||
<Button variant="outline" @click="show = false">{{
|
||||
__('Cancel')
|
||||
}}</Button>
|
||||
<Button variant="solid" @click="save">{{ __('Save') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, Button, FormControl, createResource, toast } from 'frappe-ui'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel({ type: Boolean, required: true })
|
||||
const props = defineProps({ couponId: String })
|
||||
const emit = defineEmits(['saved'])
|
||||
|
||||
const doc = ref({
|
||||
code: '',
|
||||
discount_type: 'Percent',
|
||||
percent_off: null,
|
||||
amount_off: null,
|
||||
active: 1,
|
||||
expires_on: null,
|
||||
usage_limit: null,
|
||||
applicable_items: [],
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
props.couponId === 'new' ? __('New Coupon') : __('Edit Coupon')
|
||||
)
|
||||
|
||||
const getDoc = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams() {
|
||||
return { doctype: 'LMS Coupon', name: props.couponId }
|
||||
},
|
||||
onSuccess(data) {
|
||||
doc.value = data
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => show.value,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.couponId && props.couponId !== 'new') {
|
||||
getDoc.submit()
|
||||
} else {
|
||||
doc.value = {
|
||||
code: '',
|
||||
discount_type: 'Percent',
|
||||
active: 1,
|
||||
applicable_items: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function addRow() {
|
||||
doc.value.applicable_items.push({
|
||||
reference_doctype: 'LMS Course',
|
||||
reference_name: null,
|
||||
})
|
||||
}
|
||||
function removeRow(idx) {
|
||||
doc.value.applicable_items.splice(idx, 1)
|
||||
}
|
||||
|
||||
function getFilters(idx) {
|
||||
// don't show the batch or course that has already been selected
|
||||
const row = doc.value.applicable_items[idx]
|
||||
if (!row.reference_doctype) return {}
|
||||
const doctype = row.reference_doctype
|
||||
const selectedNames = doc.value.applicable_items
|
||||
.filter(
|
||||
(r, i) => i !== idx && r.reference_doctype === doctype && r.reference_name
|
||||
)
|
||||
.map((r) => r.reference_name)
|
||||
if (selectedNames.length === 0) return {}
|
||||
return {
|
||||
name: ['not in', selectedNames],
|
||||
}
|
||||
}
|
||||
|
||||
const saveDoc = createResource({
|
||||
url: 'frappe.client.save',
|
||||
makeParams(values) {
|
||||
return { doc: doc.value }
|
||||
},
|
||||
})
|
||||
|
||||
const insertDoc = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return { doc: { doctype: 'LMS Coupon', ...doc.value } }
|
||||
},
|
||||
})
|
||||
|
||||
function handleCodeInput(event) {
|
||||
if (event.data && !/^[A-Za-z0-9]*$/.test(event.data)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (props.couponId && props.couponId !== 'new') {
|
||||
saveDoc.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
toast.success(__('Saved'))
|
||||
show.value = false
|
||||
emit('saved')
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err.message || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
insertDoc.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
toast.success(__('Saved'))
|
||||
show.value = false
|
||||
emit('saved')
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err.message || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,144 +0,0 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="coupons.data?.length" class="overflow-y-scroll">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-surface-gray-2 text-ink-gray-7">
|
||||
<th class="text-left p-2">{{ __('Code') }}</th>
|
||||
<th class="text-left p-2">{{ __('Type') }}</th>
|
||||
<th class="text-left p-2">{{ __('Value') }}</th>
|
||||
<th class="text-left p-2">{{ __('Expires On') }}</th>
|
||||
<th class="text-left p-2">{{ __('Usage') }}</th>
|
||||
<th class="text-left p-2">{{ __('Active') }}</th>
|
||||
<th class="text-right p-2 w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in coupons.data"
|
||||
:key="row.name"
|
||||
class="hover:bg-surface-gray-2 cursor-pointer"
|
||||
@click="openForm(row.name)"
|
||||
>
|
||||
<td class="p-2">{{ row.code }}</td>
|
||||
<td class="p-2">{{ row.discount_type }}</td>
|
||||
<td class="p-2">
|
||||
<span v-if="row.discount_type === 'Percent'"
|
||||
>{{ row.percent_off }}%</span
|
||||
>
|
||||
<span v-else>{{ row.amount_off }}</span>
|
||||
</td>
|
||||
<td class="p-2">{{ row.expires_on || '-' }}</td>
|
||||
<td class="p-2">
|
||||
{{ row.times_redeemed }}/{{ row.usage_limit || '∞' }}
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<Badge v-if="row.active" theme="green">{{ __('Enabled') }}</Badge>
|
||||
<Badge v-else theme="gray">{{ __('Disabled') }}</Badge>
|
||||
</td>
|
||||
<td class="p-2 text-right" @click.stop>
|
||||
<Button variant="ghost" @click="confirmDelete(row)">
|
||||
<template #icon>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5 text-ink-red-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CouponDetails
|
||||
v-model="showDialog"
|
||||
:coupon-id="selected"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, Badge, createListResource, toast, call } from 'frappe-ui'
|
||||
import { ref, getCurrentInstance } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import CouponDetails from '@/components/Settings/CouponDetails.vue'
|
||||
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
defineProps({
|
||||
label: String,
|
||||
description: String,
|
||||
})
|
||||
|
||||
const showDialog = ref(false)
|
||||
const selected = ref(null)
|
||||
|
||||
const coupons = createListResource({
|
||||
doctype: 'LMS Coupon',
|
||||
fields: [
|
||||
'name',
|
||||
'code',
|
||||
'discount_type',
|
||||
'percent_off',
|
||||
'amount_off',
|
||||
'expires_on',
|
||||
'usage_limit',
|
||||
'times_redeemed',
|
||||
'active',
|
||||
],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
function openForm(id) {
|
||||
selected.value = id
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
coupons.reload()
|
||||
}
|
||||
|
||||
function confirmDelete(row) {
|
||||
$dialog({
|
||||
title: __('Delete this coupon?'),
|
||||
message: __(
|
||||
'This will permanently delete the coupon and the code will no longer work. Are you sure?'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick({ close }) {
|
||||
trashCoupon(row.name, close)
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function trashCoupon(name, close) {
|
||||
call('frappe.client.delete', { doctype: 'LMS Coupon', name }).then(() => {
|
||||
toast.success(__('Coupon deleted successfully'))
|
||||
coupons.reload()
|
||||
if (typeof close === 'function') close()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
245
frontend/src/components/Settings/Coupons/CouponDetails.vue
Normal file
245
frontend/src/components/Settings/Coupons/CouponDetails.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="flex flex-col text-base h-full overflow-y-auto">
|
||||
<div class="flex items-center space-x-2 mb-5">
|
||||
<Button variant="ghost" @click="emit('updateStep', 'list')">
|
||||
<template #icon>
|
||||
<ChevronLeft class="size-5 stroke-1 text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ data?.name ? __('Edit Coupon') : __('New Coupon') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="data.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormControl
|
||||
v-model="data.code"
|
||||
:label="__('Coupon Code')"
|
||||
:required="true"
|
||||
@input="() => (data.code = data.code.toUpperCase())"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.discount_type"
|
||||
:label="__('Discount Type')"
|
||||
:required="true"
|
||||
type="select"
|
||||
:options="['Percentage', 'Fixed Amount']"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.expires_on"
|
||||
:label="__('Expires On')"
|
||||
type="date"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-if="data.discount_type === 'Percentage'"
|
||||
v-model="data.percentage_discount"
|
||||
:required="true"
|
||||
:label="__('Discount Percentage')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
v-model="data.fixed_amount_discount"
|
||||
:required="true"
|
||||
:label="__('Discount Amount')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="data.usage_limit"
|
||||
:label="__('Usage Limit')"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.redemptions_count"
|
||||
:label="__('Redemptions Count')"
|
||||
type="number"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="py-8">
|
||||
<div class="font-semibold text-ink-gray-9 mb-2">
|
||||
{{ __('Applicable For') }}
|
||||
</div>
|
||||
<CouponItems ref="couponItems" :data="data" :coupons="coupons" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto w-full space-x-2">
|
||||
<Button variant="solid" class="float-right" @click="saveCoupon()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ChevronLeft, Plus, X } from 'lucide-vue-next'
|
||||
import type { Coupon, Coupons } from './types'
|
||||
import CouponItems from '@/components/Settings/Coupons/CouponItems.vue'
|
||||
|
||||
const couponItems = ref<any>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
coupons: Coupons
|
||||
data: Coupon
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const saveCoupon = () => {
|
||||
if (props.data?.name) {
|
||||
editCoupon()
|
||||
} else {
|
||||
createCoupon()
|
||||
}
|
||||
}
|
||||
|
||||
const editCoupon = () => {
|
||||
props.coupons.setValue.submit(
|
||||
{
|
||||
...props.data,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Coupon) {
|
||||
if (couponItems.value) {
|
||||
couponItems.value.saveItems()
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const createCoupon = () => {
|
||||
if (couponItems.value) {
|
||||
let rows = couponItems.value.saveItems()
|
||||
console.log(rows)
|
||||
props.data.applicable_items = rows
|
||||
}
|
||||
props.coupons.insert.submit(
|
||||
{
|
||||
...props.data,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Coupon) {
|
||||
toast.success(__('Coupon created successfully'))
|
||||
emit('updateStep', 'details', { ...data })
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.error(err.messages?.[0] || err.message || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
watch(
|
||||
() => show.value,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.couponId && props.couponId !== 'new') {
|
||||
getDoc.submit()
|
||||
} else {
|
||||
doc.value = {
|
||||
code: '',
|
||||
discount_type: 'Percent',
|
||||
active: 1,
|
||||
applicable_items: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) */
|
||||
/*
|
||||
function addRow() {
|
||||
doc.value.applicable_items.push({
|
||||
reference_doctype: 'LMS Course',
|
||||
reference_name: null,
|
||||
})
|
||||
}
|
||||
function removeRow(idx) {
|
||||
doc.value.applicable_items.splice(idx, 1)
|
||||
} */
|
||||
/*
|
||||
function getFilters(idx) {
|
||||
// don't show the batch or course that has already been selected
|
||||
const row = doc.value.applicable_items[idx]
|
||||
if (!row.reference_doctype) return {}
|
||||
const doctype = row.reference_doctype
|
||||
const selectedNames = doc.value.applicable_items
|
||||
.filter(
|
||||
(r, i) => i !== idx && r.reference_doctype === doctype && r.reference_name
|
||||
)
|
||||
.map((r) => r.reference_name)
|
||||
if (selectedNames.length === 0) return {}
|
||||
return {
|
||||
name: ['not in', selectedNames],
|
||||
}
|
||||
}
|
||||
|
||||
const saveDoc = createResource({
|
||||
url: 'frappe.client.save',
|
||||
makeParams(values) {
|
||||
return { doc: doc.value }
|
||||
},
|
||||
})
|
||||
|
||||
const insertDoc = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return { doc: { doctype: 'LMS Coupon', ...doc.value } }
|
||||
},
|
||||
})
|
||||
|
||||
function handleCodeInput(event) {
|
||||
if (event.data && !/^[A-Za-z0-9]*$/.test(event.data)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (props.couponId && props.couponId !== 'new') {
|
||||
saveDoc.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
toast.success(__('Saved'))
|
||||
emit('saved')
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err.message || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
insertDoc.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
toast.success(__('Saved'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err.message || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
} */
|
||||
</script>
|
||||
164
frontend/src/components/Settings/Coupons/CouponItems.vue
Normal file
164
frontend/src/components/Settings/Coupons/CouponItems.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative overflow-x-auto border rounded-md">
|
||||
<table class="w-full text-sm text-left text-ink-gray-5">
|
||||
<thead class="text-xs text-ink-gray-7 uppercase bg-surface-gray-2">
|
||||
<tr>
|
||||
<td scope="col" class="px-6 py-2">
|
||||
{{ __('Document Type') }}
|
||||
</td>
|
||||
<td scope="col" class="px-6 py-2">
|
||||
{{ __('Document Name') }}
|
||||
</td>
|
||||
<td scope="col" class="px-6 py-2 w-16"></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in rows"
|
||||
class="bg-white dark:bg-gray-800 dark:border-gray-700 border-gray-200"
|
||||
>
|
||||
<td class="px-6 py-2">
|
||||
<FormControl
|
||||
type="select"
|
||||
v-model="row.reference_doctype"
|
||||
:options="[
|
||||
{ label: 'Course', value: 'LMS Course' },
|
||||
{ label: 'Batch', value: 'LMS Batch' },
|
||||
]"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-2">
|
||||
<Link
|
||||
:doctype="row.reference_doctype"
|
||||
v-model="row.reference_name"
|
||||
class="bg-white"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-2">
|
||||
<Button variant="ghost" @click="removeRow(row)">
|
||||
<template #icon>
|
||||
<X class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<Button @click="addRow()">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add Row') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ApplicableItem, Coupon, Coupons } from './types'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Button, createListResource, FormControl } from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const rows = ref<
|
||||
{
|
||||
reference_doctype: string
|
||||
reference_name: string | null
|
||||
name: string | null
|
||||
}[]
|
||||
>([])
|
||||
|
||||
const props = defineProps<{
|
||||
data: Coupon
|
||||
coupons: Coupons
|
||||
}>()
|
||||
|
||||
const applicableItems = createListResource({
|
||||
doctype: 'LMS Coupon Item',
|
||||
fields: [
|
||||
'reference_doctype',
|
||||
'reference_name',
|
||||
'name',
|
||||
'parent',
|
||||
'parenttype',
|
||||
'parentfield',
|
||||
],
|
||||
parent: 'LMS Coupon',
|
||||
onSuccess(data: ApplicableItem[]) {
|
||||
rows.value = data
|
||||
},
|
||||
})
|
||||
|
||||
const addRow = () => {
|
||||
rows.value.push({
|
||||
reference_doctype: 'LMS Course',
|
||||
reference_name: null,
|
||||
name: null,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
if (props.data?.name) {
|
||||
applicableItems.update({
|
||||
filters: {
|
||||
parent: props.data.name,
|
||||
},
|
||||
})
|
||||
applicableItems.reload()
|
||||
} else {
|
||||
addRow()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const saveItems = (parent = null) => {
|
||||
return rows.value
|
||||
/* for (const row of rows.value) {
|
||||
if (row.name) {
|
||||
await applicableItems.setValue.submit({
|
||||
...row,
|
||||
}, {
|
||||
onSuccess() {
|
||||
props.coupons.reload()
|
||||
applicableItems.reload()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await applicableItems.insert.submit({
|
||||
...row,
|
||||
parent: parent,
|
||||
parenttype: 'LMS Coupon',
|
||||
parentfield: 'applicable_items',
|
||||
}, {
|
||||
onSuccess() {
|
||||
props.coupons.reload()
|
||||
applicableItems.reload()
|
||||
}
|
||||
})
|
||||
}
|
||||
} */
|
||||
}
|
||||
|
||||
const removeRow = (rowToRemove: any) => {
|
||||
rows.value = rows.value.filter((row) => row !== rowToRemove)
|
||||
if (rowToRemove.name) {
|
||||
applicableItems.delete.submit(rowToRemove.name, {
|
||||
onSuccess() {
|
||||
props.coupons.reload()
|
||||
applicableItems.reload()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
saveItems,
|
||||
})
|
||||
</script>
|
||||
203
frontend/src/components/Settings/Coupons/CouponList.vue
Normal file
203
frontend/src/components/Settings/Coupons/CouponList.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="openForm()">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="coupons.data?.length" class="overflow-y-scroll">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="coupons.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: true,
|
||||
onRowClick: (row: Coupon) => {
|
||||
openForm(row)
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in coupons.data" :key="row.name">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'enabled'">
|
||||
<Badge v-if="row[column.key]" theme="green">
|
||||
{{ __('Enabled') }}
|
||||
</Badge>
|
||||
<Badge v-else theme="gray">
|
||||
{{ __('Disabled') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else-if="column.key == 'expires_on'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div v-else-if="column.key == 'discount'">
|
||||
<div v-if="row['discount_type'] == 'Percentage'">
|
||||
{{ row['percentage_discount'] }}%
|
||||
</div>
|
||||
<div v-else-if="row['discount_type'] == 'Fixed Amount'">
|
||||
{{ row['fixed_amount_discount'] }}/-
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="leading-5 text-sm">
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="confirmDeletion(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-center text-ink-gray-6 italic mt-40">
|
||||
{{ __('No coupons created yet.') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, getCurrentInstance, inject, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import type { Coupon, Coupons } from './types'
|
||||
|
||||
const dayjs = inject('$dayjs') as typeof import('dayjs')
|
||||
const app = getCurrentInstance()
|
||||
const $dialog = app?.appContext.config.globalProperties.$dialog
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
coupons: Coupons
|
||||
}>()
|
||||
|
||||
const openForm = (coupon: Coupon = {} as Coupon) => {
|
||||
emit('updateStep', 'details', { ...coupon })
|
||||
}
|
||||
|
||||
const confirmDeletion = (selections: any[], unselectAll: () => void) => {
|
||||
if (selections.length === 0) {
|
||||
toast.info(__('No coupons selected for deletion'))
|
||||
return
|
||||
}
|
||||
$dialog({
|
||||
title: __('Delete this coupon?'),
|
||||
message: __(
|
||||
'This will permanently delete the coupon and the code will no longer be valid.'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick({ close }: { close: () => void }) {
|
||||
call('lms.lms.api.delete_documents', {
|
||||
doctype: 'LMS Coupon',
|
||||
documents: Array.from(selections),
|
||||
}).then((data: any) => {
|
||||
toast.success(__('Coupon(s) deleted successfully'))
|
||||
coupons.reload()
|
||||
unselectAll()
|
||||
close()
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function trashCoupon(name, close) {
|
||||
call('frappe.client.delete', { doctype: 'LMS Coupon', name }).then(() => {
|
||||
toast.success(__('Coupon deleted successfully'))
|
||||
coupons.reload()
|
||||
if (typeof close === 'function') close()
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Code'),
|
||||
key: 'code',
|
||||
icon: 'tag',
|
||||
width: '150px',
|
||||
},
|
||||
{
|
||||
label: __('Discount'),
|
||||
key: 'discount',
|
||||
align: 'center',
|
||||
width: '80px',
|
||||
icon: 'dollar-sign',
|
||||
},
|
||||
{
|
||||
label: __('Expires On'),
|
||||
key: 'expires_on',
|
||||
width: '120px',
|
||||
icon: 'calendar',
|
||||
},
|
||||
{
|
||||
label: __('Usage Limit'),
|
||||
key: 'usage_limit',
|
||||
align: 'center',
|
||||
width: '100px',
|
||||
icon: 'hash',
|
||||
},
|
||||
{
|
||||
label: __('Redemption Count'),
|
||||
key: 'redemption_count',
|
||||
align: 'center',
|
||||
width: '100px',
|
||||
icon: 'users',
|
||||
},
|
||||
{
|
||||
label: __('Enabled'),
|
||||
key: 'enabled',
|
||||
align: 'center',
|
||||
icon: 'check-square',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
54
frontend/src/components/Settings/Coupons/Coupons.vue
Normal file
54
frontend/src/components/Settings/Coupons/Coupons.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<CouponList
|
||||
v-if="step === 'list'"
|
||||
:label="props.label"
|
||||
:description="props.description"
|
||||
:coupons="coupons"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
<CouponDetails
|
||||
v-else-if="step == 'details'"
|
||||
:coupons="coupons"
|
||||
:data="data"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { createListResource } from 'frappe-ui'
|
||||
import CouponList from '@/components/Settings/Coupons/CouponList.vue'
|
||||
import CouponDetails from '@/components/Settings/Coupons/CouponDetails.vue'
|
||||
import type { Coupon } from './types'
|
||||
|
||||
const step = ref('list')
|
||||
const data = ref<Coupon | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const updateStep = (newStep: 'list' | 'new' | 'edit', newData: Coupon) => {
|
||||
console.log('Updating step to:', newStep, newData)
|
||||
step.value = newStep
|
||||
if (newData) {
|
||||
data.value = newData
|
||||
}
|
||||
}
|
||||
|
||||
const coupons = createListResource({
|
||||
doctype: 'LMS Coupon',
|
||||
fields: [
|
||||
'name',
|
||||
'code',
|
||||
'discount_type',
|
||||
'percentage_discount',
|
||||
'fixed_amount_discount',
|
||||
'expires_on',
|
||||
'usage_limit',
|
||||
'redemption_count',
|
||||
'enabled',
|
||||
],
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
30
frontend/src/components/Settings/Coupons/types.ts
Normal file
30
frontend/src/components/Settings/Coupons/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface Coupon {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
code: string;
|
||||
discount_type: 'Percentage' | 'Fixed Amount';
|
||||
percentage_discount?: number;
|
||||
fixed_amount_discount?: number;
|
||||
expires_on?: string;
|
||||
description?: string;
|
||||
usage_limit?: number;
|
||||
redemptions_count: number;
|
||||
applicable_items: ApplicableItem[];
|
||||
}
|
||||
|
||||
export type ApplicableItem = {
|
||||
reference_doctype: "LMS Course" | "LMS Batch";
|
||||
reference_name: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
parenttype: "LMS Coupon";
|
||||
parentfield: "applicable_items";
|
||||
}
|
||||
|
||||
export interface Coupons {
|
||||
data: Coupon[];
|
||||
update: (args: { filters: any[] }) => void;
|
||||
insert: { submit: (params: Coupon, options: { onSuccess: (data: Coupon) => void; onError?: (err: any) => void }) => void };
|
||||
setValue: { submit: (params: Coupon, options: { onSuccess: (data: Coupon) => void; onError?: (err: any) => void }) => void };
|
||||
reload: () => void;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ import Categories from '@/components/Settings/Categories.vue'
|
||||
import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
|
||||
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||
import PaymentGateways from '@/components/Settings/PaymentGateways.vue'
|
||||
import Coupons from '@/components/Settings/Coupons.vue'
|
||||
import Coupons from '@/components/Settings/Coupons/Coupons.vue'
|
||||
import Transactions from '@/components/Settings/Transactions.vue'
|
||||
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||
import Badges from '@/components/Settings/Badges.vue'
|
||||
@@ -228,18 +228,18 @@ const tabsStructure = computed(() => {
|
||||
template: markRaw(PaymentGateways),
|
||||
description: 'Add and manage all your payment gateways',
|
||||
},
|
||||
{
|
||||
label: 'Coupons',
|
||||
icon: 'Tag',
|
||||
template: markRaw(Coupons),
|
||||
description: 'Create and manage coupon codes',
|
||||
},
|
||||
{
|
||||
label: 'Transactions',
|
||||
icon: 'Landmark',
|
||||
template: markRaw(Transactions),
|
||||
description: 'View all your payment transactions',
|
||||
},
|
||||
{
|
||||
label: 'Coupons',
|
||||
icon: 'Ticket',
|
||||
template: markRaw(Coupons),
|
||||
description: 'Manage discount coupons for courses and batches',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user