feat: Add SchemeForm component for creating and editing schemes with validation.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
|
||||
import type { SchemeResponse } from '@/types/api'
|
||||
import { SchemeForm } from '../../_components/scheme-form'
|
||||
|
||||
export default function EditSchemePage() {
|
||||
const router = useRouter()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [scheme, setScheme] = useState<SchemeResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
schemesApi
|
||||
.get(id)
|
||||
.then(setScheme)
|
||||
.catch((err) => {
|
||||
toast.add({ title: 'Failed to load scheme', description: getApiError(err), type: 'error' })
|
||||
router.push('/pricing/schemes')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id, router])
|
||||
|
||||
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
|
||||
if (!scheme) return <p className="text-sm text-muted-foreground">Scheme not found.</p>
|
||||
return <SchemeForm scheme={scheme} />
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
|
||||
import type { SchemeResponse } from '@/types/api'
|
||||
import { SchemeForm } from '../_components/scheme-form'
|
||||
|
||||
export default function SchemeDetailsPage() {
|
||||
const router = useRouter()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [scheme, setScheme] = useState<SchemeResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
schemesApi
|
||||
.get(id)
|
||||
.then(setScheme)
|
||||
.catch((err) => {
|
||||
toast.add({ title: 'Failed to load scheme', description: getApiError(err), type: 'error' })
|
||||
router.push('/pricing/schemes')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id, router])
|
||||
|
||||
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
|
||||
if (!scheme) return <p className="text-sm text-muted-foreground">Scheme not found.</p>
|
||||
return <SchemeForm scheme={scheme} readOnly />
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useFieldArray, useWatch } from 'react-hook-form'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, ArrowLeft01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import {
|
||||
schemes as schemesApi,
|
||||
regions as regionsApi,
|
||||
items as itemsApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { SchemeResponse, RegionResponse, ItemSummaryResponse, SchemeRuleType } from '@/types/api'
|
||||
import { RULE_META, parseRuleConfig } from './scheme-labels'
|
||||
|
||||
const RULE_ORDER: SchemeRuleType[] = ['BuyXGetY', 'FreeBundle', 'FlatPercent', 'SlabDiscount']
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
|
||||
regionId: z.string().optional(),
|
||||
eligibleSegment: z.string().optional(),
|
||||
dateRangeFrom: z.string().min(1, 'Required'),
|
||||
dateRangeTo: z.string().min(1, 'Required'),
|
||||
isActive: z.boolean(),
|
||||
// Buy & get free
|
||||
triggerItemId: z.string(),
|
||||
triggerQty: z.number(),
|
||||
// Buy & get free + combo gift: what the store receives
|
||||
freeItemId: z.string(),
|
||||
freeQty: z.number(),
|
||||
// Combo gift: the products that must be bought together
|
||||
bundle: z.array(z.object({ itemId: z.string(), qty: z.number() })),
|
||||
// Flat discount
|
||||
discountPercent: z.number(),
|
||||
// Spend more, save more
|
||||
tiers: z.array(z.object({ minOrderAmount: z.number(), discountPercent: z.number() })),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const fail = (path: (string | number)[], message: string) => ctx.addIssue({ code: 'custom', path, message })
|
||||
const wholeQty = (n: number) => Number.isInteger(n) && n >= 1
|
||||
const pct = (n: number) => n > 0 && n <= 100
|
||||
|
||||
if (v.dateRangeFrom && v.dateRangeTo && v.dateRangeFrom > v.dateRangeTo)
|
||||
fail(['dateRangeTo'], 'End date must not be before the start date')
|
||||
|
||||
if (v.ruleType === 'BuyXGetY') {
|
||||
if (!v.triggerItemId) fail(['triggerItemId'], 'Choose the product the store must buy')
|
||||
if (!wholeQty(v.triggerQty)) fail(['triggerQty'], 'Enter a whole number, 1 or more')
|
||||
}
|
||||
if (v.ruleType === 'BuyXGetY' || v.ruleType === 'FreeBundle') {
|
||||
if (!v.freeItemId) fail(['freeItemId'], 'Choose the free product')
|
||||
if (!wholeQty(v.freeQty)) fail(['freeQty'], 'Enter a whole number, 1 or more')
|
||||
}
|
||||
if (v.ruleType === 'FreeBundle') {
|
||||
if (v.bundle.length === 0) fail(['bundle'], 'Add at least one product')
|
||||
v.bundle.forEach((b, i) => {
|
||||
if (!b.itemId) fail(['bundle', i, 'itemId'], 'Choose a product')
|
||||
if (!wholeQty(b.qty)) fail(['bundle', i, 'qty'], 'Whole number, 1 or more')
|
||||
})
|
||||
const ids = v.bundle.map((b) => b.itemId).filter(Boolean)
|
||||
if (new Set(ids).size !== ids.length) fail(['bundle'], 'Each product can only be listed once')
|
||||
}
|
||||
if (v.ruleType === 'FlatPercent' && !pct(v.discountPercent))
|
||||
fail(['discountPercent'], 'Enter a percentage between 1 and 100')
|
||||
if (v.ruleType === 'SlabDiscount') {
|
||||
if (v.tiers.length === 0) fail(['tiers'], 'Add at least one level')
|
||||
v.tiers.forEach((t, i) => {
|
||||
if (!(t.minOrderAmount > 0)) fail(['tiers', i, 'minOrderAmount'], 'Enter an amount above 0')
|
||||
if (!pct(t.discountPercent)) fail(['tiers', i, 'discountPercent'], '1 to 100')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const EMPTY: FormValues = {
|
||||
name: '',
|
||||
ruleType: 'BuyXGetY',
|
||||
regionId: '',
|
||||
eligibleSegment: '',
|
||||
dateRangeFrom: '',
|
||||
dateRangeTo: '',
|
||||
isActive: true,
|
||||
triggerItemId: '',
|
||||
triggerQty: 10,
|
||||
freeItemId: '',
|
||||
freeQty: 1,
|
||||
bundle: [{ itemId: '', qty: 1 }],
|
||||
discountPercent: 10,
|
||||
tiers: [{ minOrderAmount: 5000, discountPercent: 5 }],
|
||||
}
|
||||
|
||||
function toFormValues(scheme: SchemeResponse): FormValues {
|
||||
const cfg = parseRuleConfig(scheme)
|
||||
return {
|
||||
...EMPTY,
|
||||
name: scheme.name,
|
||||
ruleType: scheme.ruleType,
|
||||
regionId: scheme.regionId ?? '',
|
||||
eligibleSegment: scheme.eligibleSegment ?? '',
|
||||
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
|
||||
dateRangeTo: scheme.dateRangeTo.split('T')[0],
|
||||
isActive: scheme.isActive,
|
||||
triggerItemId: scheme.triggerItemId ?? '',
|
||||
triggerQty: scheme.triggerQty ?? EMPTY.triggerQty,
|
||||
freeItemId: scheme.freeItemId ?? '',
|
||||
freeQty: scheme.freeQty ?? EMPTY.freeQty,
|
||||
bundle: scheme.bundleComponents.length
|
||||
? scheme.bundleComponents.map((c) => ({ itemId: c.itemId, qty: c.qty }))
|
||||
: EMPTY.bundle,
|
||||
discountPercent: cfg.discountPercent || EMPTY.discountPercent,
|
||||
tiers: cfg.tiers.length ? cfg.tiers : EMPTY.tiers,
|
||||
}
|
||||
}
|
||||
|
||||
interface SchemeFormProps {
|
||||
/** null = create a new scheme, otherwise edit this one. */
|
||||
scheme: SchemeResponse | null
|
||||
/** Show the scheme exactly as the edit page does, but locked, with an Edit button. */
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export function SchemeForm({ scheme, readOnly = false }: SchemeFormProps) {
|
||||
const router = useRouter()
|
||||
const goBack = () => router.push('/pricing/schemes')
|
||||
const isEdit = !!scheme
|
||||
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: EMPTY })
|
||||
const bundle = useFieldArray({ control: form.control, name: 'bundle' })
|
||||
const tiers = useFieldArray({ control: form.control, name: 'tiers' })
|
||||
const v = useWatch({ control: form.control }) as FormValues
|
||||
const ruleType = v.ruleType ?? 'BuyXGetY'
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(scheme ? toFormValues(scheme) : EMPTY)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scheme])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([regionsApi.list({ pageSize: 100 }), itemsApi.list({ pageSize: 200 })])
|
||||
.then(([regions, items]) => {
|
||||
setRegionsList(getPaginatedItems(regions))
|
||||
setItemsList(getPaginatedItems(items))
|
||||
})
|
||||
.catch(() => toast.add({ title: 'Failed to load regions or products', type: 'error' }))
|
||||
}, [])
|
||||
|
||||
const itemName = (id?: string) => itemsList.find((i) => i.id === id)?.name
|
||||
|
||||
// Live sentence so the user can check the offer reads the way they intended.
|
||||
const preview = useMemo(() => {
|
||||
const n = (x?: number) => (x && x > 0 ? x : '?')
|
||||
if (ruleType === 'BuyXGetY') {
|
||||
const buy = itemName(v.triggerItemId) ?? 'a product'
|
||||
const free = itemName(v.freeItemId) ?? 'a product'
|
||||
return `Buy ${n(v.triggerQty)} × ${buy}, get ${n(v.freeQty)} × ${free} free. Repeats: buying ${n(v.triggerQty && v.triggerQty * 2)} earns ${n(v.freeQty && v.freeQty * 2)} free.`
|
||||
}
|
||||
if (ruleType === 'FreeBundle') {
|
||||
const parts = (v.bundle ?? []).map((b) => `${n(b.qty)} × ${itemName(b.itemId) ?? 'a product'}`).join(' + ')
|
||||
return `Buy ${parts || 'products'} together, get ${n(v.freeQty)} × ${itemName(v.freeItemId) ?? 'a product'} free (once per order).`
|
||||
}
|
||||
if (ruleType === 'FlatPercent') return `${n(v.discountPercent)}% off the whole order.`
|
||||
const levels = [...(v.tiers ?? [])]
|
||||
.sort((a, b) => a.minOrderAmount - b.minOrderAmount)
|
||||
.map((t) => `${n(t.discountPercent)}% off from Rs. ${(t.minOrderAmount || 0).toLocaleString()}`)
|
||||
return levels.length ? `${levels.join('; ')}. The highest level the order reaches applies.` : 'Add at least one level.'
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [v, ruleType, itemsList])
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
const isGoods = values.ruleType === 'BuyXGetY' || values.ruleType === 'FreeBundle'
|
||||
const ruleConfig =
|
||||
values.ruleType === 'FlatPercent'
|
||||
? JSON.stringify({ discountPercent: values.discountPercent })
|
||||
: values.ruleType === 'SlabDiscount'
|
||||
? JSON.stringify({ tiers: values.tiers })
|
||||
: null
|
||||
// The name is optional: when left blank, describe the offer itself (e.g. "Buy 10 Soap, get 1 Soap free").
|
||||
const n = (id: string) => itemName(id) ?? 'item'
|
||||
const autoName =
|
||||
values.ruleType === 'BuyXGetY'
|
||||
? `Buy ${values.triggerQty} ${n(values.triggerItemId)}, get ${values.freeQty} ${n(values.freeItemId)} free`
|
||||
: values.ruleType === 'FreeBundle'
|
||||
? `Buy ${values.bundle.map((b) => `${b.qty} ${n(b.itemId)}`).join(' + ')}, get ${values.freeQty} ${n(values.freeItemId)} free`
|
||||
: values.ruleType === 'FlatPercent'
|
||||
? `${values.discountPercent}% off the order`
|
||||
: `Up to ${Math.max(...values.tiers.map((t) => t.discountPercent))}% off by order value`
|
||||
const common = {
|
||||
name: values.name.trim() || autoName,
|
||||
ruleConfig,
|
||||
regionId: values.regionId || null,
|
||||
eligibleSegment: values.eligibleSegment?.trim() || null,
|
||||
dateRangeFrom: values.dateRangeFrom,
|
||||
dateRangeTo: values.dateRangeTo,
|
||||
isActive: values.isActive,
|
||||
}
|
||||
const goods = isGoods
|
||||
? {
|
||||
triggerItemId: values.ruleType === 'BuyXGetY' ? values.triggerItemId : null,
|
||||
triggerQty: values.ruleType === 'BuyXGetY' ? values.triggerQty : undefined,
|
||||
freeItemId: values.freeItemId,
|
||||
freeQty: values.freeQty,
|
||||
}
|
||||
: {}
|
||||
|
||||
try {
|
||||
if (!scheme) {
|
||||
await schemesApi.create({
|
||||
...common,
|
||||
ruleType: values.ruleType,
|
||||
...goods,
|
||||
bundleComponents:
|
||||
values.ruleType === 'FreeBundle' ? values.bundle.map((b) => ({ itemId: b.itemId, qty: b.qty })) : undefined,
|
||||
})
|
||||
toast.add({ title: 'Scheme created', type: 'success' })
|
||||
} else {
|
||||
// The rule type is fixed once created; update the basics, then the free-goods rule and bundle rows.
|
||||
await schemesApi.update(scheme.id, { ...common, agentLimitExempt: scheme.agentLimitExempt })
|
||||
if (isGoods) {
|
||||
await schemesApi.setFreeGoodsRule(scheme.id, {
|
||||
triggerItemId: goods.triggerItemId,
|
||||
triggerQty: goods.triggerQty,
|
||||
freeItemId: values.freeItemId,
|
||||
freeQty: values.freeQty,
|
||||
})
|
||||
}
|
||||
if (values.ruleType === 'FreeBundle') {
|
||||
const keep = new Set(values.bundle.map((b) => b.itemId))
|
||||
for (const old of scheme.bundleComponents) {
|
||||
if (!keep.has(old.itemId)) await schemesApi.removeBundleComponent(scheme.id, old.itemId)
|
||||
}
|
||||
for (const b of values.bundle) await schemesApi.upsertBundleComponent(scheme.id, b.itemId, b.qty)
|
||||
}
|
||||
toast.add({ title: 'Scheme updated', type: 'success' })
|
||||
}
|
||||
goBack()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const itemSelect = (value: string, onChange: (v: string) => void, placeholder: string, invalid?: boolean) => (
|
||||
<Select value={value} onValueChange={(x) => onChange(x ?? '')}>
|
||||
<SelectTrigger aria-invalid={invalid}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{itemsList.map((i) => (
|
||||
<SelectItem key={i.id} value={i.id}>
|
||||
{i.name} — {i.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
const numberInput = (value: number, onChange: (n: number) => void, invalid?: boolean, props?: { min?: number; max?: number; step?: string }) => (
|
||||
<Input
|
||||
type="number"
|
||||
value={Number.isFinite(value) ? value : ''}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
aria-invalid={invalid}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="button" variant="outline" size="sm" onClick={goBack}>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{readOnly ? 'Scheme Details' : isEdit ? 'Edit Scheme' : 'Add Scheme'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{readOnly
|
||||
? 'How this offer is set up.'
|
||||
: isEdit ? 'Update the offer below.' : 'Pick the kind of offer, then fill in the details.'}
|
||||
</p>
|
||||
</div>
|
||||
{readOnly && scheme && (
|
||||
<Button type="button" className="ml-auto" onClick={() => router.push(`/pricing/schemes/${scheme.id}/edit`)}>
|
||||
Edit Scheme
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<fieldset disabled={readOnly} className="m-0 min-w-0 rounded-2xl border p-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Scheme name"
|
||||
description="Optional. If left blank, the name is created from the offer."
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Soap — buy 10 get 1 free (optional)"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-foreground">What kind of offer is it?</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{RULE_ORDER.map((type) => {
|
||||
const selected = ruleType === type
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
disabled={isEdit && !selected}
|
||||
onClick={() => form.setValue('ruleType', type, { shouldValidate: false })}
|
||||
className={`rounded-xl border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
selected ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground">{RULE_META[type].label}</p>
|
||||
<p className="text-xs text-muted-foreground">{RULE_META[type].description}</p>
|
||||
<p className="mt-1 text-xs italic text-muted-foreground">e.g. {RULE_META[type].example}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isEdit && <p className="text-xs text-muted-foreground">The kind of offer can't be changed after it is created.</p>}
|
||||
</div>
|
||||
|
||||
{/* ── Buy & get free ─────────────────────────────────────────── */}
|
||||
{ruleType === 'BuyXGetY' && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
||||
<p className="text-sm font-medium text-foreground">The store buys</p>
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="triggerItemId"
|
||||
label="Product"
|
||||
render={({ field, invalid }) =>
|
||||
itemSelect(field.value as string, (id) => {
|
||||
field.onChange(id)
|
||||
if (!form.getValues('freeItemId')) form.setValue('freeItemId', id)
|
||||
}, 'Select product…', invalid)
|
||||
}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="triggerQty"
|
||||
label="Quantity"
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">…and gets free</p>
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="freeItemId"
|
||||
label="Free product"
|
||||
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="freeQty"
|
||||
label="Free quantity"
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Combo gift ─────────────────────────────────────────────── */}
|
||||
{ruleType === 'FreeBundle' && (
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-foreground">The store must buy all of these</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => bundle.append({ itemId: '', qty: 1 })}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add product
|
||||
</Button>
|
||||
</div>
|
||||
{bundle.fields.map((row, index) => (
|
||||
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_7rem_auto]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`bundle.${index}.itemId`}
|
||||
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`bundle.${index}.qty`}
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={bundle.fields.length === 1}
|
||||
onClick={() => bundle.remove(index)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{form.formState.errors.bundle?.message && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.bundle.message}</p>
|
||||
)}
|
||||
<p className="text-sm font-medium text-foreground">…and gets this free</p>
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="freeItemId"
|
||||
label="Free product"
|
||||
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="freeQty"
|
||||
label="Free quantity"
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Flat discount ──────────────────────────────────────────── */}
|
||||
{ruleType === 'FlatPercent' && (
|
||||
<div className="rounded-xl border p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="discountPercent"
|
||||
label="Discount on the whole order (%)"
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Spend more, save more ──────────────────────────────────── */}
|
||||
{ruleType === 'SlabDiscount' && (
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-foreground">Discount levels</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => tiers.append({ minOrderAmount: 0, discountPercent: 0 })}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add level
|
||||
</Button>
|
||||
</div>
|
||||
{tiers.fields.map((row, index) => (
|
||||
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_1fr_auto]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tiers.${index}.minOrderAmount`}
|
||||
label={index === 0 ? 'When the order reaches (Rs.)' : undefined}
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 0 })}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tiers.${index}.discountPercent`}
|
||||
label={index === 0 ? 'Discount (%)' : undefined}
|
||||
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={index === 0 ? 'mt-6' : ''}
|
||||
disabled={tiers.fields.length === 1}
|
||||
onClick={() => tiers.remove(index)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{form.formState.errors.tiers?.message && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.tiers.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl bg-muted/50 px-4 py-3 text-sm">
|
||||
<span className="font-medium text-foreground">Summary: </span>
|
||||
<span className="text-muted-foreground">{preview}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateRangeFrom"
|
||||
label="Starts on"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
|
||||
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateRangeTo"
|
||||
label="Ends on"
|
||||
description="The offer still applies on this day."
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
|
||||
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="isActive" checked={field.value as boolean} onCheckedChange={field.onChange} />
|
||||
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<p className="text-sm font-medium text-foreground">Where does it apply? <span className="font-normal text-muted-foreground">(optional)</span></p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="regionId"
|
||||
label="Region"
|
||||
description="Leave on All Regions to offer it everywhere."
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={(x) => field.onChange(x ?? '')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Regions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Regions</SelectItem>
|
||||
{regionsList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eligibleSegment"
|
||||
label="Store segment"
|
||||
description="Optional, e.g. Gold. Empty = every store."
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={(field.value as string) ?? ''}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="All segments"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</fieldset>
|
||||
|
||||
{!readOnly && <div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={goBack}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
|
||||
</Button>
|
||||
</div>}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
|
||||
|
||||
// Plain-language names for the four rule types, so users don't need to know the technical names.
|
||||
export const RULE_META: Record<SchemeRuleType, { label: string; description: string; example: string }> = {
|
||||
BuyXGetY: {
|
||||
label: 'Buy & get free',
|
||||
description: 'The store buys a quantity of a product and gets free units.',
|
||||
example: 'Buy 10 Soap, get 1 Soap free',
|
||||
},
|
||||
FreeBundle: {
|
||||
label: 'Combo gift',
|
||||
description: 'The store buys a set of products together and gets a free gift.',
|
||||
example: 'Buy Soap + Shampoo, get a Toothbrush free',
|
||||
},
|
||||
FlatPercent: {
|
||||
label: 'Flat discount',
|
||||
description: 'A fixed percentage off the whole order.',
|
||||
example: '10% off every order',
|
||||
},
|
||||
SlabDiscount: {
|
||||
label: 'Spend more, save more',
|
||||
description: 'The discount grows as the order value reaches higher levels.',
|
||||
example: '5% off above Rs. 5,000, 8% off above Rs. 10,000',
|
||||
},
|
||||
}
|
||||
|
||||
export interface SlabTierConfig {
|
||||
minOrderAmount: number
|
||||
discountPercent: number
|
||||
}
|
||||
|
||||
export function parseRuleConfig(scheme: Pick<SchemeResponse, 'ruleType' | 'ruleConfig'>) {
|
||||
try {
|
||||
const cfg = scheme.ruleConfig ? JSON.parse(scheme.ruleConfig) : null
|
||||
if (scheme.ruleType === 'FlatPercent') return { discountPercent: Number(cfg?.discountPercent) || 0, tiers: [] as SlabTierConfig[] }
|
||||
if (scheme.ruleType === 'SlabDiscount') return { discountPercent: 0, tiers: (cfg?.tiers ?? []) as SlabTierConfig[] }
|
||||
} catch {
|
||||
// Malformed config — fall through to empty.
|
||||
}
|
||||
return { discountPercent: 0, tiers: [] as SlabTierConfig[] }
|
||||
}
|
||||
|
||||
const money = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||
|
||||
/** One-sentence description of what a scheme does, built from its saved rule. */
|
||||
export function describeScheme(scheme: SchemeResponse): string {
|
||||
switch (scheme.ruleType) {
|
||||
case 'BuyXGetY':
|
||||
return `Buy ${scheme.triggerQty ?? '?'} ${scheme.triggerItemName ?? 'item'}, get ${scheme.freeQty ?? '?'} ${scheme.freeItemName ?? 'item'} free`
|
||||
case 'FreeBundle': {
|
||||
const parts = scheme.bundleComponents.map((c) => `${c.qty} ${c.itemName}`).join(' + ') || 'items'
|
||||
return `Buy ${parts}, get ${scheme.freeQty ?? '?'} ${scheme.freeItemName ?? 'item'} free`
|
||||
}
|
||||
case 'FlatPercent':
|
||||
return `${parseRuleConfig(scheme).discountPercent}% off the order`
|
||||
case 'SlabDiscount': {
|
||||
const tiers = [...parseRuleConfig(scheme).tiers].sort((a, b) => a.minOrderAmount - b.minOrderAmount)
|
||||
return tiers.length ? tiers.map((t) => `${t.discountPercent}% off from ${money(t.minOrderAmount)}`).join(', ') : 'No levels set'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { schemes as schemesApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||
import type { SchemeResponse, RegionResponse } from '@/types/api'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
|
||||
regionId: z.string().optional(),
|
||||
dateRangeFrom: z.string().min(1, 'Required'),
|
||||
dateRangeTo: z.string().min(1, 'Required'),
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
interface SchemeSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
scheme: SchemeResponse | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function SchemeSheet({ open, onClose, scheme, onSuccess }: SchemeSheetProps) {
|
||||
const isEdit = !!scheme
|
||||
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
ruleType: 'FlatPercent',
|
||||
regionId: '',
|
||||
dateRangeFrom: '',
|
||||
dateRangeTo: '',
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset(
|
||||
scheme
|
||||
? {
|
||||
name: scheme.name,
|
||||
ruleType: scheme.ruleType,
|
||||
regionId: scheme.regionId ?? '',
|
||||
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
|
||||
dateRangeTo: scheme.dateRangeTo.split('T')[0],
|
||||
isActive: scheme.isActive,
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
ruleType: 'FlatPercent',
|
||||
regionId: '',
|
||||
dateRangeFrom: '',
|
||||
dateRangeTo: '',
|
||||
isActive: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [open, scheme])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
regionsApi.list({ pageSize: 100 })
|
||||
.then((res) => setRegionsList(getPaginatedItems(res)))
|
||||
.catch(() => {
|
||||
toast.add({ title: 'Failed to load regions', type: 'error' })
|
||||
})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
const body = {
|
||||
name: values.name,
|
||||
ruleType: values.ruleType,
|
||||
regionId: values.regionId || null,
|
||||
dateRangeFrom: values.dateRangeFrom,
|
||||
dateRangeTo: values.dateRangeTo,
|
||||
isActive: values.isActive,
|
||||
}
|
||||
if (isEdit) {
|
||||
await schemesApi.update(scheme!.id, body)
|
||||
toast.add({ title: 'Scheme updated', type: 'success' })
|
||||
} else {
|
||||
await schemesApi.create(body)
|
||||
toast.add({ title: 'Scheme created', type: 'success' })
|
||||
}
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Scheme' : 'Add Scheme'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the scheme details below.' : 'Create a new promotion or discount scheme.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Buy 2 Get 1 Free"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ruleType"
|
||||
label="Rule Type"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rule type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BuyXGetY">BuyXGetY</SelectItem>
|
||||
<SelectItem value="SlabDiscount">SlabDiscount</SelectItem>
|
||||
<SelectItem value="FlatPercent">FlatPercent</SelectItem>
|
||||
<SelectItem value="FreeBundle">FreeBundle</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="regionId"
|
||||
label="Region"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Regions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Regions</SelectItem>
|
||||
{regionsList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateRangeFrom"
|
||||
label="Date Range From"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="dateRangeFrom"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateRangeTo"
|
||||
label="Date Range To"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="dateRangeTo"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import type { SchemeResponse } from '@/types/api'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
|
||||
interface SchemeViewSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
scheme: SchemeResponse | null
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium text-right">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SchemeViewSheet({ open, onClose, scheme }: SchemeViewSheetProps) {
|
||||
if (!scheme) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Scheme Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Row label="Name" value={scheme.name} />
|
||||
<Row label="Rule Type" value={<Badge>{scheme.ruleType}</Badge>} />
|
||||
<Row label="Region" value={scheme.regionName ?? 'All Regions'} />
|
||||
<Row label="Eligible Segment" value={scheme.eligibleSegment ?? 'All Segments'} />
|
||||
<Row label="Status" value={<Badge variant={scheme.isActive ? 'default' : 'secondary'}>{scheme.isActive ? 'Active' : 'Inactive'}</Badge>} />
|
||||
<Row label="Date Range" value={`${fmtDate(scheme.dateRangeFrom)} → ${fmtDate(scheme.dateRangeTo)}`} />
|
||||
<Row label="Agent Limit Exempt" value={scheme.agentLimitExempt ? 'Yes' : 'No'} />
|
||||
{scheme.baselineExpectedSales > 0 && (
|
||||
<Row label="Baseline Expected Sales" value={`Rs. ${scheme.baselineExpectedSales.toLocaleString()}`} />
|
||||
)}
|
||||
{scheme.ruleConfig && <Row label="Rule Config" value={<span className="font-mono text-xs">{scheme.ruleConfig}</span>} />}
|
||||
{scheme.triggerItemName && (
|
||||
<Row label="Trigger" value={`${scheme.triggerQty} × ${scheme.triggerItemName}`} />
|
||||
)}
|
||||
{scheme.freeItemName && (
|
||||
<Row label="Free Goods" value={`${scheme.freeQty} × ${scheme.freeItemName}`} />
|
||||
)}
|
||||
<Row label="Created" value={fmtDate(scheme.createdAt)} />
|
||||
</div>
|
||||
|
||||
{scheme.bundleComponents.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-foreground">Bundle Components</p>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Item</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Qty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scheme.bundleComponents.map((c) => (
|
||||
<tr key={c.itemId} className="border-t">
|
||||
<td className="px-3 py-2">{c.itemName} <span className="ml-1 text-xs text-muted-foreground">({c.sku})</span></td>
|
||||
<td className="px-3 py-2 text-right">{c.qty}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
@@ -12,8 +13,7 @@ import { toast } from '@/components/ui/toast'
|
||||
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
|
||||
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
|
||||
|
||||
import { SchemeSheet } from './scheme-sheet'
|
||||
import { SchemeViewSheet } from './scheme-view-sheet'
|
||||
import { RULE_META, describeScheme } from './scheme-labels'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
@@ -26,14 +26,11 @@ const RULE_TYPE_VARIANT: Record<SchemeRuleType, 'default' | 'secondary'> = {
|
||||
}
|
||||
|
||||
export function SchemesClient() {
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<SchemeResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<SchemeResponse | null>(null)
|
||||
const [viewing, setViewing] = useState<SchemeResponse | null>(null)
|
||||
const [viewOpen, setViewOpen] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -52,18 +49,15 @@ export function SchemesClient() {
|
||||
}, [fetchData])
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setSheetOpen(true)
|
||||
router.push('/pricing/schemes/new')
|
||||
}
|
||||
|
||||
function openEdit(scheme: SchemeResponse) {
|
||||
setEditing(scheme)
|
||||
setSheetOpen(true)
|
||||
router.push(`/pricing/schemes/${scheme.id}/edit`)
|
||||
}
|
||||
|
||||
function openView(scheme: SchemeResponse) {
|
||||
setViewing(scheme)
|
||||
setViewOpen(true)
|
||||
router.push(`/pricing/schemes/${scheme.id}`)
|
||||
}
|
||||
|
||||
async function handleDelete(scheme: SchemeResponse) {
|
||||
@@ -91,11 +85,14 @@ export function SchemesClient() {
|
||||
},
|
||||
{
|
||||
key: 'ruleType',
|
||||
title: 'Rule Type',
|
||||
title: 'Offer',
|
||||
fields: ['ruleType'],
|
||||
width: 160,
|
||||
minWidth: 280,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]}>{row.ruleType}</Badge>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]} className="w-fit">{RULE_META[row.ruleType].label}</Badge>
|
||||
<span className="text-sm text-muted-foreground">{describeScheme(row)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -177,18 +174,6 @@ export function SchemesClient() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<SchemeSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
scheme={editing}
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
|
||||
<SchemeViewSheet
|
||||
open={viewOpen}
|
||||
onClose={() => setViewOpen(false)}
|
||||
scheme={viewing}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SchemeForm } from '../_components/scheme-form'
|
||||
|
||||
export default function NewSchemePage() {
|
||||
return <SchemeForm scheme={null} />
|
||||
}
|
||||
Reference in New Issue
Block a user