From 58247a1a0bc1b4285a63feb5b9bc19b212a734b4 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Thu, 22 Oct 2026 16:36:00 +0530 Subject: [PATCH] feat: Add SchemeForm component for creating and editing schemes with validation. --- .../pricing/schemes/[id]/edit/page.tsx | 30 + .../(dashboard)/pricing/schemes/[id]/page.tsx | 30 + .../schemes/_components/scheme-form.tsx | 602 ++++++++++++++++++ .../schemes/_components/scheme-labels.ts | 61 ++ .../schemes/_components/scheme-sheet.tsx | 265 -------- .../schemes/_components/scheme-view-sheet.tsx | 87 --- .../schemes/_components/schemes-client.tsx | 39 +- .../(dashboard)/pricing/schemes/new/page.tsx | 5 + 8 files changed, 740 insertions(+), 379 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/pricing/schemes/[id]/edit/page.tsx create mode 100644 distribution_fe/app/(dashboard)/pricing/schemes/[id]/page.tsx create mode 100644 distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-form.tsx create mode 100644 distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-labels.ts delete mode 100644 distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-sheet.tsx delete mode 100644 distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-view-sheet.tsx create mode 100644 distribution_fe/app/(dashboard)/pricing/schemes/new/page.tsx diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/[id]/edit/page.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/[id]/edit/page.tsx new file mode 100644 index 0000000..fc3a0a8 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/schemes/[id]/edit/page.tsx @@ -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(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

Loading...

+ if (!scheme) return

Scheme not found.

+ return +} diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/[id]/page.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/[id]/page.tsx new file mode 100644 index 0000000..fdc2341 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/schemes/[id]/page.tsx @@ -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(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

Loading...

+ if (!scheme) return

Scheme not found.

+ return +} diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-form.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-form.tsx new file mode 100644 index 0000000..431efc1 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-form.tsx @@ -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 + +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([]) + const [itemsList, setItemsList] = useState([]) + + const form = useForm({ 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) => ( + + ) + + const numberInput = (value: number, onChange: (n: number) => void, invalid?: boolean, props?: { min?: number; max?: number; step?: string }) => ( + onChange(Number(e.target.value))} + aria-invalid={invalid} + {...props} + /> + ) + + return ( +
+
+ +
+

+ {readOnly ? 'Scheme Details' : isEdit ? 'Edit Scheme' : 'Add Scheme'} +

+

+ {readOnly + ? 'How this offer is set up.' + : isEdit ? 'Update the offer below.' : 'Pick the kind of offer, then fill in the details.'} +

+
+ {readOnly && scheme && ( + + )} +
+ +
+
+ + ( + + )} + /> + +
+

What kind of offer is it?

+
+ {RULE_ORDER.map((type) => { + const selected = ruleType === type + return ( + + ) + })} +
+ {isEdit &&

The kind of offer can't be changed after it is created.

} +
+ + {/* ── Buy & get free ─────────────────────────────────────────── */} + {ruleType === 'BuyXGetY' && ( +
+

The store buys

+
+ + itemSelect(field.value as string, (id) => { + field.onChange(id) + if (!form.getValues('freeItemId')) form.setValue('freeItemId', id) + }, 'Select product…', invalid) + } + /> + numberInput(field.value as number, field.onChange, invalid, { min: 1 })} + /> +
+

…and gets free

+
+ itemSelect(field.value as string, field.onChange, 'Select product…', invalid)} + /> + numberInput(field.value as number, field.onChange, invalid, { min: 1 })} + /> +
+
+ )} + + {/* ── Combo gift ─────────────────────────────────────────────── */} + {ruleType === 'FreeBundle' && ( +
+
+

The store must buy all of these

+ +
+ {bundle.fields.map((row, index) => ( +
+ itemSelect(field.value as string, field.onChange, 'Select product…', invalid)} + /> + numberInput(field.value as number, field.onChange, invalid, { min: 1 })} + /> + +
+ ))} + {form.formState.errors.bundle?.message && ( +

{form.formState.errors.bundle.message}

+ )} +

…and gets this free

