Add promotion details and modal to orders and promotions

Introduces promotion-related fields to the Order interface and displays promotion discounts in both the order details and order table. Adds a PromotionDetailsModal component for viewing detailed promotion analytics and eligibility, and integrates it into the PromotionsList with a new 'view details' action.
This commit is contained in:
NotII
2025-08-07 17:16:49 +01:00
parent 2c48ecd2b4
commit 3cef1076d0
4 changed files with 458 additions and 2 deletions

View File

@@ -0,0 +1,391 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Calendar,
Target,
TrendingUp,
Package,
ShoppingCart,
Percent,
DollarSign,
Users,
Clock,
CheckCircle,
XCircle,
Info
} from 'lucide-react';
import { Promotion, Product } from '@/lib/types/promotion';
import { fetchClient } from '@/lib/api';
import { toast } from '@/components/ui/use-toast';
interface PromotionDetailsModalProps {
promotion: Promotion | null;
onClose: () => void;
}
export default function PromotionDetailsModal({
promotion,
onClose
}: PromotionDetailsModalProps) {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (promotion && (promotion.blacklistedProducts.length > 0 || promotion.specificProducts.length > 0)) {
loadProducts();
}
}, [promotion]);
const loadProducts = async () => {
setLoading(true);
try {
const response = await fetchClient<Product[]>('/promotions/products/all');
setProducts(response || []);
} catch (error) {
console.error('Failed to fetch products:', error);
toast({
title: 'Warning',
description: 'Could not load product details',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const getProductDetails = (productIds: string[]): Product[] => {
return products.filter(product => productIds.includes(product._id));
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'No end date';
return new Date(dateString).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const calculateUsagePercentage = () => {
if (!promotion?.maxUsage) return 0;
return Math.round((promotion.usageCount / promotion.maxUsage) * 100);
};
const getDiscountDisplay = () => {
if (!promotion) return '';
return promotion.discountType === 'percentage'
? `${promotion.discountValue}%`
: `£${promotion.discountValue.toFixed(2)}`;
};
const getApplicabilityText = () => {
if (!promotion) return '';
switch (promotion.applicableProducts) {
case 'all':
return 'All products (except blacklisted)';
case 'specific':
return 'Only specific products';
case 'exclude_specific':
return 'All products except specific ones';
default:
return 'All products';
}
};
const isActive = () => {
if (!promotion) return false;
const now = new Date();
const startDate = new Date(promotion.startDate);
const endDate = promotion.endDate ? new Date(promotion.endDate) : null;
return promotion.isActive &&
now >= startDate &&
(!endDate || now <= endDate) &&
(!promotion.maxUsage || promotion.usageCount < promotion.maxUsage);
};
if (!promotion) return null;
return (
<Dialog open={!!promotion} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<div className="flex items-center justify-between">
<div>
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
<Target className="h-6 w-6" />
{promotion.code}
</DialogTitle>
<DialogDescription className="mt-1">
Comprehensive promotion details and analytics
</DialogDescription>
</div>
<Badge
variant={isActive() ? 'default' : 'secondary'}
className={`text-sm px-3 py-1 ${isActive() ? 'bg-green-500' : 'bg-gray-500'}`}
>
{isActive() ? 'Active' : 'Inactive'}
</Badge>
</div>
</DialogHeader>
<ScrollArea className="max-h-[70vh] pr-4">
<div className="space-y-6">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Info className="h-5 w-5" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Promotion Code</label>
<p className="text-lg font-mono font-bold">{promotion.code}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Description</label>
<p className="text-sm">{promotion.description || 'No description provided'}</p>
</div>
</div>
<Separator />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-3">
{promotion.discountType === 'percentage' ? (
<Percent className="h-8 w-8 text-blue-500" />
) : (
<DollarSign className="h-8 w-8 text-green-500" />
)}
<div>
<label className="text-sm font-medium text-muted-foreground">Discount</label>
<p className="text-xl font-bold">{getDiscountDisplay()}</p>
<p className="text-xs text-muted-foreground">
{promotion.discountType === 'percentage' ? 'Percentage' : 'Fixed Amount'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<ShoppingCart className="h-8 w-8 text-orange-500" />
<div>
<label className="text-sm font-medium text-muted-foreground">Min. Order</label>
<p className="text-xl font-bold">
{promotion.minOrderAmount > 0 ? `£${promotion.minOrderAmount.toFixed(2)}` : 'None'}
</p>
<p className="text-xs text-muted-foreground">Required minimum</p>
</div>
</div>
<div className="flex items-center gap-3">
<Users className="h-8 w-8 text-purple-500" />
<div>
<label className="text-sm font-medium text-muted-foreground">Usage Limit</label>
<p className="text-xl font-bold">
{promotion.maxUsage ? promotion.maxUsage.toLocaleString() : 'Unlimited'}
</p>
<p className="text-xs text-muted-foreground">Maximum uses</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Usage Analytics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Usage Analytics
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Current Usage</span>
<span className="text-sm text-muted-foreground">
{promotion.usageCount} / {promotion.maxUsage || '∞'}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{
width: promotion.maxUsage
? `${Math.min(calculateUsagePercentage(), 100)}%`
: '0%'
}}
></div>
</div>
{promotion.maxUsage && (
<p className="text-xs text-muted-foreground mt-1">
{calculateUsagePercentage()}% of limit used
</p>
)}
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm font-medium">Total Uses</span>
<span className="text-sm font-bold">{promotion.usageCount.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-sm font-medium">Remaining Uses</span>
<span className="text-sm font-bold">
{promotion.maxUsage
? (promotion.maxUsage - promotion.usageCount).toLocaleString()
: 'Unlimited'
}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Validity Period */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Validity Period
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-3">
<Clock className="h-8 w-8 text-green-500" />
<div>
<label className="text-sm font-medium text-muted-foreground">Start Date</label>
<p className="font-medium">{formatDate(promotion.startDate)}</p>
</div>
</div>
<div className="flex items-center gap-3">
{promotion.endDate ? (
<Clock className="h-8 w-8 text-red-500" />
) : (
<CheckCircle className="h-8 w-8 text-blue-500" />
)}
<div>
<label className="text-sm font-medium text-muted-foreground">End Date</label>
<p className="font-medium">
{promotion.endDate ? formatDate(promotion.endDate) : 'No expiry date'}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Product Eligibility */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Product Eligibility
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Applies To</label>
<p className="font-medium">{getApplicabilityText()}</p>
</div>
{promotion.applicableProducts === 'specific' && promotion.specificProducts.length > 0 && (
<div>
<label className="text-sm font-medium text-muted-foreground">
Specific Products ({promotion.specificProducts.length})
</label>
<div className="mt-2 space-y-1">
{loading ? (
<p className="text-sm text-muted-foreground">Loading products...</p>
) : (
getProductDetails(promotion.specificProducts).map((product) => (
<div key={product._id} className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-sm">{product.name}</span>
{!product.enabled && (
<Badge variant="secondary" className="text-xs">Disabled</Badge>
)}
</div>
))
)}
</div>
</div>
)}
{promotion.blacklistedProducts.length > 0 && (
<div>
<label className="text-sm font-medium text-muted-foreground">
Excluded Products ({promotion.blacklistedProducts.length})
</label>
<div className="mt-2 space-y-1">
{loading ? (
<p className="text-sm text-muted-foreground">Loading products...</p>
) : (
getProductDetails(promotion.blacklistedProducts).map((product) => (
<div key={product._id} className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-red-500" />
<span className="text-sm">{product.name}</span>
{!product.enabled && (
<Badge variant="secondary" className="text-xs">Disabled</Badge>
)}
</div>
))
)}
</div>
</div>
)}
{promotion.applicableProducts === 'all' && promotion.blacklistedProducts.length === 0 && (
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-4 w-4" />
<span className="text-sm">This promotion applies to all products in your store</span>
</div>
)}
</CardContent>
</Card>
{/* Metadata */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Additional Information</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<label className="font-medium text-muted-foreground">Created</label>
<p>{formatDate(promotion.createdAt)}</p>
</div>
<div>
<label className="font-medium text-muted-foreground">Last Updated</label>
<p>{formatDate(promotion.updatedAt)}</p>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}