Create pages and routes for new settings tables, start improving product details
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
ClipboardList,
|
||||
LogOut,
|
||||
Tags,
|
||||
FileSpreadsheet,
|
||||
Plus,
|
||||
ShoppingBag,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
@@ -40,18 +40,6 @@ const items = [
|
||||
url: "/products",
|
||||
permission: "access:products"
|
||||
},
|
||||
{
|
||||
title: "Import",
|
||||
icon: FileSpreadsheet,
|
||||
url: "/import",
|
||||
permission: "access:import"
|
||||
},
|
||||
{
|
||||
title: "Forecasting",
|
||||
icon: IconCrystalBall,
|
||||
url: "/forecasting",
|
||||
permission: "access:forecasting"
|
||||
},
|
||||
{
|
||||
title: "Categories",
|
||||
icon: Tags,
|
||||
@@ -82,6 +70,18 @@ const items = [
|
||||
url: "/analytics",
|
||||
permission: "access:analytics"
|
||||
},
|
||||
{
|
||||
title: "Forecasting",
|
||||
icon: IconCrystalBall,
|
||||
url: "/forecasting",
|
||||
permission: "access:forecasting"
|
||||
},
|
||||
{
|
||||
title: "Create Products",
|
||||
icon: Plus,
|
||||
url: "/import",
|
||||
permission: "access:import"
|
||||
}
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Drawer as VaulDrawer } from "vaul";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { X, Calendar, Users, DollarSign, Tag, Package, Clock, AlertTriangle } from "lucide-react";
|
||||
import { ProductMetric, ProductStatus } from "@/types/products";
|
||||
import {
|
||||
getStatusBadge,
|
||||
@@ -14,11 +14,38 @@ import {
|
||||
formatPercentage,
|
||||
formatDays,
|
||||
formatDate,
|
||||
formatBoolean,
|
||||
getProductStatus
|
||||
formatBoolean
|
||||
} from "@/utils/productUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import config from "@/config";
|
||||
import { ResponsiveContainer, BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, Legend } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
|
||||
// Interfaces for POs and time series data
|
||||
interface ProductPurchaseOrder {
|
||||
poId: string;
|
||||
date: string;
|
||||
expectedDate: string;
|
||||
receivedDate: string | null;
|
||||
ordered: number;
|
||||
received: number;
|
||||
status: number;
|
||||
receivingStatus: number;
|
||||
costPrice: number;
|
||||
notes: string | null;
|
||||
leadTimeDays: number | null;
|
||||
}
|
||||
|
||||
interface ProductTimeSeries {
|
||||
monthlySales: {
|
||||
month: string;
|
||||
sales: number;
|
||||
revenue: number;
|
||||
profit: number;
|
||||
}[];
|
||||
recentPurchases: ProductPurchaseOrder[];
|
||||
}
|
||||
|
||||
interface ProductDetailProps {
|
||||
productId: number | null;
|
||||
@@ -63,17 +90,77 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
transformed.pid = typeof transformed.pid === 'string' ?
|
||||
parseInt(transformed.pid, 10) : transformed.pid;
|
||||
|
||||
// Make sure we have a status
|
||||
if (!transformed.status) {
|
||||
transformed.status = getProductStatus(transformed);
|
||||
}
|
||||
console.log("Transformed product data:", transformed);
|
||||
|
||||
return transformed;
|
||||
},
|
||||
enabled: !!productId, // Only run query when productId is truthy
|
||||
});
|
||||
|
||||
const isLoading = isLoadingProduct;
|
||||
// Fetch time series data and purchase orders history
|
||||
const { data: timeSeriesData, isLoading: isLoadingTimeSeries } = useQuery<ProductTimeSeries, Error>({
|
||||
queryKey: ["productTimeSeries", productId],
|
||||
queryFn: async () => {
|
||||
if (!productId) throw new Error("Product ID is required");
|
||||
const response = await fetch(`${config.apiUrl}/products/${productId}/time-series`, {credentials: 'include'});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`Failed to fetch time series data (${response.status}): ${errorData.error || 'Server error'}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Ensure the monthly_sales data is properly formatted for charts
|
||||
const formattedMonthlySales = data.monthly_sales.map((item: any) => ({
|
||||
month: item.month,
|
||||
sales: Number(item.sales),
|
||||
revenue: Number(item.revenue),
|
||||
profit: Number(item.profit || 0)
|
||||
}));
|
||||
|
||||
return {
|
||||
monthlySales: formattedMonthlySales,
|
||||
recentPurchases: data.recent_purchases || []
|
||||
};
|
||||
},
|
||||
enabled: !!productId, // Only run query when productId is truthy
|
||||
});
|
||||
|
||||
// Get PO status names
|
||||
const getPOStatusName = (status: number): string => {
|
||||
const statusMap: {[key: number]: string} = {
|
||||
0: 'Canceled',
|
||||
1: 'Created',
|
||||
10: 'Ready to Send',
|
||||
11: 'Ordered',
|
||||
12: 'Preordered',
|
||||
13: 'Electronically Sent',
|
||||
15: 'Receiving Started',
|
||||
50: 'Completed'
|
||||
};
|
||||
return statusMap[status] || 'Unknown';
|
||||
};
|
||||
|
||||
// Get receiving status names
|
||||
const getReceivingStatusName = (status: number): string => {
|
||||
const statusMap: {[key: number]: string} = {
|
||||
0: 'Canceled',
|
||||
1: 'Created',
|
||||
30: 'Partial Received',
|
||||
40: 'Fully Received',
|
||||
50: 'Paid'
|
||||
};
|
||||
return statusMap[status] || 'Unknown';
|
||||
};
|
||||
|
||||
// Get status badge color class
|
||||
const getStatusBadgeClass = (status: number): string => {
|
||||
if (status === 0) return "bg-destructive text-destructive-foreground"; // Canceled
|
||||
if (status === 50) return "bg-green-600 text-white"; // Completed
|
||||
if (status >= 15) return "bg-amber-500 text-black"; // In progress
|
||||
return "bg-blue-600 text-white"; // Other statuses
|
||||
};
|
||||
|
||||
const isLoading = isLoadingProduct || isLoadingTimeSeries;
|
||||
|
||||
if (!productId) return null; // Don't render anything if no ID
|
||||
|
||||
@@ -130,10 +217,11 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
</div>
|
||||
) : product ? (
|
||||
<Tabs defaultValue="overview" className="p-4">
|
||||
<TabsList className="mb-4 grid w-full grid-cols-4 h-auto">
|
||||
<TabsList className="mb-4 grid w-full grid-cols-5 h-auto">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
||||
<TabsTrigger value="performance">Performance</TabsTrigger>
|
||||
<TabsTrigger value="orders">Orders</TabsTrigger>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -191,6 +279,68 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="performance" className="space-y-4">
|
||||
{/* Sales Performance Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Sales & Revenue Trend</CardTitle>
|
||||
<CardDescription>Monthly performance over time</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
{isLoadingTimeSeries ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Skeleton className="h-[250px] w-full" />
|
||||
</div>
|
||||
) : timeSeriesData && timeSeriesData.monthlySales && timeSeriesData.monthlySales.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={timeSeriesData.monthlySales}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis yAxisId="left" />
|
||||
<YAxis yAxisId="right" orientation="right" />
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'revenue' || name === 'profit') {
|
||||
return [formatCurrency(value), name];
|
||||
}
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="sales"
|
||||
name="Units Sold"
|
||||
stroke="#8884d8"
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#82ca9d"
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="profit"
|
||||
name="Profit"
|
||||
stroke="#ffc658"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<p>No sales data available for this product.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Sales Performance (30 Days)</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
@@ -203,6 +353,60 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<InfoItem label="Daily Velocity" value={formatNumber(product.salesVelocityDaily, 2)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Inventory KPIs Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Key Inventory Metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[250px]">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[200px] w-full" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={[
|
||||
{
|
||||
name: 'Stock Turn',
|
||||
value: product.stockturn30d || 0,
|
||||
fill: '#8884d8'
|
||||
},
|
||||
{
|
||||
name: 'GMROI',
|
||||
value: product.gmroi30d || 0,
|
||||
fill: '#82ca9d'
|
||||
},
|
||||
{
|
||||
name: 'Sell Through %',
|
||||
value: product.sellThrough30d ? product.sellThrough30d * 100 : 0,
|
||||
fill: '#ffc658'
|
||||
},
|
||||
{
|
||||
name: 'Margin %',
|
||||
value: product.margin30d ? product.margin30d * 100 : 0,
|
||||
fill: '#ff8042'
|
||||
}
|
||||
]}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 50 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" height={60} />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Sell Through %' || name === 'Margin %') {
|
||||
return [`${value.toFixed(1)}%`, name];
|
||||
}
|
||||
return [value.toFixed(2), name];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Inventory Performance (30 Days)</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
@@ -222,6 +426,143 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<InfoItem label="Avg Lead Time" value={formatDays(product.avgLeadTimeDays, 1)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Additional Metrics</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Returns (Units)" value={formatNumber(product.returnsUnits30d)} />
|
||||
<InfoItem label="Returns (Revenue)" value={formatCurrency(product.returnsRevenue30d)} />
|
||||
<InfoItem label="Return Rate" value={formatPercentage(product.returnRate30d)} />
|
||||
<InfoItem label="Discounts" value={formatCurrency(product.discounts30d)} />
|
||||
<InfoItem label="Discount Rate" value={formatPercentage(product.discountRate30d)} />
|
||||
<InfoItem label="Markdown" value={formatCurrency(product.markdown30d)} />
|
||||
<InfoItem label="Markdown Rate" value={formatPercentage(product.markdownRate30d)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orders" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Purchase Orders & Receivings</CardTitle>
|
||||
<CardDescription>Recent purchase orders for this product</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingTimeSeries ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : timeSeriesData?.recentPurchases && timeSeriesData.recentPurchases.length > 0 ? (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>PO #</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Qty</TableHead>
|
||||
<TableHead className="text-right">Received</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{timeSeriesData.recentPurchases.map((po) => (
|
||||
<TableRow key={po.poId}>
|
||||
<TableCell className="font-medium">{po.poId}</TableCell>
|
||||
<TableCell>{po.date}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getStatusBadgeClass(po.status)}>
|
||||
{getPOStatusName(po.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{po.ordered}</TableCell>
|
||||
<TableCell className="text-right">{po.received}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(po.costPrice)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<p>No purchase orders found for this product.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Order Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem
|
||||
label="Total Ordered"
|
||||
value={
|
||||
timeSeriesData?.recentPurchases ?
|
||||
formatNumber(
|
||||
timeSeriesData.recentPurchases.reduce((acc, po) => acc + po.ordered, 0)
|
||||
) : 'N/A'
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Total Received"
|
||||
value={
|
||||
timeSeriesData?.recentPurchases ?
|
||||
formatNumber(
|
||||
timeSeriesData.recentPurchases.reduce((acc, po) => acc + po.received, 0)
|
||||
) : 'N/A'
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Fulfillment Rate"
|
||||
value={
|
||||
timeSeriesData?.recentPurchases ?
|
||||
(() => {
|
||||
const totalOrdered = timeSeriesData.recentPurchases.reduce((acc, po) => acc + po.ordered, 0);
|
||||
const totalReceived = timeSeriesData.recentPurchases.reduce((acc, po) => acc + po.received, 0);
|
||||
return totalOrdered > 0 ? formatPercentage(totalReceived / totalOrdered) : 'N/A';
|
||||
})() : 'N/A'
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Total PO Cost"
|
||||
value={
|
||||
timeSeriesData?.recentPurchases ?
|
||||
formatCurrency(
|
||||
timeSeriesData.recentPurchases.reduce((acc, po) => acc + (po.ordered * po.costPrice), 0)
|
||||
) : 'N/A'
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Avg Lead Time"
|
||||
value={
|
||||
timeSeriesData?.recentPurchases ?
|
||||
(() => {
|
||||
const leadTimes = timeSeriesData.recentPurchases
|
||||
.filter(po => po.leadTimeDays != null)
|
||||
.map(po => po.leadTimeDays as number);
|
||||
const avgLeadTime = leadTimes.length > 0
|
||||
? leadTimes.reduce((acc, lt) => acc + lt, 0) / leadTimes.length
|
||||
: null;
|
||||
return formatDays(avgLeadTime);
|
||||
})() : 'N/A'
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Active Orders"
|
||||
value={
|
||||
timeSeriesData?.recentPurchases ?
|
||||
formatNumber(
|
||||
timeSeriesData.recentPurchases.filter(
|
||||
po => po.status < 50 && po.receivingStatus < 40
|
||||
).length
|
||||
) : 'N/A'
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
@@ -242,6 +583,17 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Lead Time" value={formatDays(product.configLeadTime)} />
|
||||
<InfoItem label="Days of Stock" value={formatDays(product.configDaysOfStock)} />
|
||||
<InfoItem label="Safety Stock" value={formatNumber(product.configSafetyStock)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Forecasting</CardTitle></CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-sm">
|
||||
<InfoItem label="Replenishment Units" value={formatNumber(product.replenishmentUnits)} />
|
||||
<InfoItem label="Replenishment Cost" value={formatCurrency(product.replenishmentCost)} />
|
||||
<InfoItem label="To Order Units" value={formatNumber(product.toOrderUnits)} />
|
||||
<InfoItem label="Forecast Lost Sales" value={formatNumber(product.forecastLostSalesUnits)} />
|
||||
<InfoItem label="Forecast Lost Revenue" value={formatCurrency(product.forecastLostRevenue)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import config from '../../config';
|
||||
|
||||
interface SalesVelocityConfig {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
daily_window_days: number;
|
||||
weekly_window_days: number;
|
||||
monthly_window_days: number;
|
||||
}
|
||||
|
||||
export function CalculationSettings() {
|
||||
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
||||
id: 1,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
daily_window_days: 30,
|
||||
weekly_window_days: 7,
|
||||
monthly_window_days: 90
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load configuration');
|
||||
}
|
||||
const data = await response.json();
|
||||
setSalesVelocityConfig(data.salesVelocityConfig);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to load configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const handleUpdateSalesVelocityConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/sales-velocity/1`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(salesVelocityConfig)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update sales velocity configuration');
|
||||
}
|
||||
|
||||
toast.success('Sales velocity configuration updated successfully');
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[700px] space-y-4">
|
||||
{/* Sales Velocity Configuration Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Velocity Windows</CardTitle>
|
||||
<CardDescription>Configure time windows for sales velocity calculations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="daily-window">Daily Window (days)</Label>
|
||||
<Input
|
||||
id="daily-window"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={salesVelocityConfig.daily_window_days}
|
||||
onChange={(e) => setSalesVelocityConfig(prev => ({
|
||||
...prev,
|
||||
daily_window_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="weekly-window">Weekly Window (days)</Label>
|
||||
<Input
|
||||
id="weekly-window"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={salesVelocityConfig.weekly_window_days}
|
||||
onChange={(e) => setSalesVelocityConfig(prev => ({
|
||||
...prev,
|
||||
weekly_window_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="monthly-window">Monthly Window (days)</Label>
|
||||
<Input
|
||||
id="monthly-window"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={salesVelocityConfig.monthly_window_days}
|
||||
onChange={(e) => setSalesVelocityConfig(prev => ({
|
||||
...prev,
|
||||
monthly_window_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleUpdateSalesVelocityConfig}>
|
||||
Update Sales Velocity Windows
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,626 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import axios from "axios";
|
||||
import config from "@/config";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface StockThreshold {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
critical_days: number;
|
||||
reorder_days: number;
|
||||
overstock_days: number;
|
||||
low_stock_threshold: number;
|
||||
min_reorder_quantity: number;
|
||||
category_name?: string;
|
||||
threshold_scope?: string;
|
||||
}
|
||||
|
||||
interface LeadTimeThreshold {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
target_days: number;
|
||||
warning_days: number;
|
||||
critical_days: number;
|
||||
}
|
||||
|
||||
interface SalesVelocityConfig {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
daily_window_days: number;
|
||||
weekly_window_days: number;
|
||||
monthly_window_days: number;
|
||||
}
|
||||
|
||||
interface ABCClassificationConfig {
|
||||
id: number;
|
||||
a_threshold: number;
|
||||
b_threshold: number;
|
||||
classification_period_days: number;
|
||||
}
|
||||
|
||||
interface SafetyStockConfig {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
coverage_days: number;
|
||||
service_level: number;
|
||||
}
|
||||
|
||||
interface TurnoverConfig {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
calculation_period_days: number;
|
||||
target_rate: number;
|
||||
}
|
||||
|
||||
export function Configuration() {
|
||||
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
||||
id: 1,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
critical_days: 7,
|
||||
reorder_days: 14,
|
||||
overstock_days: 90,
|
||||
low_stock_threshold: 5,
|
||||
min_reorder_quantity: 1
|
||||
});
|
||||
|
||||
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
||||
id: 1,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
target_days: 14,
|
||||
warning_days: 21,
|
||||
critical_days: 30
|
||||
});
|
||||
|
||||
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
||||
id: 1,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
daily_window_days: 30,
|
||||
weekly_window_days: 7,
|
||||
monthly_window_days: 90
|
||||
});
|
||||
|
||||
const [abcConfig, setAbcConfig] = useState<ABCClassificationConfig>({
|
||||
id: 1,
|
||||
a_threshold: 20.0,
|
||||
b_threshold: 50.0,
|
||||
classification_period_days: 90
|
||||
});
|
||||
|
||||
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
||||
id: 1,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
coverage_days: 14,
|
||||
service_level: 95.0
|
||||
});
|
||||
|
||||
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
|
||||
id: 1,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
calculation_period_days: 30,
|
||||
target_rate: 1.0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load configuration');
|
||||
}
|
||||
const data = await response.json();
|
||||
setStockThresholds(data.stockThresholds);
|
||||
setLeadTimeThresholds(data.leadTimeThresholds);
|
||||
setSalesVelocityConfig(data.salesVelocityConfig);
|
||||
setAbcConfig(data.abcConfig);
|
||||
setSafetyStockConfig(data.safetyStockConfig);
|
||||
setTurnoverConfig(data.turnoverConfig);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load configuration');
|
||||
}
|
||||
};
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const handleUpdateStockThresholds = async () => {
|
||||
try {
|
||||
const response = await axios.post(`${config.apiUrl}/settings/stock-thresholds`, stockThresholds);
|
||||
if (response.status === 200) {
|
||||
toast.success('Stock thresholds updated successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating stock thresholds:", error);
|
||||
toast.error('Failed to update stock thresholds');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateLeadTimeThresholds = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/lead-time`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(leadTimeThresholds)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update lead time thresholds');
|
||||
}
|
||||
|
||||
toast.success('Lead time thresholds updated successfully');
|
||||
} catch (error) {
|
||||
console.error("Error updating lead time thresholds:", error);
|
||||
toast.error(`Failed to update thresholds: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSalesVelocityConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/sales-velocity/1`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(salesVelocityConfig)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update sales velocity configuration');
|
||||
}
|
||||
|
||||
toast.success('Sales velocity configuration updated successfully');
|
||||
} catch (error) {
|
||||
console.error("Error updating sales velocity configuration:", error);
|
||||
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateABCConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/abc-classification/1`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(abcConfig)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update ABC classification configuration');
|
||||
}
|
||||
|
||||
toast.success('ABC classification configuration updated successfully');
|
||||
} catch (error) {
|
||||
console.error("Error updating ABC classification configuration:", error);
|
||||
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSafetyStockConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/safety-stock/1`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(safetyStockConfig)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update safety stock configuration');
|
||||
}
|
||||
|
||||
toast.success('Safety stock configuration updated successfully');
|
||||
} catch (error) {
|
||||
console.error("Error updating safety stock configuration:", error);
|
||||
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTurnoverConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/turnover/1`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(turnoverConfig)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update turnover configuration');
|
||||
}
|
||||
|
||||
toast.success('Turnover configuration updated successfully');
|
||||
} catch (error) {
|
||||
console.error("Error updating turnover configuration:", error);
|
||||
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="stock" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="stock">Stock Management</TabsTrigger>
|
||||
<TabsTrigger value="performance">Performance Metrics</TabsTrigger>
|
||||
<TabsTrigger value="calculation">Calculation Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stock" className="space-y-4">
|
||||
{/* Stock Thresholds Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Thresholds</CardTitle>
|
||||
<CardDescription>Configure stock level thresholds for inventory management</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="critical-days">Critical Days</Label>
|
||||
<Input
|
||||
id="critical-days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={stockThresholds.critical_days}
|
||||
onChange={(e) => setStockThresholds(prev => ({
|
||||
...prev,
|
||||
critical_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reorder-days">Reorder Days</Label>
|
||||
<Input
|
||||
id="reorder-days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={stockThresholds.reorder_days}
|
||||
onChange={(e) => setStockThresholds(prev => ({
|
||||
...prev,
|
||||
reorder_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="overstock-days">Overstock Days</Label>
|
||||
<Input
|
||||
id="overstock-days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={stockThresholds.overstock_days}
|
||||
onChange={(e) => setStockThresholds(prev => ({
|
||||
...prev,
|
||||
overstock_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="low-stock-threshold">Low Stock Threshold</Label>
|
||||
<Input
|
||||
id="low-stock-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
value={stockThresholds.low_stock_threshold}
|
||||
onChange={(e) => setStockThresholds(prev => ({
|
||||
...prev,
|
||||
low_stock_threshold: parseInt(e.target.value) || 0
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="min-reorder-quantity">Minimum Reorder Quantity</Label>
|
||||
<Input
|
||||
id="min-reorder-quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={stockThresholds.min_reorder_quantity}
|
||||
onChange={(e) => setStockThresholds(prev => ({
|
||||
...prev,
|
||||
min_reorder_quantity: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleUpdateStockThresholds}>
|
||||
Update Stock Thresholds
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Safety Stock Configuration Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Safety Stock</CardTitle>
|
||||
<CardDescription>Configure safety stock parameters</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="coverage-days">Coverage Days</Label>
|
||||
<Input
|
||||
id="coverage-days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={safetyStockConfig.coverage_days}
|
||||
onChange={(e) => setSafetyStockConfig(prev => ({
|
||||
...prev,
|
||||
coverage_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="service-level">Service Level (%)</Label>
|
||||
<Input
|
||||
id="service-level"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={safetyStockConfig.service_level}
|
||||
onChange={(e) => setSafetyStockConfig(prev => ({
|
||||
...prev,
|
||||
service_level: parseFloat(e.target.value) || 0
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleUpdateSafetyStockConfig}>
|
||||
Update Safety Stock Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="performance" className="space-y-4">
|
||||
{/* Lead Time Thresholds Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lead Time Thresholds</CardTitle>
|
||||
<CardDescription>Configure lead time thresholds for vendor performance</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="target-days">Target Days</Label>
|
||||
<Input
|
||||
id="target-days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={leadTimeThresholds.target_days}
|
||||
onChange={(e) => setLeadTimeThresholds(prev => ({
|
||||
...prev,
|
||||
target_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="warning-days">Warning Days</Label>
|
||||
<Input
|
||||
id="warning-days"
|
||||
type="number"
|
||||
min="1"
|
||||
value={leadTimeThresholds.warning_days}
|
||||
onChange={(e) => setLeadTimeThresholds(prev => ({
|
||||
...prev,
|
||||
warning_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="critical-days-lead">Critical Days</Label>
|
||||
<Input
|
||||
id="critical-days-lead"
|
||||
type="number"
|
||||
min="1"
|
||||
value={leadTimeThresholds.critical_days}
|
||||
onChange={(e) => setLeadTimeThresholds(prev => ({
|
||||
...prev,
|
||||
critical_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleUpdateLeadTimeThresholds}>
|
||||
Update Lead Time Thresholds
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ABC Classification Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ABC Classification</CardTitle>
|
||||
<CardDescription>Configure ABC classification parameters</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="a-threshold">A Threshold (%)</Label>
|
||||
<Input
|
||||
id="a-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={abcConfig.a_threshold}
|
||||
onChange={(e) => setAbcConfig(prev => ({
|
||||
...prev,
|
||||
a_threshold: parseFloat(e.target.value) || 0
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="b-threshold">B Threshold (%)</Label>
|
||||
<Input
|
||||
id="b-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={abcConfig.b_threshold}
|
||||
onChange={(e) => setAbcConfig(prev => ({
|
||||
...prev,
|
||||
b_threshold: parseFloat(e.target.value) || 0
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="classification-period">Classification Period (days)</Label>
|
||||
<Input
|
||||
id="classification-period"
|
||||
type="number"
|
||||
min="1"
|
||||
value={abcConfig.classification_period_days}
|
||||
onChange={(e) => setAbcConfig(prev => ({
|
||||
...prev,
|
||||
classification_period_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleUpdateABCConfig}>
|
||||
Update ABC Classification
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Turnover Configuration Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Turnover Rate</CardTitle>
|
||||
<CardDescription>Configure turnover rate calculations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="calculation-period">Calculation Period (days)</Label>
|
||||
<Input
|
||||
id="calculation-period"
|
||||
type="number"
|
||||
min="1"
|
||||
value={turnoverConfig.calculation_period_days}
|
||||
onChange={(e) => setTurnoverConfig(prev => ({
|
||||
...prev,
|
||||
calculation_period_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="target-rate">Target Rate</Label>
|
||||
<Input
|
||||
id="target-rate"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={turnoverConfig.target_rate}
|
||||
onChange={(e) => setTurnoverConfig(prev => ({
|
||||
...prev,
|
||||
target_rate: parseFloat(e.target.value) || 0
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleUpdateTurnoverConfig}>
|
||||
Update Turnover Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="calculation" className="space-y-4">
|
||||
{/* Sales Velocity Configuration Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Velocity Windows</CardTitle>
|
||||
<CardDescription>Configure time windows for sales velocity calculations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="daily-window">Daily Window (days)</Label>
|
||||
<Input
|
||||
id="daily-window"
|
||||
type="number"
|
||||
min="1"
|
||||
value={salesVelocityConfig.daily_window_days}
|
||||
onChange={(e) => setSalesVelocityConfig(prev => ({
|
||||
...prev,
|
||||
daily_window_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="weekly-window">Weekly Window (days)</Label>
|
||||
<Input
|
||||
id="weekly-window"
|
||||
type="number"
|
||||
min="1"
|
||||
value={salesVelocityConfig.weekly_window_days}
|
||||
onChange={(e) => setSalesVelocityConfig(prev => ({
|
||||
...prev,
|
||||
weekly_window_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="monthly-window">Monthly Window (days)</Label>
|
||||
<Input
|
||||
id="monthly-window"
|
||||
type="number"
|
||||
min="1"
|
||||
value={salesVelocityConfig.monthly_window_days}
|
||||
onChange={(e) => setSalesVelocityConfig(prev => ({
|
||||
...prev,
|
||||
monthly_window_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleUpdateSalesVelocityConfig}>
|
||||
Update Sales Velocity Windows
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
188
inventory/src/components/settings/GlobalSettings.tsx
Normal file
188
inventory/src/components/settings/GlobalSettings.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import config from '../../config';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface GlobalSetting {
|
||||
setting_key: string;
|
||||
setting_value: string;
|
||||
description: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function GlobalSettings() {
|
||||
const [settings, setSettings] = useState<GlobalSetting[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${config.apiUrl}/config/global`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load global settings');
|
||||
}
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = async (key: string, value: string) => {
|
||||
const updatedSettings = settings.map(s =>
|
||||
s.setting_key === key ? { ...s, setting_value: value } : s
|
||||
);
|
||||
setSettings(updatedSettings);
|
||||
};
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/global`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update global settings');
|
||||
}
|
||||
|
||||
toast.success('Global settings updated successfully');
|
||||
await loadSettings(); // Reload to get fresh data
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update settings: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSettingInput = (setting: GlobalSetting) => {
|
||||
// Handle different input types based on setting key or value
|
||||
if (setting.setting_key === 'abc_calculation_basis') {
|
||||
return (
|
||||
<Select
|
||||
value={setting.setting_value}
|
||||
onValueChange={(value) => updateSetting(setting.setting_key, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select calculation basis" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="revenue_30d">Revenue (30 days)</SelectItem>
|
||||
<SelectItem value="sales_30d">Sales Quantity (30 days)</SelectItem>
|
||||
<SelectItem value="lifetime_revenue">Lifetime Revenue</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else if (setting.setting_key === 'default_forecast_method') {
|
||||
return (
|
||||
<Select
|
||||
value={setting.setting_value}
|
||||
onValueChange={(value) => updateSetting(setting.setting_key, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select forecast method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">Standard</SelectItem>
|
||||
<SelectItem value="seasonal">Seasonal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else if (setting.setting_key.includes('threshold')) {
|
||||
// Percentage inputs
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={setting.setting_value}
|
||||
onChange={(e) => updateSetting(setting.setting_key, e.target.value)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// Default to number input for other settings
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={setting.setting_value}
|
||||
onChange={(e) => updateSetting(setting.setting_key, e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Group settings by their purpose
|
||||
const abcSettings = settings.filter(s => s.setting_key.startsWith('abc_'));
|
||||
const defaultSettings = settings.filter(s => s.setting_key.startsWith('default_'));
|
||||
|
||||
if (loading) {
|
||||
return <div className="py-4">Loading settings...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[700px] space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ABC Classification Settings</CardTitle>
|
||||
<CardDescription>Configure how products are classified into A, B, and C categories</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{abcSettings.map(setting => (
|
||||
<div key={setting.setting_key} className="grid gap-2">
|
||||
<Label htmlFor={setting.setting_key}>
|
||||
{setting.description}
|
||||
</Label>
|
||||
{renderSettingInput(setting)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Default Settings</CardTitle>
|
||||
<CardDescription>Configure system-wide default values</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{defaultSettings.map(setting => (
|
||||
<div key={setting.setting_key} className="grid gap-2">
|
||||
<Label htmlFor={setting.setting_key}>
|
||||
{setting.description}
|
||||
</Label>
|
||||
{renderSettingInput(setting)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveSettings}>
|
||||
Save All Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import config from '../../config';
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
interface LeadTimeThreshold {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
target_days: number;
|
||||
warning_days: number;
|
||||
critical_days: number;
|
||||
}
|
||||
|
||||
interface ABCClassificationConfig {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
a_threshold: number;
|
||||
b_threshold: number;
|
||||
classification_period_days: number;
|
||||
}
|
||||
|
||||
interface TurnoverConfig {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
calculation_period_days: number;
|
||||
target_rate: number;
|
||||
}
|
||||
|
||||
export function PerformanceMetrics() {
|
||||
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
||||
id: 1,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
target_days: 14,
|
||||
warning_days: 21,
|
||||
critical_days: 30
|
||||
});
|
||||
|
||||
const [abcConfigs, setAbcConfigs] = useState<ABCClassificationConfig[]>([]);
|
||||
|
||||
const [turnoverConfigs, setTurnoverConfigs] = useState<TurnoverConfig[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load configuration');
|
||||
}
|
||||
const data = await response.json();
|
||||
setLeadTimeThresholds(data.leadTimeThresholds);
|
||||
setAbcConfigs(data.abcConfigs);
|
||||
setTurnoverConfigs(data.turnoverConfigs);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to load configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const handleUpdateLeadTimeThresholds = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/lead-time-thresholds/1`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(leadTimeThresholds)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update lead time thresholds');
|
||||
}
|
||||
|
||||
toast.success('Lead time thresholds updated successfully');
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update thresholds: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateABCConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/abc-classification/1`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(abcConfigs)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update ABC classification configuration');
|
||||
}
|
||||
|
||||
toast.success('ABC classification configuration updated successfully');
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTurnoverConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/turnover/1`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(turnoverConfigs)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update turnover configuration');
|
||||
}
|
||||
|
||||
toast.success('Turnover configuration updated successfully');
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
function getCategoryName(cat_id: number): import("react").ReactNode {
|
||||
// Simple implementation that just returns the ID as a string
|
||||
return `Category ${cat_id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[700px] space-y-4">
|
||||
{/* Lead Time Thresholds Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lead Time Thresholds</CardTitle>
|
||||
<CardDescription>Configure lead time thresholds for vendor performance</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="target-days">Target Days</Label>
|
||||
<Input
|
||||
id="target-days"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={leadTimeThresholds.target_days}
|
||||
onChange={(e) => setLeadTimeThresholds(prev => ({
|
||||
...prev,
|
||||
target_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="warning-days">Warning Days</Label>
|
||||
<Input
|
||||
id="warning-days"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={leadTimeThresholds.warning_days}
|
||||
onChange={(e) => setLeadTimeThresholds(prev => ({
|
||||
...prev,
|
||||
warning_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="critical-days-lead">Critical Days</Label>
|
||||
<Input
|
||||
id="critical-days-lead"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={leadTimeThresholds.critical_days}
|
||||
onChange={(e) => setLeadTimeThresholds(prev => ({
|
||||
...prev,
|
||||
critical_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleUpdateLeadTimeThresholds}>
|
||||
Update Lead Time Thresholds
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ABC Classification Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ABC Classification</CardTitle>
|
||||
<CardDescription>Configure ABC classification parameters</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell>Category</TableCell>
|
||||
<TableCell>Vendor</TableCell>
|
||||
<TableCell className="text-right">A Threshold</TableCell>
|
||||
<TableCell className="text-right">B Threshold</TableCell>
|
||||
<TableCell className="text-right">Period Days</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{abcConfigs && abcConfigs.length > 0 ? abcConfigs.map((config) => (
|
||||
<TableRow key={`${config.cat_id}-${config.vendor}`}>
|
||||
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
|
||||
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
|
||||
<TableCell className="text-right">{config.a_threshold !== undefined ? `${config.a_threshold}%` : '0%'}</TableCell>
|
||||
<TableCell className="text-right">{config.b_threshold !== undefined ? `${config.b_threshold}%` : '0%'}</TableCell>
|
||||
<TableCell className="text-right">{config.classification_period_days || 0}</TableCell>
|
||||
</TableRow>
|
||||
)) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-4">No ABC configurations available</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Button onClick={handleUpdateABCConfig}>
|
||||
Update ABC Classification
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Turnover Configuration Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Turnover Rate</CardTitle>
|
||||
<CardDescription>Configure turnover rate calculations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell>Category</TableCell>
|
||||
<TableCell>Vendor</TableCell>
|
||||
<TableCell className="text-right">Period Days</TableCell>
|
||||
<TableCell className="text-right">Target Rate</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{turnoverConfigs && turnoverConfigs.length > 0 ? turnoverConfigs.map((config) => (
|
||||
<TableRow key={`${config.cat_id}-${config.vendor}`}>
|
||||
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
|
||||
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
|
||||
<TableCell className="text-right">{config.calculation_period_days}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{config.target_rate !== undefined && config.target_rate !== null
|
||||
? (typeof config.target_rate === 'number'
|
||||
? config.target_rate.toFixed(2)
|
||||
: (isNaN(parseFloat(String(config.target_rate)))
|
||||
? '0.00'
|
||||
: parseFloat(String(config.target_rate)).toFixed(2)))
|
||||
: '0.00'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-4">No turnover configurations available</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Button onClick={handleUpdateTurnoverConfig}>
|
||||
Update Turnover Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
inventory/src/components/settings/ProductSettings.tsx
Normal file
322
inventory/src/components/settings/ProductSettings.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import config from '../../config';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Search } from 'lucide-react';
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
interface ProductSetting {
|
||||
pid: string;
|
||||
lead_time_days: number | null;
|
||||
days_of_stock: number | null;
|
||||
safety_stock: number;
|
||||
forecast_method: string | null;
|
||||
exclude_from_forecast: boolean;
|
||||
updated_at: string;
|
||||
product_name?: string; // Added for display purposes
|
||||
}
|
||||
|
||||
export function ProductSettings() {
|
||||
const [settings, setSettings] = useState<ProductSetting[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [pendingChanges, setPendingChanges] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Use useCallback to avoid unnecessary re-renders
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${config.apiUrl}/config/products?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(searchQuery)}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load product settings');
|
||||
}
|
||||
const data = await response.json();
|
||||
setSettings(data.items);
|
||||
setTotalCount(data.total);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, searchQuery, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const updateSetting = useCallback((pid: string, field: keyof ProductSetting, value: any) => {
|
||||
setSettings(prev => prev.map(setting =>
|
||||
setting.pid === pid ? { ...setting, [field]: value } : setting
|
||||
));
|
||||
setPendingChanges(prev => ({ ...prev, [pid]: true }));
|
||||
}, []);
|
||||
|
||||
const handleSaveSetting = useCallback(async (pid: string) => {
|
||||
try {
|
||||
const setting = settings.find(s => s.pid === pid);
|
||||
if (!setting) return;
|
||||
|
||||
const response = await fetch(`${config.apiUrl}/config/products/${pid}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
lead_time_days: setting.lead_time_days,
|
||||
days_of_stock: setting.days_of_stock,
|
||||
safety_stock: setting.safety_stock,
|
||||
forecast_method: setting.forecast_method,
|
||||
exclude_from_forecast: setting.exclude_from_forecast
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update product setting');
|
||||
}
|
||||
|
||||
toast.success(`Settings updated for product ${pid}`);
|
||||
setPendingChanges(prev => ({ ...prev, [pid]: false }));
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleResetToDefault = useCallback(async (pid: string) => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/products/${pid}/reset`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to reset product setting');
|
||||
}
|
||||
|
||||
toast.success(`Settings reset for product ${pid}`);
|
||||
loadSettings(); // Reload settings to get defaults
|
||||
} catch (error) {
|
||||
toast.error(`Failed to reset setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}, [loadSettings]);
|
||||
|
||||
const totalPages = useMemo(() => Math.ceil(totalCount / pageSize), [totalCount, pageSize]);
|
||||
|
||||
// Generate page numbers for pagination
|
||||
const paginationItems = useMemo(() => {
|
||||
const pages = [];
|
||||
const maxVisiblePages = 5;
|
||||
|
||||
// Always include first page
|
||||
pages.push(1);
|
||||
|
||||
// Calculate range of visible pages
|
||||
let startPage = Math.max(2, page - Math.floor(maxVisiblePages / 2));
|
||||
let endPage = Math.min(totalPages - 1, startPage + maxVisiblePages - 3);
|
||||
|
||||
// Adjust if we're near the end
|
||||
if (endPage <= startPage) {
|
||||
endPage = Math.min(totalPages - 1, startPage + 1);
|
||||
}
|
||||
|
||||
// Add ellipsis after first page if needed
|
||||
if (startPage > 2) {
|
||||
pages.push('ellipsis1');
|
||||
}
|
||||
|
||||
// Add visible pages
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
// Add ellipsis before last page if needed
|
||||
if (endPage < totalPages - 1) {
|
||||
pages.push('ellipsis2');
|
||||
}
|
||||
|
||||
// Always include last page if it exists and is not already included
|
||||
if (totalPages > 1) {
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}, [page, totalPages]);
|
||||
|
||||
if (loading && settings.length === 0) {
|
||||
return <div className="py-4">Loading settings...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[900px] space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Product-Specific Settings</CardTitle>
|
||||
<CardDescription>Configure settings for individual products that override global defaults</CardDescription>
|
||||
|
||||
<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 by ID or name..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[500px] rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product ID</TableHead>
|
||||
<TableHead>Lead Time (days)</TableHead>
|
||||
<TableHead>Days of Stock</TableHead>
|
||||
<TableHead>Safety Stock</TableHead>
|
||||
<TableHead>Forecast Method</TableHead>
|
||||
<TableHead>Exclude</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{settings.map(setting => (
|
||||
<TableRow key={setting.pid}>
|
||||
<TableCell>{setting.pid} {setting.product_name && <span className="text-muted-foreground text-xs block">{setting.product_name}</span>}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
className="w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={setting.lead_time_days ?? ''}
|
||||
onChange={(e) => updateSetting(setting.pid, 'lead_time_days', e.target.value ? parseInt(e.target.value) : null)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
className="w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={setting.days_of_stock ?? ''}
|
||||
onChange={(e) => updateSetting(setting.pid, 'days_of_stock', e.target.value ? parseInt(e.target.value) : null)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
className="w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={setting.safety_stock}
|
||||
onChange={(e) => updateSetting(setting.pid, 'safety_stock', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={setting.forecast_method || 'default'}
|
||||
onValueChange={(value) => updateSetting(setting.pid, 'forecast_method', value === 'default' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue placeholder="Default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="standard">Standard</SelectItem>
|
||||
<SelectItem value="seasonal">Seasonal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={setting.exclude_from_forecast}
|
||||
onCheckedChange={(checked) => updateSetting(setting.pid, 'exclude_from_forecast', checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSaveSetting(setting.pid)}
|
||||
disabled={!pendingChanges[setting.pid]}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResetToDefault(setting.pid)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
|
||||
{/* shadcn/ui Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
className={page === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{paginationItems.map((item, i) => (
|
||||
typeof item === 'number' ? (
|
||||
<PaginationItem key={i}>
|
||||
<PaginationLink
|
||||
onClick={() => setPage(item)}
|
||||
isActive={page === item}
|
||||
>
|
||||
{item}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={i}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
)
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
className={page === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import config from '../../config';
|
||||
|
||||
interface StockThreshold {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
critical_days: number;
|
||||
reorder_days: number;
|
||||
overstock_days: number;
|
||||
low_stock_threshold: number;
|
||||
min_reorder_quantity: number;
|
||||
}
|
||||
|
||||
interface SafetyStockConfig {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
coverage_days: number;
|
||||
service_level: number;
|
||||
}
|
||||
|
||||
export function StockManagement() {
|
||||
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
||||
id: 1,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
critical_days: 7,
|
||||
reorder_days: 14,
|
||||
overstock_days: 90,
|
||||
low_stock_threshold: 5,
|
||||
min_reorder_quantity: 1
|
||||
});
|
||||
|
||||
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
||||
id: 1,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
coverage_days: 14,
|
||||
service_level: 95.0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load configuration');
|
||||
}
|
||||
const data = await response.json();
|
||||
setStockThresholds(data.stockThresholds);
|
||||
setSafetyStockConfig(data.safetyStockConfig);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to load configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const handleUpdateStockThresholds = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/stock-thresholds/1`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(stockThresholds)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update stock thresholds');
|
||||
}
|
||||
|
||||
toast.success('Stock thresholds updated successfully');
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update thresholds: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSafetyStockConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/safety-stock/1`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(safetyStockConfig)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update safety stock configuration');
|
||||
}
|
||||
|
||||
toast.success('Safety stock configuration updated successfully');
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[700px] space-y-4">
|
||||
{/* Stock Thresholds Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stock Thresholds</CardTitle>
|
||||
<CardDescription>Configure stock level thresholds for inventory management</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="critical-days">Critical Days</Label>
|
||||
<Input
|
||||
id="critical-days"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={stockThresholds.critical_days}
|
||||
onChange={(e) => setStockThresholds(prev => ({
|
||||
...prev,
|
||||
critical_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reorder-days">Reorder Days</Label>
|
||||
<Input
|
||||
id="reorder-days"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={stockThresholds.reorder_days}
|
||||
onChange={(e) => setStockThresholds(prev => ({
|
||||
...prev,
|
||||
reorder_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="overstock-days">Overstock Days</Label>
|
||||
<Input
|
||||
id="overstock-days"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={stockThresholds.overstock_days}
|
||||
onChange={(e) => setStockThresholds(prev => ({
|
||||
...prev,
|
||||
overstock_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="low-stock-threshold">Low Stock Threshold</Label>
|
||||
<Input
|
||||
id="low-stock-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={stockThresholds.low_stock_threshold}
|
||||
onChange={(e) => setStockThresholds(prev => ({
|
||||
...prev,
|
||||
low_stock_threshold: parseInt(e.target.value) || 0
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="min-reorder-quantity">Minimum Reorder Quantity</Label>
|
||||
<Input
|
||||
id="min-reorder-quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={stockThresholds.min_reorder_quantity}
|
||||
onChange={(e) => setStockThresholds(prev => ({
|
||||
...prev,
|
||||
min_reorder_quantity: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleUpdateStockThresholds}>
|
||||
Update Stock Thresholds
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Safety Stock Configuration Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Safety Stock</CardTitle>
|
||||
<CardDescription>Configure safety stock parameters</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="coverage-days">Coverage Days</Label>
|
||||
<Input
|
||||
id="coverage-days"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={safetyStockConfig.coverage_days}
|
||||
onChange={(e) => setSafetyStockConfig(prev => ({
|
||||
...prev,
|
||||
coverage_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="service-level">Service Level (%)</Label>
|
||||
<Input
|
||||
id="service-level"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={safetyStockConfig.service_level}
|
||||
onChange={(e) => setSafetyStockConfig(prev => ({
|
||||
...prev,
|
||||
service_level: parseFloat(e.target.value) || 0
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleUpdateSafetyStockConfig}>
|
||||
Update Safety Stock Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
283
inventory/src/components/settings/VendorSettings.tsx
Normal file
283
inventory/src/components/settings/VendorSettings.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import config from '../../config';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search } from 'lucide-react';
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
|
||||
interface VendorSetting {
|
||||
vendor: string;
|
||||
default_lead_time_days: number | null;
|
||||
default_days_of_stock: number | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function VendorSettings() {
|
||||
const [settings, setSettings] = useState<VendorSetting[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(50);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchInputValue, setSearchInputValue] = useState('');
|
||||
const searchQuery = useDebounce(searchInputValue, 300); // 300ms debounce
|
||||
const [pendingChanges, setPendingChanges] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Use useCallback to avoid unnecessary re-renders
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${config.apiUrl}/config/vendors?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(searchQuery)}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load vendor settings');
|
||||
}
|
||||
const data = await response.json();
|
||||
setSettings(data.items);
|
||||
setTotalCount(data.total);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, searchQuery, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const updateSetting = useCallback((vendor: string, field: keyof VendorSetting, value: any) => {
|
||||
setSettings(prev => prev.map(setting =>
|
||||
setting.vendor === vendor ? { ...setting, [field]: value } : setting
|
||||
));
|
||||
setPendingChanges(prev => ({ ...prev, [vendor]: true }));
|
||||
}, []);
|
||||
|
||||
const handleSaveSetting = useCallback(async (vendor: string) => {
|
||||
try {
|
||||
const setting = settings.find(s => s.vendor === vendor);
|
||||
if (!setting) return;
|
||||
|
||||
const response = await fetch(`${config.apiUrl}/config/vendors/${encodeURIComponent(vendor)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
default_lead_time_days: setting.default_lead_time_days,
|
||||
default_days_of_stock: setting.default_days_of_stock
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to update vendor setting');
|
||||
}
|
||||
|
||||
toast.success(`Settings updated for vendor ${vendor}`);
|
||||
setPendingChanges(prev => ({ ...prev, [vendor]: false }));
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleResetToDefault = useCallback(async (vendor: string) => {
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/config/vendors/${encodeURIComponent(vendor)}/reset`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to reset vendor setting');
|
||||
}
|
||||
|
||||
toast.success(`Settings reset for vendor ${vendor}`);
|
||||
loadSettings(); // Reload settings to get defaults
|
||||
} catch (error) {
|
||||
toast.error(`Failed to reset setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}, [loadSettings]);
|
||||
|
||||
const totalPages = useMemo(() => Math.ceil(totalCount / pageSize), [totalCount, pageSize]);
|
||||
|
||||
// Generate page numbers for pagination
|
||||
const paginationItems = useMemo(() => {
|
||||
const pages = [];
|
||||
const maxVisiblePages = 5;
|
||||
|
||||
// Always include first page
|
||||
pages.push(1);
|
||||
|
||||
// Calculate range of visible pages
|
||||
let startPage = Math.max(2, page - Math.floor(maxVisiblePages / 2));
|
||||
let endPage = Math.min(totalPages - 1, startPage + maxVisiblePages - 3);
|
||||
|
||||
// Adjust if we're near the end
|
||||
if (endPage <= startPage) {
|
||||
endPage = Math.min(totalPages - 1, startPage + 1);
|
||||
}
|
||||
|
||||
// Add ellipsis after first page if needed
|
||||
if (startPage > 2) {
|
||||
pages.push('ellipsis1');
|
||||
}
|
||||
|
||||
// Add visible pages
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
// Add ellipsis before last page if needed
|
||||
if (endPage < totalPages - 1) {
|
||||
pages.push('ellipsis2');
|
||||
}
|
||||
|
||||
// Always include last page if it exists and is not already included
|
||||
if (totalPages > 1) {
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}, [page, totalPages]);
|
||||
|
||||
if (loading && settings.length === 0) {
|
||||
return <div className="py-4">Loading settings...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[900px] space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vendor-Specific Settings</CardTitle>
|
||||
<CardDescription>Configure default settings for products from specific vendors</CardDescription>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search vendors..."
|
||||
className="pl-8"
|
||||
value={searchInputValue}
|
||||
onChange={(e) => setSearchInputValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[500px] rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Default Lead Time (days)</TableHead>
|
||||
<TableHead>Default Days of Stock</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{settings.map(setting => (
|
||||
<TableRow key={setting.vendor}>
|
||||
<TableCell>{setting.vendor}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
className="w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={setting.default_lead_time_days ?? ''}
|
||||
onChange={(e) => updateSetting(setting.vendor, 'default_lead_time_days', e.target.value ? parseInt(e.target.value) : null)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
className="w-20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={setting.default_days_of_stock ?? ''}
|
||||
onChange={(e) => updateSetting(setting.vendor, 'default_days_of_stock', e.target.value ? parseInt(e.target.value) : null)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSaveSetting(setting.vendor)}
|
||||
disabled={!pendingChanges[setting.vendor]}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResetToDefault(setting.vendor)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
|
||||
{/* shadcn/ui Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
className={page === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{paginationItems.map((item, i) => (
|
||||
typeof item === 'number' ? (
|
||||
<PaginationItem key={i}>
|
||||
<PaginationLink
|
||||
onClick={() => setPage(item)}
|
||||
isActive={page === item}
|
||||
>
|
||||
{item}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={i}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
)
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
className={page === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
inventory/src/hooks/useDebounce.ts
Normal file
25
inventory/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* A hook that returns a debounced value after the specified delay
|
||||
* @param value The value to debounce
|
||||
* @param delay The delay in milliseconds
|
||||
* @returns The debounced value
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Update the debounced value after the specified delay
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Clean up the timeout on unmount or when value/delay changes
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { DataManagement } from "@/components/settings/DataManagement";
|
||||
import { StockManagement } from "@/components/settings/StockManagement";
|
||||
import { PerformanceMetrics } from "@/components/settings/PerformanceMetrics";
|
||||
import { CalculationSettings } from "@/components/settings/CalculationSettings";
|
||||
import { GlobalSettings } from "@/components/settings/GlobalSettings";
|
||||
import { ProductSettings } from "@/components/settings/ProductSettings";
|
||||
import { VendorSettings } from "@/components/settings/VendorSettings";
|
||||
import { TemplateManagement } from "@/components/settings/TemplateManagement";
|
||||
import { UserManagement } from "@/components/settings/UserManagement";
|
||||
import { PromptManagement } from "@/components/settings/PromptManagement";
|
||||
@@ -33,9 +33,9 @@ const SETTINGS_GROUPS: SettingsGroup[] = [
|
||||
id: "inventory",
|
||||
label: "Inventory Settings",
|
||||
tabs: [
|
||||
{ id: "stock-management", permission: "settings:stock_management", label: "Stock Management" },
|
||||
{ id: "performance-metrics", permission: "settings:performance_metrics", label: "Performance Metrics" },
|
||||
{ id: "calculation-settings", permission: "settings:calculation_settings", label: "Calculation Settings" },
|
||||
{ id: "global-settings", permission: "settings:global", label: "Global Settings" },
|
||||
{ id: "product-settings", permission: "settings:products", label: "Product Settings" },
|
||||
{ id: "vendor-settings", permission: "settings:vendors", label: "Vendor Settings" },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -160,48 +160,48 @@ export function Settings() {
|
||||
</Protected>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stock-management" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||
<TabsContent value="global-settings" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||
<Protected
|
||||
permission="settings:stock_management"
|
||||
permission="settings:global"
|
||||
fallback={
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
You don't have permission to access Stock Management.
|
||||
You don't have permission to access Global Settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
}
|
||||
>
|
||||
<StockManagement />
|
||||
<GlobalSettings />
|
||||
</Protected>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="performance-metrics" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||
<TabsContent value="product-settings" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||
<Protected
|
||||
permission="settings:performance_metrics"
|
||||
permission="settings:products"
|
||||
fallback={
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
You don't have permission to access Performance Metrics.
|
||||
You don't have permission to access Product Settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
}
|
||||
>
|
||||
<PerformanceMetrics />
|
||||
<ProductSettings />
|
||||
</Protected>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="calculation-settings" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||
<TabsContent value="vendor-settings" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||
<Protected
|
||||
permission="settings:calculation_settings"
|
||||
permission="settings:vendors"
|
||||
fallback={
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
You don't have permission to access Calculation Settings.
|
||||
You don't have permission to access Vendor Settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
}
|
||||
>
|
||||
<CalculationSettings />
|
||||
<VendorSettings />
|
||||
</Protected>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user