+
+ itemSelect(field.value as string, field.onChange, 'Select product…', invalid)} + /> + numberInput(field.value as number, field.onChange, invalid, { min: 1 })} + /> +
+
+ )} + + {/* ── Flat discount ──────────────────────────────────────────── */} + {ruleType === 'FlatPercent' && ( +
+ numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })} + /> +
+ )} + + {/* ── Spend more, save more ──────────────────────────────────── */} + {ruleType === 'SlabDiscount' && ( +
+
+

Discount levels

+ +
+ {tiers.fields.map((row, index) => ( +
+ numberInput(field.value as number, field.onChange, invalid, { min: 0 })} + /> + numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })} + /> + +
+ ))} + {form.formState.errors.tiers?.message && ( +

{form.formState.errors.tiers.message}

+ )} +
+ )} + +
+ Summary: + {preview} +
+ +
+ ( + + )} + /> + ( + + )} + /> +
+ + ( +
+ + +
+ )} + /> + +
+

Where does it apply? (optional)

+
+ ( + + )} + /> + ( + + )} + /> +
+
+
+
+ + {!readOnly &&
+ + +
} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-labels.ts b/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-labels.ts new file mode 100644 index 0000000..37f7f65 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-labels.ts @@ -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 = { + 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) { + 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' + } + } +} diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-sheet.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-sheet.tsx deleted file mode 100644 index 3ad9406..0000000 --- a/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-sheet.tsx +++ /dev/null @@ -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 - -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([]) - - const form = useForm({ - 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 ( - !v && onClose()}> - - - {isEdit ? 'Edit Scheme' : 'Add Scheme'} - - {isEdit ? 'Update the scheme details below.' : 'Create a new promotion or discount scheme.'} - - - -
- - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - - ( - - )} - /> - - ( -
- - -
- )} - /> -
- - - - - -
-
-
- ) -} diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-view-sheet.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-view-sheet.tsx deleted file mode 100644 index 3ad58fa..0000000 --- a/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-view-sheet.tsx +++ /dev/null @@ -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 ( -
- {label} - {value} -
- ) -} - -export function SchemeViewSheet({ open, onClose, scheme }: SchemeViewSheetProps) { - if (!scheme) return null - - return ( - !v && onClose()}> - - - Scheme Details - - -
- - {scheme.ruleType}} /> - - - {scheme.isActive ? 'Active' : 'Inactive'}} /> - - - {scheme.baselineExpectedSales > 0 && ( - - )} - {scheme.ruleConfig && {scheme.ruleConfig}} />} - {scheme.triggerItemName && ( - - )} - {scheme.freeItemName && ( - - )} - -
- - {scheme.bundleComponents.length > 0 && ( -
-

Bundle Components

-
- - - - - - - - - {scheme.bundleComponents.map((c) => ( - - - - - ))} - -
ItemQty
{c.itemName} ({c.sku}){c.qty}
-
-
- )} -
-
- ) -} diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/_components/schemes-client.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/_components/schemes-client.tsx index e8e2b2a..19e5372 100644 --- a/distribution_fe/app/(dashboard)/pricing/schemes/_components/schemes-client.tsx +++ b/distribution_fe/app/(dashboard)/pricing/schemes/_components/schemes-client.tsx @@ -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 = { } export function SchemesClient() { + const router = useRouter() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const [pageSize, setPageSize] = useState(20) - const [sheetOpen, setSheetOpen] = useState(false) - const [editing, setEditing] = useState(null) - const [viewing, setViewing] = useState(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) => ( - {row.ruleType} +
+ {RULE_META[row.ruleType].label} + {describeScheme(row)} +
), }, { @@ -177,18 +174,6 @@ export function SchemesClient() { }} /> - setSheetOpen(false)} - scheme={editing} - onSuccess={fetchData} - /> - - setViewOpen(false)} - scheme={viewing} - /> ) } diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/new/page.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/new/page.tsx new file mode 100644 index 0000000..ea2f986 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/schemes/new/page.tsx @@ -0,0 +1,5 @@ +import { SchemeForm } from '../_components/scheme-form' + +export default function NewSchemePage() { + return +}