Files
ember-market-frontend/app/dashboard/stock/page.tsx
g 73adbe5d07
All checks were successful
Build Frontend / build (push) Successful in 1m4s
Enhance admin dashboard UI and tables with new styles
Refactors admin dashboard, users, vendors, shipping, and stock pages to improve UI consistency and visual clarity. Adds new icons, animated transitions, and card styles for stats and tables. Updates table row rendering with framer-motion for smooth animations, improves badge and button styling, and enhances search/filter inputs. Refines loading skeletons and overall layout for a more modern, accessible admin experience.
2026-01-12 07:16:33 +00:00

690 lines
27 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator
} 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, Search, Filter, Save, X, Edit2 } 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";
import { motion, AnimatePresence } from "framer-motion";
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 getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'Out of stock': return 'destructive';
case 'Low stock': return 'warning'; // Custom variant or use secondary/outline
case 'In stock': return 'default'; // often maps to primary which might be blue/black
default: return 'secondary';
}
};
// Helper for badging - if your Badge component doesn't support 'warning' directly, use className overrides
const StatusBadge = ({ status }: { status: string }) => {
let styles = "font-medium border-transparent shadow-none";
if (status === 'Out of stock') styles += " bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400";
else if (status === 'Low stock') styles += " bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400";
else if (status === 'In stock') styles += " bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400";
else styles += " bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400";
return <Badge className={styles} variant="outline">{status}</Badge>;
};
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 animate-in fade-in duration-500">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<Boxes className="h-6 w-6 text-primary" />
Stock Management
</h1>
<p className="text-muted-foreground text-sm mt-1">
Track inventory levels and manage stock status
</p>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search products..."
className="pl-9 w-full sm:w-64 bg-background/50 border-border/50 focus:bg-background transition-colors"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-10 w-10 border-border/50 bg-background/50">
<Filter className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Filter Reports</DropdownMenuLabel>
<DropdownMenuSeparator />
<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 */}
<div className="hidden sm:block">
{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 border-border/50 bg-background/50"
/>
)}
{(reportType === 'weekly' || reportType === 'custom') && (
<DateRangePicker
dateRange={exportDateRange}
onDateRangeChange={setExportDateRange}
placeholder="Select date range"
className="w-auto border-border/50 bg-background/50"
/>
)}
{reportType === 'monthly' && (
<MonthPicker
selectedMonth={selectedMonth}
onMonthChange={(date) => setSelectedMonth(date || new Date())}
placeholder="Select month"
className="w-auto border-border/50 bg-background/50"
/>
)}
</div>
<Button
variant="outline"
onClick={handleExportStock}
disabled={isExporting}
className="gap-2 border-border/50 bg-background/50 hover:bg-background transition-colors"
>
{isExporting ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Export
</Button>
{selectedProducts.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" 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 Tracking
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleBulkAction('disable')}>
<XSquare className="h-4 w-4 mr-2" />
Disable Tracking
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-medium">Inventory Data</CardTitle>
<CardDescription>Manage stock levels and tracking for {products.length} products</CardDescription>
</div>
<div className="text-xs text-muted-foreground bg-background/50 px-3 py-1 rounded-full border border-border/50">
{filteredProducts.length} items
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader className="bg-muted/50">
<TableRow className="border-border/50 hover:bg-transparent">
<TableHead className="w-12 pl-6">
<input
type="checkbox"
checked={selectedProducts.length === products.length && products.length > 0}
onChange={toggleSelectAll}
className="rounded border-gray-300 dark:border-zinc-700 bg-background focus:ring-primary/20"
/>
</TableHead>
<TableHead>Product</TableHead>
<TableHead>Status</TableHead>
<TableHead>Current Stock</TableHead>
<TableHead>Tracking</TableHead>
<TableHead className="text-right pr-6">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence mode="popLayout">
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12">
<div className="flex flex-col items-center justify-center gap-3 text-muted-foreground">
<RefreshCw className="h-8 w-8 animate-spin opacity-20" />
<p>Loading products...</p>
</div>
</TableCell>
</TableRow>
) : filteredProducts.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12">
<div className="flex flex-col items-center justify-center gap-3 text-muted-foreground">
<Boxes className="h-10 w-10 opacity-20" />
<p>No products found matching your search</p>
</div>
</TableCell>
</TableRow>
) : (
filteredProducts.map((product, index) => (
<motion.tr
key={product._id}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
>
<TableCell className="pl-6">
<input
type="checkbox"
checked={selectedProducts.includes(product._id || '')}
onChange={() => toggleSelectProduct(product._id || '')}
className="rounded border-gray-300 dark:border-zinc-700 bg-background focus:ring-primary/20"
/>
</TableCell>
<TableCell className="font-medium">{product.name}</TableCell>
<TableCell>
<StatusBadge status={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-20 h-8 font-mono bg-background"
/>
</div>
) : (
<span className="font-mono text-sm">{product.currentStock || 0}</span>
)}
</TableCell>
<TableCell>
<Switch
checked={product.stockTracking || false}
onCheckedChange={() => handleToggleStockTracking(product)}
className="data-[state=checked]:bg-primary"
/>
</TableCell>
<TableCell className="text-right pr-6">
<div className="flex justify-end gap-1">
{editingStock[product._id || ''] ? (
<>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100 dark:hover:bg-green-900/20"
onClick={() => handleSaveStock(product)}
>
<Save className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setEditingStock({ ...editingStock, [product._id || '']: false })}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10"
onClick={() => handleEditStock(product._id || '')}
>
<Edit2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</motion.tr>
))
)}
</AnimatePresence>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
<AlertDialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Bulk Action</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to {bulkAction} stock tracking for <span className="font-medium text-foreground">{selectedProducts.length}</span> selected products?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setBulkAction(null)}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={executeBulkAction} className="bg-primary text-primary-foreground">
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}