Introduces a reusable date picker component with support for single date, date range, and month selection. Updates the stock management page to allow exporting reports by daily, weekly, monthly, or custom date ranges using the new pickers. Refactors promotion form to use the new date picker for start and end dates. Adds more business quotes to the quotes config.
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { z } from 'zod';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { useForm } from 'react-hook-form';
|
|
import { Plus, Save, X, Loader2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form';
|
|
import { Input } from '@/components/ui/input';
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { toast } from '@/components/ui/use-toast';
|
|
import { PromotionFormData } from '@/lib/types/promotion';
|
|
import { fetchClient } from '@/lib/api';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
|
|
// Form schema validation with Zod
|
|
const formSchema = z.object({
|
|
code: z.string()
|
|
.min(3, 'Code must be at least 3 characters')
|
|
.max(20, 'Code cannot exceed 20 characters')
|
|
.regex(/^[A-Za-z0-9]+$/, 'Only letters and numbers are allowed'),
|
|
discountType: z.enum(['percentage', 'fixed']),
|
|
discountValue: z.coerce.number()
|
|
.positive('Discount value must be positive')
|
|
.refine(val => val <= 100, {
|
|
message: 'Percentage discount cannot exceed 100%',
|
|
path: ['discountValue'],
|
|
// Only validate this rule if discount type is percentage
|
|
params: { type: 'percentage' },
|
|
}),
|
|
minOrderAmount: z.coerce.number()
|
|
.min(0, 'Minimum order amount cannot be negative'),
|
|
maxUsage: z.coerce.number()
|
|
.nullable()
|
|
.optional(),
|
|
isActive: z.boolean().default(true),
|
|
startDate: z.string().optional(),
|
|
endDate: z.string().nullable().optional(),
|
|
description: z.string().max(200, 'Description cannot exceed 200 characters').optional(),
|
|
});
|
|
|
|
interface NewPromotionFormProps {
|
|
onSuccess: () => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
export default function NewPromotionForm({ onSuccess, onCancel }: NewPromotionFormProps) {
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [discountType, setDiscountType] = useState<'percentage' | 'fixed'>('percentage');
|
|
|
|
// Initialize form with default values
|
|
const form = useForm<z.infer<typeof formSchema>>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: {
|
|
code: '',
|
|
discountType: 'percentage',
|
|
discountValue: 10,
|
|
minOrderAmount: 0,
|
|
maxUsage: null,
|
|
isActive: true,
|
|
description: '',
|
|
startDate: new Date().toISOString().split('T')[0], // Today
|
|
endDate: null,
|
|
},
|
|
});
|
|
|
|
// Keep local state in sync with form
|
|
useEffect(() => {
|
|
const subscription = form.watch((value, { name }) => {
|
|
if (name === 'discountType') {
|
|
setDiscountType(value.discountType as 'percentage' | 'fixed');
|
|
}
|
|
});
|
|
return () => subscription.unsubscribe();
|
|
}, [form, form.watch]);
|
|
|
|
// Form submission handler
|
|
async function onSubmit(data: z.infer<typeof formSchema>) {
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
await fetchClient('/promotions', {
|
|
method: 'POST',
|
|
body: data,
|
|
});
|
|
|
|
toast({
|
|
title: 'Success',
|
|
description: 'Promotion created successfully',
|
|
});
|
|
|
|
onSuccess();
|
|
} catch (error) {
|
|
console.error('Error creating promotion:', error);
|
|
// Error toast is already shown by fetchClient
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
|
<div className="grid grid-cols-1 gap-6">
|
|
<FormField
|
|
control={form.control}
|
|
name="code"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-sm font-medium">Promotion Code</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="SUMMER20"
|
|
{...field}
|
|
onChange={(e) => field.onChange(e.target.value.toUpperCase())}
|
|
className="h-10"
|
|
/>
|
|
</FormControl>
|
|
<FormDescription className="text-xs">
|
|
Enter a unique code for your promotion. Only letters and numbers.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 gap-6">
|
|
<FormField
|
|
control={form.control}
|
|
name="discountType"
|
|
render={({ field }) => (
|
|
<FormItem className="col-span-1">
|
|
<FormLabel className="text-sm font-medium">Discount Type</FormLabel>
|
|
<FormControl>
|
|
<RadioGroup
|
|
onValueChange={(value) => {
|
|
field.onChange(value);
|
|
setDiscountType(value as 'percentage' | 'fixed');
|
|
}}
|
|
value={field.value}
|
|
className="flex flex-col space-y-2"
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="percentage" id="percentage" />
|
|
<label htmlFor="percentage" className="cursor-pointer">Percentage (%)</label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="fixed" id="fixed" />
|
|
<label htmlFor="fixed" className="cursor-pointer">Fixed Amount (£)</label>
|
|
</div>
|
|
</RadioGroup>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="discountValue"
|
|
render={({ field }) => (
|
|
<FormItem className="col-span-1">
|
|
<FormLabel className="text-sm font-medium">Discount Value</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
step={discountType === 'percentage' ? '1' : '0.01'}
|
|
min={0}
|
|
max={discountType === 'percentage' ? 100 : undefined}
|
|
className="h-10"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription className="text-xs">
|
|
{discountType === 'percentage'
|
|
? 'Enter a percentage (1-100%)'
|
|
: 'Enter an amount in £'}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="minOrderAmount"
|
|
render={({ field }) => (
|
|
<FormItem className="col-span-1">
|
|
<FormLabel className="text-sm font-medium">Minimum Order Amount (£)</FormLabel>
|
|
<FormControl>
|
|
<Input type="number" step="0.01" min="0" className="h-10" {...field} />
|
|
</FormControl>
|
|
<FormDescription className="text-xs">
|
|
Minimum purchase required
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="maxUsage"
|
|
render={({ field }) => (
|
|
<FormItem className="col-span-1">
|
|
<FormLabel className="text-sm font-medium">Maximum Usage Count</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
placeholder="Unlimited"
|
|
className="h-10"
|
|
{...field}
|
|
value={field.value === null ? '' : field.value}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
field.onChange(value === '' ? null : parseInt(value, 10));
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription className="text-xs">
|
|
Leave empty for unlimited usage
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<FormField
|
|
control={form.control}
|
|
name="startDate"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-sm font-medium">Start Date</FormLabel>
|
|
<FormControl>
|
|
<DatePicker
|
|
date={field.value ? new Date(field.value) : undefined}
|
|
onDateChange={(date) => field.onChange(date ? date.toISOString().split('T')[0] : '')}
|
|
placeholder="Select start date"
|
|
className="h-10"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="endDate"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="text-sm font-medium">End Date (Optional)</FormLabel>
|
|
<FormControl>
|
|
<DatePicker
|
|
date={field.value ? new Date(field.value) : undefined}
|
|
onDateChange={(date) => field.onChange(date ? date.toISOString().split('T')[0] : null)}
|
|
placeholder="Select end date (optional)"
|
|
className="h-10"
|
|
/>
|
|
</FormControl>
|
|
<FormDescription className="text-xs">
|
|
Leave empty for no expiration
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="isActive"
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
<div className="space-y-0.5">
|
|
<FormLabel className="text-base">Active Status</FormLabel>
|
|
<FormDescription>
|
|
Enable or disable this promotion
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="description"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Description (Optional)</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="Enter a brief description for this promotion"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Internal notes about this promotion
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className="flex justify-end gap-3 pt-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onCancel}
|
|
disabled={isSubmitting}
|
|
>
|
|
<X className="h-4 w-4 mr-2" />
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Creating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Create Promotion
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
);
|
|
}
|