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.
603 lines
21 KiB
TypeScript
603 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Layout from "@/components/layout/layout";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Product } from "@/models/products";
|
|
import { Package, RefreshCw, ChevronDown, CheckSquare, XSquare, Boxes, Download, Calendar } from "lucide-react";
|
|
import { clientFetch } from "@/lib/api";
|
|
import { toast } from "sonner";
|
|
import { DatePicker, DateRangePicker, DateRangeDisplay, MonthPicker } from "@/components/ui/date-picker";
|
|
import { DateRange } from "react-day-picker";
|
|
import { addDays, startOfDay, endOfDay, format, isSameDay } from "date-fns";
|
|
|
|
interface StockData {
|
|
currentStock: number;
|
|
stockTracking: boolean;
|
|
lowStockThreshold?: number;
|
|
}
|
|
|
|
type ReportType = 'daily' | 'weekly' | 'monthly' | 'custom';
|
|
|
|
export default function StockManagementPage() {
|
|
const router = useRouter();
|
|
const [products, setProducts] = useState<Product[]>([]);
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
const [editingStock, setEditingStock] = useState<Record<string, boolean>>({});
|
|
const [stockValues, setStockValues] = useState<Record<string, number>>({});
|
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
|
const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
|
|
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
|
const [bulkAction, setBulkAction] = useState<'enable' | 'disable' | null>(null);
|
|
|
|
// Export state
|
|
const [exportDate, setExportDate] = useState<string>(new Date().toISOString().split('T')[0]);
|
|
const [exportDateRange, setExportDateRange] = useState<DateRange | undefined>({
|
|
from: startOfDay(addDays(new Date(), -6)),
|
|
to: endOfDay(new Date())
|
|
});
|
|
const [selectedMonth, setSelectedMonth] = useState<Date>(new Date());
|
|
const [reportType, setReportType] = useState<ReportType>('daily');
|
|
const [isExporting, setIsExporting] = useState<boolean>(false);
|
|
|
|
useEffect(() => {
|
|
const authToken = document.cookie
|
|
.split("; ")
|
|
.find((row) => row.startsWith("Authorization="))
|
|
?.split("=")[1];
|
|
|
|
if (!authToken) {
|
|
router.push("/login");
|
|
return;
|
|
}
|
|
|
|
const fetchDataAsync = async () => {
|
|
try {
|
|
const response = await clientFetch<Product[]>('api/products');
|
|
const fetchedProducts = response || [];
|
|
setProducts(fetchedProducts);
|
|
|
|
// Initialize stock values
|
|
const initialStockValues: Record<string, number> = {};
|
|
fetchedProducts.forEach((product: Product) => {
|
|
if (product._id) {
|
|
initialStockValues[product._id] = product.currentStock || 0;
|
|
}
|
|
});
|
|
setStockValues(initialStockValues);
|
|
|
|
setLoading(false);
|
|
} catch (error) {
|
|
console.error("Error fetching products:", error);
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchDataAsync();
|
|
}, [router]);
|
|
|
|
const handleEditStock = (productId: string) => {
|
|
setEditingStock({
|
|
...editingStock,
|
|
[productId]: true,
|
|
});
|
|
};
|
|
|
|
const handleSaveStock = async (product: Product) => {
|
|
if (!product._id) return;
|
|
|
|
try {
|
|
const newStockValue = stockValues[product._id] || 0;
|
|
|
|
const stockData: StockData = {
|
|
currentStock: newStockValue,
|
|
stockTracking: product.stockTracking || false,
|
|
lowStockThreshold: product.lowStockThreshold
|
|
};
|
|
|
|
await clientFetch(`api/stock/${product._id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(stockData)
|
|
});
|
|
|
|
// Update local products state
|
|
setProducts(products.map(p => {
|
|
if (p._id === product._id) {
|
|
return {
|
|
...p,
|
|
currentStock: newStockValue
|
|
};
|
|
}
|
|
return p;
|
|
}));
|
|
|
|
setEditingStock({
|
|
...editingStock,
|
|
[product._id]: false,
|
|
});
|
|
|
|
toast.success("Stock updated successfully");
|
|
} catch (error) {
|
|
console.error("Error updating stock:", error);
|
|
toast.error("Failed to update stock");
|
|
}
|
|
};
|
|
|
|
const handleStockChange = (productId: string, value: number) => {
|
|
setStockValues({
|
|
...stockValues,
|
|
[productId]: value,
|
|
});
|
|
};
|
|
|
|
const handleToggleStockTracking = async (product: Product) => {
|
|
if (!product._id) return;
|
|
|
|
try {
|
|
// Toggle the stock tracking status
|
|
const newTrackingStatus = !product.stockTracking;
|
|
|
|
// For enabling tracking, we need to ensure there's a stock value
|
|
const stockData: StockData = {
|
|
stockTracking: newTrackingStatus,
|
|
currentStock: product.currentStock || 0,
|
|
lowStockThreshold: product.lowStockThreshold || 10,
|
|
};
|
|
|
|
// Update stock tracking status
|
|
await clientFetch(`api/stock/${product._id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(stockData)
|
|
});
|
|
|
|
// Update local state
|
|
setProducts(products.map(p => {
|
|
if (p._id === product._id) {
|
|
return {
|
|
...p,
|
|
stockTracking: newTrackingStatus,
|
|
currentStock: stockData.currentStock,
|
|
lowStockThreshold: stockData.lowStockThreshold,
|
|
};
|
|
}
|
|
return p;
|
|
}));
|
|
|
|
toast.success(`Stock tracking ${newTrackingStatus ? 'enabled' : 'disabled'} for ${product.name}`);
|
|
} catch (error) {
|
|
console.error("Error toggling stock tracking:", error);
|
|
toast.error(`Failed to ${product.stockTracking ? 'disable' : 'enable'} stock tracking`);
|
|
}
|
|
};
|
|
|
|
const handleBulkAction = async (action: 'enable' | 'disable') => {
|
|
setBulkAction(action);
|
|
setIsConfirmDialogOpen(true);
|
|
};
|
|
|
|
const executeBulkAction = async () => {
|
|
if (!bulkAction) return;
|
|
|
|
try {
|
|
const productsToUpdate = products.filter(p => selectedProducts.includes(p._id || ''));
|
|
|
|
await Promise.all(productsToUpdate.map(async (product) => {
|
|
if (!product._id) return;
|
|
|
|
const stockData: StockData = {
|
|
stockTracking: bulkAction === 'enable',
|
|
currentStock: product.currentStock || 0,
|
|
lowStockThreshold: product.lowStockThreshold || 10,
|
|
};
|
|
|
|
await clientFetch(`api/stock/${product._id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(stockData)
|
|
});
|
|
}));
|
|
|
|
// Update local state
|
|
setProducts(products.map(p => {
|
|
if (selectedProducts.includes(p._id || '')) {
|
|
return {
|
|
...p,
|
|
stockTracking: bulkAction === 'enable',
|
|
};
|
|
}
|
|
return p;
|
|
}));
|
|
|
|
setSelectedProducts([]);
|
|
toast.success(`Stock tracking ${bulkAction}d for selected products`);
|
|
} catch (error) {
|
|
console.error(`Error ${bulkAction}ing stock tracking:`, error);
|
|
toast.error(`Failed to ${bulkAction} stock tracking`);
|
|
}
|
|
|
|
setIsConfirmDialogOpen(false);
|
|
setBulkAction(null);
|
|
};
|
|
|
|
const toggleSelectProduct = (productId: string) => {
|
|
setSelectedProducts(prev =>
|
|
prev.includes(productId)
|
|
? prev.filter(id => id !== productId)
|
|
: [...prev, productId]
|
|
);
|
|
};
|
|
|
|
const toggleSelectAll = () => {
|
|
setSelectedProducts(prev =>
|
|
prev.length === products.length
|
|
? []
|
|
: products.map(p => p._id || '')
|
|
);
|
|
};
|
|
|
|
const handleExportStock = async () => {
|
|
setIsExporting(true);
|
|
try {
|
|
let response;
|
|
let filename;
|
|
|
|
switch (reportType) {
|
|
case 'daily':
|
|
response = await clientFetch(`/api/analytics/daily-stock-report?date=${exportDate}`);
|
|
filename = `daily-stock-report-${exportDate}.csv`;
|
|
break;
|
|
|
|
case 'weekly':
|
|
if (!exportDateRange?.from) {
|
|
toast.error('Please select a date range for weekly report');
|
|
return;
|
|
}
|
|
const weekStart = format(exportDateRange.from, 'yyyy-MM-dd');
|
|
response = await clientFetch(`/api/analytics/weekly-stock-report?weekStart=${weekStart}`);
|
|
filename = `weekly-stock-report-${weekStart}.csv`;
|
|
break;
|
|
|
|
case 'monthly':
|
|
const year = selectedMonth.getFullYear();
|
|
const month = selectedMonth.getMonth() + 1;
|
|
response = await clientFetch(`/api/analytics/monthly-stock-report?year=${year}&month=${month}`);
|
|
filename = `monthly-stock-report-${year}-${month.toString().padStart(2, '0')}.csv`;
|
|
break;
|
|
|
|
case 'custom':
|
|
if (!exportDateRange?.from || !exportDateRange?.to) {
|
|
toast.error('Please select a date range for custom report');
|
|
return;
|
|
}
|
|
const startDate = format(exportDateRange.from, 'yyyy-MM-dd');
|
|
const endDate = format(exportDateRange.to, 'yyyy-MM-dd');
|
|
response = await clientFetch(`/api/analytics/daily-stock-report?startDate=${startDate}&endDate=${endDate}`);
|
|
filename = `custom-stock-report-${startDate}-to-${endDate}.csv`;
|
|
break;
|
|
|
|
default:
|
|
toast.error('Invalid report type');
|
|
return;
|
|
}
|
|
|
|
if (!response || !response.products) {
|
|
throw new Error('No data received from server');
|
|
}
|
|
|
|
// Convert data to CSV format
|
|
const csvHeaders = [
|
|
'Product Name',
|
|
'Quantity Sold',
|
|
'Total Revenue (£)',
|
|
'Average Price (£)',
|
|
'Order Count',
|
|
'Current Stock',
|
|
'Stock Status',
|
|
'Unit Type'
|
|
];
|
|
|
|
const csvData = [
|
|
csvHeaders.join(','),
|
|
...response.products.map((product: any) => [
|
|
`"${product.productName.replace(/"/g, '""')}"`, // Escape quotes in product names
|
|
product.quantitySold,
|
|
product.totalRevenue.toFixed(2),
|
|
product.averagePrice.toFixed(2),
|
|
product.orderCount,
|
|
product.currentStock || 0,
|
|
`"${product.stockStatus}"`,
|
|
`"${product.unitType || 'N/A'}"`
|
|
].join(','))
|
|
].join('\n');
|
|
|
|
// Create and download the CSV file
|
|
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', filename);
|
|
link.style.visibility = 'hidden';
|
|
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
const periodText = reportType === 'daily' ? exportDate :
|
|
reportType === 'weekly' ? `week starting ${format(exportDateRange?.from || new Date(), 'MMM dd')}` :
|
|
reportType === 'monthly' ? `${response.monthName || 'current month'}` :
|
|
`${format(exportDateRange?.from || new Date(), 'MMM dd')} to ${format(exportDateRange?.to || new Date(), 'MMM dd')}`;
|
|
|
|
toast.success(`${reportType.charAt(0).toUpperCase() + reportType.slice(1)} stock report for ${periodText} exported successfully`);
|
|
} catch (error) {
|
|
console.error('Error exporting stock report:', error);
|
|
toast.error('Failed to export stock report');
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
const getStockStatus = (product: Product) => {
|
|
if (!product.stockTracking) return 'Not tracked';
|
|
if (product.currentStock === undefined) return 'Unknown';
|
|
if (product.currentStock <= 0) return 'Out of stock';
|
|
if (product.lowStockThreshold && product.currentStock <= product.lowStockThreshold) return 'Low stock';
|
|
return 'In stock';
|
|
};
|
|
|
|
const filteredProducts = products.filter(product => {
|
|
if (!searchTerm) return true;
|
|
|
|
const searchLower = searchTerm.toLowerCase();
|
|
return (
|
|
product.name.toLowerCase().includes(searchLower) ||
|
|
product.description.toLowerCase().includes(searchLower) ||
|
|
getStockStatus(product).toLowerCase().includes(searchLower)
|
|
);
|
|
});
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
|
<Boxes className="mr-2 h-6 w-6" />
|
|
Stock Management
|
|
</h1>
|
|
<div className="flex items-center gap-3">
|
|
<Input
|
|
type="search"
|
|
placeholder="Search products..."
|
|
className="w-64"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
|
|
{/* Report Type Selector */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" className="gap-2">
|
|
<Calendar className="h-4 w-4" />
|
|
{reportType.charAt(0).toUpperCase() + reportType.slice(1)} Report
|
|
<ChevronDown className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuItem onClick={() => setReportType('daily')}>
|
|
Daily Report
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setReportType('weekly')}>
|
|
Weekly Report
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setReportType('monthly')}>
|
|
Monthly Report
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setReportType('custom')}>
|
|
Custom Range
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Date Selection based on report type */}
|
|
{reportType === 'daily' && (
|
|
<DatePicker
|
|
date={exportDate ? new Date(exportDate) : undefined}
|
|
onDateChange={(date) => setExportDate(date ? date.toISOString().split('T')[0] : '')}
|
|
placeholder="Select export date"
|
|
className="w-auto"
|
|
/>
|
|
)}
|
|
|
|
{(reportType === 'weekly' || reportType === 'custom') && (
|
|
<DateRangePicker
|
|
dateRange={exportDateRange}
|
|
onDateRangeChange={setExportDateRange}
|
|
placeholder="Select date range"
|
|
className="w-auto"
|
|
/>
|
|
)}
|
|
|
|
{reportType === 'monthly' && (
|
|
<MonthPicker
|
|
selectedMonth={selectedMonth}
|
|
onMonthChange={(date) => setSelectedMonth(date || new Date())}
|
|
placeholder="Select month"
|
|
className="w-auto"
|
|
/>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleExportStock}
|
|
disabled={isExporting}
|
|
className="gap-2"
|
|
>
|
|
{isExporting ? (
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Download className="h-4 w-4" />
|
|
)}
|
|
{isExporting ? 'Exporting...' : 'Export CSV'}
|
|
</Button>
|
|
|
|
{selectedProducts.length > 0 && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" className="gap-2">
|
|
<Package className="h-4 w-4" />
|
|
Bulk Actions
|
|
<ChevronDown className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => handleBulkAction('enable')}>
|
|
<CheckSquare className="h-4 w-4 mr-2" />
|
|
Enable Stock Tracking
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleBulkAction('disable')}>
|
|
<XSquare className="h-4 w-4 mr-2" />
|
|
Disable Stock Tracking
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-12">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedProducts.length === products.length}
|
|
onChange={toggleSelectAll}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
</TableHead>
|
|
<TableHead>Product</TableHead>
|
|
<TableHead>Stock Status</TableHead>
|
|
<TableHead>Current Stock</TableHead>
|
|
<TableHead>Track Stock</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center py-8">
|
|
<RefreshCw className="h-6 w-6 animate-spin inline-block" />
|
|
<span className="ml-2">Loading products...</span>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : filteredProducts.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center py-8">
|
|
No products found
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredProducts.map((product) => (
|
|
<TableRow key={product._id}>
|
|
<TableCell>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedProducts.includes(product._id || '')}
|
|
onChange={() => toggleSelectProduct(product._id || '')}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
</TableCell>
|
|
<TableCell>{product.name}</TableCell>
|
|
<TableCell>{getStockStatus(product)}</TableCell>
|
|
<TableCell>
|
|
{editingStock[product._id || ''] ? (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="number"
|
|
value={stockValues[product._id || ''] || 0}
|
|
onChange={(e) => handleStockChange(product._id || '', parseInt(e.target.value) || 0)}
|
|
className="w-24"
|
|
/>
|
|
<Button size="sm" onClick={() => handleSaveStock(product)}>Save</Button>
|
|
</div>
|
|
) : (
|
|
<span>{product.currentStock || 0}</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Switch
|
|
checked={product.stockTracking || false}
|
|
onCheckedChange={() => handleToggleStockTracking(product)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{!editingStock[product._id || ''] && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleEditStock(product._id || '')}
|
|
>
|
|
Edit Stock
|
|
</Button>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
|
|
<AlertDialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Confirm Bulk Action</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to {bulkAction} stock tracking for {selectedProducts.length} selected products?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => setBulkAction(null)}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={executeBulkAction}>Continue</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Layout>
|
|
);
|
|
}
|