diff --git a/inventory-server/src/routes/config.js b/inventory-server/src/routes/config.js index 7e730c7..e17fa83 100644 --- a/inventory-server/src/routes/config.js +++ b/inventory-server/src/routes/config.js @@ -7,6 +7,323 @@ router.use((req, res, next) => { next(); }); +// ===== GLOBAL SETTINGS ===== + +// Get all global settings +router.get('/global', async (req, res) => { + const pool = req.app.locals.pool; + try { + console.log('[Config Route] Fetching global settings...'); + + const { rows } = await pool.query('SELECT * FROM settings_global ORDER BY setting_key'); + + console.log('[Config Route] Sending global settings:', rows); + res.json(rows); + } catch (error) { + console.error('[Config Route] Error fetching global settings:', error); + res.status(500).json({ error: 'Failed to fetch global settings', details: error.message }); + } +}); + +// Update global settings +router.put('/global', async (req, res) => { + const pool = req.app.locals.pool; + try { + console.log('[Config Route] Updating global settings:', req.body); + + // Validate request + if (!Array.isArray(req.body)) { + return res.status(400).json({ error: 'Request body must be an array of settings' }); + } + + // Begin transaction + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const setting of req.body) { + if (!setting.setting_key || !setting.setting_value) { + throw new Error('Each setting must have a key and value'); + } + + await client.query( + `UPDATE settings_global + SET setting_value = $1, + updated_at = CURRENT_TIMESTAMP + WHERE setting_key = $2`, + [setting.setting_value, setting.setting_key] + ); + } + + await client.query('COMMIT'); + res.json({ success: true }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } catch (error) { + console.error('[Config Route] Error updating global settings:', error); + res.status(500).json({ error: 'Failed to update global settings', details: error.message }); + } +}); + +// ===== PRODUCT SETTINGS ===== + +// Get product settings with pagination and search +router.get('/products', async (req, res) => { + const pool = req.app.locals.pool; + try { + console.log('[Config Route] Fetching product settings...'); + + const page = parseInt(req.query.page) || 1; + const pageSize = parseInt(req.query.pageSize) || 10; + const offset = (page - 1) * pageSize; + const search = req.query.search || ''; + + // Get total count for pagination + const countQuery = search + ? `SELECT COUNT(*) FROM settings_product sp + JOIN products p ON sp.pid::text = p.pid::text + WHERE sp.pid::text ILIKE $1 OR p.title ILIKE $1` + : 'SELECT COUNT(*) FROM settings_product'; + + const countParams = search ? [`%${search}%`] : []; + const { rows: countResult } = await pool.query(countQuery, countParams); + const total = parseInt(countResult[0].count); + + // Get paginated settings + const query = search + ? `SELECT sp.*, p.title as product_name + FROM settings_product sp + JOIN products p ON sp.pid::text = p.pid::text + WHERE sp.pid::text ILIKE $1 OR p.title ILIKE $1 + ORDER BY sp.pid + LIMIT $2 OFFSET $3` + : `SELECT sp.*, p.title as product_name + FROM settings_product sp + JOIN products p ON sp.pid::text = p.pid::text + ORDER BY sp.pid + LIMIT $1 OFFSET $2`; + + const queryParams = search + ? [`%${search}%`, pageSize, offset] + : [pageSize, offset]; + + const { rows } = await pool.query(query, queryParams); + + const response = { + items: rows, + total, + page, + pageSize + }; + + console.log(`[Config Route] Sending ${rows.length} product settings`); + res.json(response); + } catch (error) { + console.error('[Config Route] Error fetching product settings:', error); + res.status(500).json({ error: 'Failed to fetch product settings', details: error.message }); + } +}); + +// Update product settings +router.put('/products/:pid', async (req, res) => { + const pool = req.app.locals.pool; + try { + const { pid } = req.params; + const { lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast } = req.body; + + console.log(`[Config Route] Updating product settings for ${pid}:`, req.body); + + // Check if product exists + const { rows: checkProduct } = await pool.query( + 'SELECT 1 FROM settings_product WHERE pid::text = $1', + [pid] + ); + + if (checkProduct.length === 0) { + // Insert if it doesn't exist + await pool.query( + `INSERT INTO settings_product + (pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast) + VALUES ($1, $2, $3, $4, $5, $6)`, + [pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast] + ); + } else { + // Update if it exists + await pool.query( + `UPDATE settings_product + SET lead_time_days = $2, + days_of_stock = $3, + safety_stock = $4, + forecast_method = $5, + exclude_from_forecast = $6, + updated_at = CURRENT_TIMESTAMP + WHERE pid::text = $1`, + [pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast] + ); + } + + res.json({ success: true }); + } catch (error) { + console.error(`[Config Route] Error updating product settings for ${req.params.pid}:`, error); + res.status(500).json({ error: 'Failed to update product settings', details: error.message }); + } +}); + +// Reset product settings to defaults +router.post('/products/:pid/reset', async (req, res) => { + const pool = req.app.locals.pool; + try { + const { pid } = req.params; + + console.log(`[Config Route] Resetting product settings for ${pid}`); + + // Reset by setting everything to null/default + await pool.query( + `UPDATE settings_product + SET lead_time_days = NULL, + days_of_stock = NULL, + safety_stock = 0, + forecast_method = NULL, + exclude_from_forecast = false, + updated_at = CURRENT_TIMESTAMP + WHERE pid::text = $1`, + [pid] + ); + + res.json({ success: true }); + } catch (error) { + console.error(`[Config Route] Error resetting product settings for ${req.params.pid}:`, error); + res.status(500).json({ error: 'Failed to reset product settings', details: error.message }); + } +}); + +// ===== VENDOR SETTINGS ===== + +// Get vendor settings with pagination and search +router.get('/vendors', async (req, res) => { + const pool = req.app.locals.pool; + try { + console.log('[Config Route] Fetching vendor settings...'); + + const page = parseInt(req.query.page) || 1; + const pageSize = parseInt(req.query.pageSize) || 10; + const offset = (page - 1) * pageSize; + const search = req.query.search || ''; + + // Get total count for pagination + const countQuery = search + ? 'SELECT COUNT(*) FROM settings_vendor WHERE vendor ILIKE $1' + : 'SELECT COUNT(*) FROM settings_vendor'; + + const countParams = search ? [`%${search}%`] : []; + const { rows: countResult } = await pool.query(countQuery, countParams); + const total = parseInt(countResult[0].count); + + // Get paginated settings + const query = search + ? `SELECT * FROM settings_vendor + WHERE vendor ILIKE $1 + ORDER BY vendor + LIMIT $2 OFFSET $3` + : `SELECT * FROM settings_vendor + ORDER BY vendor + LIMIT $1 OFFSET $2`; + + const queryParams = search + ? [`%${search}%`, pageSize, offset] + : [pageSize, offset]; + + const { rows } = await pool.query(query, queryParams); + + const response = { + items: rows, + total, + page, + pageSize + }; + + console.log(`[Config Route] Sending ${rows.length} vendor settings`); + res.json(response); + } catch (error) { + console.error('[Config Route] Error fetching vendor settings:', error); + res.status(500).json({ error: 'Failed to fetch vendor settings', details: error.message }); + } +}); + +// Update vendor settings +router.put('/vendors/:vendor', async (req, res) => { + const pool = req.app.locals.pool; + try { + const vendor = req.params.vendor; + const { default_lead_time_days, default_days_of_stock } = req.body; + + console.log(`[Config Route] Updating vendor settings for ${vendor}:`, req.body); + + // Check if vendor exists + const { rows: checkVendor } = await pool.query( + 'SELECT 1 FROM settings_vendor WHERE vendor = $1', + [vendor] + ); + + if (checkVendor.length === 0) { + // Insert if it doesn't exist + await pool.query( + `INSERT INTO settings_vendor + (vendor, default_lead_time_days, default_days_of_stock) + VALUES ($1, $2, $3)`, + [vendor, default_lead_time_days, default_days_of_stock] + ); + } else { + // Update if it exists + await pool.query( + `UPDATE settings_vendor + SET default_lead_time_days = $2, + default_days_of_stock = $3, + updated_at = CURRENT_TIMESTAMP + WHERE vendor = $1`, + [vendor, default_lead_time_days, default_days_of_stock] + ); + } + + res.json({ success: true }); + } catch (error) { + console.error(`[Config Route] Error updating vendor settings for ${req.params.vendor}:`, error); + res.status(500).json({ error: 'Failed to update vendor settings', details: error.message }); + } +}); + +// Reset vendor settings to defaults +router.post('/vendors/:vendor/reset', async (req, res) => { + const pool = req.app.locals.pool; + try { + const vendor = req.params.vendor; + + console.log(`[Config Route] Resetting vendor settings for ${vendor}`); + + // Reset by setting everything to null + await pool.query( + `UPDATE settings_vendor + SET default_lead_time_days = NULL, + default_days_of_stock = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE vendor = $1`, + [vendor] + ); + + res.json({ success: true }); + } catch (error) { + console.error(`[Config Route] Error resetting vendor settings for ${req.params.vendor}:`, error); + res.status(500).json({ error: 'Failed to reset vendor settings', details: error.message }); + } +}); + +// ===== LEGACY ENDPOINTS ===== +// These are kept for backward compatibility but will be removed in future versions + // Get all configuration values router.get('/', async (req, res) => { const pool = req.app.locals.pool; diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index e8f4186..10e7c15 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -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() { diff --git a/inventory/src/components/products/ProductDetail.tsx b/inventory/src/components/products/ProductDetail.tsx index e298612..bdb8d4a 100644 --- a/inventory/src/components/products/ProductDetail.tsx +++ b/inventory/src/components/products/ProductDetail.tsx @@ -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({ + 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) { ) : product ? ( - + Overview Inventory Performance + Orders Details @@ -191,6 +279,68 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) { + {/* Sales Performance Chart */} + + + Sales & Revenue Trend + Monthly performance over time + + + {isLoadingTimeSeries ? ( +
+ +
+ ) : timeSeriesData && timeSeriesData.monthlySales && timeSeriesData.monthlySales.length > 0 ? ( + + + + + + + { + if (name === 'revenue' || name === 'profit') { + return [formatCurrency(value), name]; + } + return [value, name]; + }} + /> + + + + + + + ) : ( +
+

No sales data available for this product.

+
+ )} +
+
+ Sales Performance (30 Days) @@ -203,6 +353,60 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) { + + {/* Inventory KPIs Chart */} + + + Key Inventory Metrics + + + {isLoading ? ( + + ) : ( + + + + + + { + if (name === 'Sell Through %' || name === 'Margin %') { + return [`${value.toFixed(1)}%`, name]; + } + return [value.toFixed(2), name]; + }} + /> + + + + )} + + + Inventory Performance (30 Days) @@ -222,6 +426,143 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) { + + Additional Metrics + + + + + + + + + + +
+ + + + + Purchase Orders & Receivings + Recent purchase orders for this product + + + {isLoadingTimeSeries ? ( +
+ + + +
+ ) : timeSeriesData?.recentPurchases && timeSeriesData.recentPurchases.length > 0 ? ( +
+ + + + PO # + Date + Status + Qty + Received + Cost + + + + {timeSeriesData.recentPurchases.map((po) => ( + + {po.poId} + {po.date} + + + {getPOStatusName(po.status)} + + + {po.ordered} + {po.received} + {formatCurrency(po.costPrice)} + + ))} + +
+
+ ) : ( +
+

No purchase orders found for this product.

+
+ )} +
+
+ + + + Order Summary + + + acc + po.ordered, 0) + ) : 'N/A' + } + /> + acc + po.received, 0) + ) : 'N/A' + } + /> + { + 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' + } + /> + acc + (po.ordered * po.costPrice), 0) + ) : 'N/A' + } + /> + { + 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' + } + /> + po.status < 50 && po.receivingStatus < 40 + ).length + ) : 'N/A' + } + /> + +
@@ -242,6 +583,17 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) { + + + + + Forecasting + + + + + + diff --git a/inventory/src/components/settings/CalculationSettings.tsx b/inventory/src/components/settings/CalculationSettings.tsx deleted file mode 100644 index 24d83fd..0000000 --- a/inventory/src/components/settings/CalculationSettings.tsx +++ /dev/null @@ -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({ - 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 ( -
- {/* Sales Velocity Configuration Card */} - - - Sales Velocity Windows - Configure time windows for sales velocity calculations - - -
-
-
- - setSalesVelocityConfig(prev => ({ - ...prev, - daily_window_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setSalesVelocityConfig(prev => ({ - ...prev, - weekly_window_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setSalesVelocityConfig(prev => ({ - ...prev, - monthly_window_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- -
-
-
-
- ); -} \ No newline at end of file diff --git a/inventory/src/components/settings/Configuration.tsx b/inventory/src/components/settings/Configuration.tsx deleted file mode 100644 index 88094a1..0000000 --- a/inventory/src/components/settings/Configuration.tsx +++ /dev/null @@ -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({ - 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({ - id: 1, - cat_id: null, - vendor: null, - target_days: 14, - warning_days: 21, - critical_days: 30 - }); - - const [salesVelocityConfig, setSalesVelocityConfig] = useState({ - id: 1, - cat_id: null, - vendor: null, - daily_window_days: 30, - weekly_window_days: 7, - monthly_window_days: 90 - }); - - const [abcConfig, setAbcConfig] = useState({ - id: 1, - a_threshold: 20.0, - b_threshold: 50.0, - classification_period_days: 90 - }); - - const [safetyStockConfig, setSafetyStockConfig] = useState({ - id: 1, - cat_id: null, - vendor: null, - coverage_days: 14, - service_level: 95.0 - }); - - const [turnoverConfig, setTurnoverConfig] = useState({ - 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 ( - - - Stock Management - Performance Metrics - Calculation Settings - - - - {/* Stock Thresholds Card */} - - - Stock Thresholds - Configure stock level thresholds for inventory management - - -
-
-
- - setStockThresholds(prev => ({ - ...prev, - critical_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setStockThresholds(prev => ({ - ...prev, - reorder_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setStockThresholds(prev => ({ - ...prev, - overstock_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setStockThresholds(prev => ({ - ...prev, - low_stock_threshold: parseInt(e.target.value) || 0 - }))} - /> -
-
- - setStockThresholds(prev => ({ - ...prev, - min_reorder_quantity: parseInt(e.target.value) || 1 - }))} - /> -
-
- -
-
-
- - {/* Safety Stock Configuration Card */} - - - Safety Stock - Configure safety stock parameters - - -
-
-
- - setSafetyStockConfig(prev => ({ - ...prev, - coverage_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setSafetyStockConfig(prev => ({ - ...prev, - service_level: parseFloat(e.target.value) || 0 - }))} - /> -
-
- -
-
-
-
- - - {/* Lead Time Thresholds Card */} - - - Lead Time Thresholds - Configure lead time thresholds for vendor performance - - -
-
-
- - setLeadTimeThresholds(prev => ({ - ...prev, - target_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setLeadTimeThresholds(prev => ({ - ...prev, - warning_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setLeadTimeThresholds(prev => ({ - ...prev, - critical_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- -
-
-
- - {/* ABC Classification Card */} - - - ABC Classification - Configure ABC classification parameters - - -
-
-
- - setAbcConfig(prev => ({ - ...prev, - a_threshold: parseFloat(e.target.value) || 0 - }))} - /> -
-
- - setAbcConfig(prev => ({ - ...prev, - b_threshold: parseFloat(e.target.value) || 0 - }))} - /> -
-
- - setAbcConfig(prev => ({ - ...prev, - classification_period_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- -
-
-
- - {/* Turnover Configuration Card */} - - - Turnover Rate - Configure turnover rate calculations - - -
-
-
- - setTurnoverConfig(prev => ({ - ...prev, - calculation_period_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setTurnoverConfig(prev => ({ - ...prev, - target_rate: parseFloat(e.target.value) || 0 - }))} - /> -
-
- -
-
-
-
- - - {/* Sales Velocity Configuration Card */} - - - Sales Velocity Windows - Configure time windows for sales velocity calculations - - -
-
-
- - setSalesVelocityConfig(prev => ({ - ...prev, - daily_window_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setSalesVelocityConfig(prev => ({ - ...prev, - weekly_window_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setSalesVelocityConfig(prev => ({ - ...prev, - monthly_window_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- -
-
-
-
-
- ); -} \ No newline at end of file diff --git a/inventory/src/components/settings/GlobalSettings.tsx b/inventory/src/components/settings/GlobalSettings.tsx new file mode 100644 index 0000000..255a326 --- /dev/null +++ b/inventory/src/components/settings/GlobalSettings.tsx @@ -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([]); + 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 ( + + ); + } else if (setting.setting_key === 'default_forecast_method') { + return ( + + ); + } else if (setting.setting_key.includes('threshold')) { + // Percentage inputs + return ( + updateSetting(setting.setting_key, e.target.value)} + /> + ); + } else { + // Default to number input for other settings + return ( + 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
Loading settings...
; + } + + return ( +
+ + + ABC Classification Settings + Configure how products are classified into A, B, and C categories + + +
+ {abcSettings.map(setting => ( +
+ + {renderSettingInput(setting)} +
+ ))} +
+
+
+ + + + Default Settings + Configure system-wide default values + + +
+ {defaultSettings.map(setting => ( +
+ + {renderSettingInput(setting)} +
+ ))} +
+
+
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/settings/PerformanceMetrics.tsx b/inventory/src/components/settings/PerformanceMetrics.tsx deleted file mode 100644 index 1573aa8..0000000 --- a/inventory/src/components/settings/PerformanceMetrics.tsx +++ /dev/null @@ -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({ - id: 1, - cat_id: null, - vendor: null, - target_days: 14, - warning_days: 21, - critical_days: 30 - }); - - const [abcConfigs, setAbcConfigs] = useState([]); - - const [turnoverConfigs, setTurnoverConfigs] = useState([]); - - 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 ( -
- {/* Lead Time Thresholds Card */} - - - Lead Time Thresholds - Configure lead time thresholds for vendor performance - - -
-
-
- - setLeadTimeThresholds(prev => ({ - ...prev, - target_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setLeadTimeThresholds(prev => ({ - ...prev, - warning_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setLeadTimeThresholds(prev => ({ - ...prev, - critical_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- -
-
-
- - {/* ABC Classification Card */} - - - ABC Classification - Configure ABC classification parameters - - -
- - - - Category - Vendor - A Threshold - B Threshold - Period Days - - - - {abcConfigs && abcConfigs.length > 0 ? abcConfigs.map((config) => ( - - {config.cat_id ? getCategoryName(config.cat_id) : 'Global'} - {config.vendor || 'All Vendors'} - {config.a_threshold !== undefined ? `${config.a_threshold}%` : '0%'} - {config.b_threshold !== undefined ? `${config.b_threshold}%` : '0%'} - {config.classification_period_days || 0} - - )) : ( - - No ABC configurations available - - )} - -
- -
-
-
- - {/* Turnover Configuration Card */} - - - Turnover Rate - Configure turnover rate calculations - - -
- - - - Category - Vendor - Period Days - Target Rate - - - - {turnoverConfigs && turnoverConfigs.length > 0 ? turnoverConfigs.map((config) => ( - - {config.cat_id ? getCategoryName(config.cat_id) : 'Global'} - {config.vendor || 'All Vendors'} - {config.calculation_period_days} - - {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'} - - - )) : ( - - No turnover configurations available - - )} - -
- -
-
-
-
- ); -} \ No newline at end of file diff --git a/inventory/src/components/settings/ProductSettings.tsx b/inventory/src/components/settings/ProductSettings.tsx new file mode 100644 index 0000000..b108bc4 --- /dev/null +++ b/inventory/src/components/settings/ProductSettings.tsx @@ -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([]); + 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>({}); + + // 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
Loading settings...
; + } + + return ( +
+ + + Product-Specific Settings + Configure settings for individual products that override global defaults + +
+ + setSearchQuery(e.target.value)} + /> +
+
+ + + + + + Product ID + Lead Time (days) + Days of Stock + Safety Stock + Forecast Method + Exclude + Actions + + + + {settings.map(setting => ( + + {setting.pid} {setting.product_name && {setting.product_name}} + + updateSetting(setting.pid, 'lead_time_days', e.target.value ? parseInt(e.target.value) : null)} + /> + + + updateSetting(setting.pid, 'days_of_stock', e.target.value ? parseInt(e.target.value) : null)} + /> + + + updateSetting(setting.pid, 'safety_stock', parseInt(e.target.value) || 0)} + /> + + + + + + updateSetting(setting.pid, 'exclude_from_forecast', checked)} + /> + + +
+ + +
+
+
+ ))} +
+
+
+ + {/* shadcn/ui Pagination */} + {totalPages > 1 && ( +
+ + + + setPage(p => Math.max(1, p - 1))} + className={page === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + {paginationItems.map((item, i) => ( + typeof item === 'number' ? ( + + setPage(item)} + isActive={page === item} + > + {item} + + + ) : ( + + + + ) + ))} + + + setPage(p => Math.min(totalPages, p + 1))} + className={page === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/settings/StockManagement.tsx b/inventory/src/components/settings/StockManagement.tsx deleted file mode 100644 index a716fd9..0000000 --- a/inventory/src/components/settings/StockManagement.tsx +++ /dev/null @@ -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({ - 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({ - 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 ( -
- {/* Stock Thresholds Card */} - - - Stock Thresholds - Configure stock level thresholds for inventory management - - -
-
-
- - setStockThresholds(prev => ({ - ...prev, - critical_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setStockThresholds(prev => ({ - ...prev, - reorder_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setStockThresholds(prev => ({ - ...prev, - overstock_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setStockThresholds(prev => ({ - ...prev, - low_stock_threshold: parseInt(e.target.value) || 0 - }))} - /> -
-
- - setStockThresholds(prev => ({ - ...prev, - min_reorder_quantity: parseInt(e.target.value) || 1 - }))} - /> -
-
- -
-
-
- - {/* Safety Stock Configuration Card */} - - - Safety Stock - Configure safety stock parameters - - -
-
-
- - setSafetyStockConfig(prev => ({ - ...prev, - coverage_days: parseInt(e.target.value) || 1 - }))} - /> -
-
- - setSafetyStockConfig(prev => ({ - ...prev, - service_level: parseFloat(e.target.value) || 0 - }))} - /> -
-
- -
-
-
-
- ); -} \ No newline at end of file diff --git a/inventory/src/components/settings/VendorSettings.tsx b/inventory/src/components/settings/VendorSettings.tsx new file mode 100644 index 0000000..66d955a --- /dev/null +++ b/inventory/src/components/settings/VendorSettings.tsx @@ -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([]); + 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>({}); + + // 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
Loading settings...
; + } + + return ( +
+ + + Vendor-Specific Settings + Configure default settings for products from specific vendors + +
+ + setSearchInputValue(e.target.value)} + /> +
+
+ + + + + + Vendor + Default Lead Time (days) + Default Days of Stock + Actions + + + + {settings.map(setting => ( + + {setting.vendor} + + updateSetting(setting.vendor, 'default_lead_time_days', e.target.value ? parseInt(e.target.value) : null)} + /> + + + updateSetting(setting.vendor, 'default_days_of_stock', e.target.value ? parseInt(e.target.value) : null)} + /> + + +
+ + +
+
+
+ ))} +
+
+
+ + {/* shadcn/ui Pagination */} + {totalPages > 1 && ( +
+ + + + setPage(p => Math.max(1, p - 1))} + className={page === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + {paginationItems.map((item, i) => ( + typeof item === 'number' ? ( + + setPage(item)} + isActive={page === item} + > + {item} + + + ) : ( + + + + ) + ))} + + + setPage(p => Math.min(totalPages, p + 1))} + className={page === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/hooks/useDebounce.ts b/inventory/src/hooks/useDebounce.ts new file mode 100644 index 0000000..bdb8a79 --- /dev/null +++ b/inventory/src/hooks/useDebounce.ts @@ -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(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(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; +} \ No newline at end of file diff --git a/inventory/src/pages/Settings.tsx b/inventory/src/pages/Settings.tsx index cfb5cf1..e275130 100644 --- a/inventory/src/pages/Settings.tsx +++ b/inventory/src/pages/Settings.tsx @@ -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() { - + - You don't have permission to access Stock Management. + You don't have permission to access Global Settings. } > - + - + - You don't have permission to access Performance Metrics. + You don't have permission to access Product Settings. } > - + - + - You don't have permission to access Calculation Settings. + You don't have permission to access Vendor Settings. } > - +