Files
ember-market-frontend/app/dashboard/stock/page.tsx
NotII 1b51f29c24 Add flexible date pickers and export options to stock dashboard
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.
2025-07-30 00:38:25 +02:00

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>
);
}