Create pages and routes for new settings tables, start improving product details
This commit is contained in:
@@ -7,6 +7,323 @@ router.use((req, res, next) => {
|
|||||||
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
|
// Get all configuration values
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
LogOut,
|
LogOut,
|
||||||
Tags,
|
Tags,
|
||||||
FileSpreadsheet,
|
Plus,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Truck,
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -40,18 +40,6 @@ const items = [
|
|||||||
url: "/products",
|
url: "/products",
|
||||||
permission: "access:products"
|
permission: "access:products"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Import",
|
|
||||||
icon: FileSpreadsheet,
|
|
||||||
url: "/import",
|
|
||||||
permission: "access:import"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Forecasting",
|
|
||||||
icon: IconCrystalBall,
|
|
||||||
url: "/forecasting",
|
|
||||||
permission: "access:forecasting"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Categories",
|
title: "Categories",
|
||||||
icon: Tags,
|
icon: Tags,
|
||||||
@@ -82,6 +70,18 @@ const items = [
|
|||||||
url: "/analytics",
|
url: "/analytics",
|
||||||
permission: "access: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() {
|
export function AppSidebar() {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { Drawer as VaulDrawer } from "vaul";
|
import { Drawer as VaulDrawer } from "vaul";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 { 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 { ProductMetric, ProductStatus } from "@/types/products";
|
||||||
import {
|
import {
|
||||||
getStatusBadge,
|
getStatusBadge,
|
||||||
@@ -14,11 +14,38 @@ import {
|
|||||||
formatPercentage,
|
formatPercentage,
|
||||||
formatDays,
|
formatDays,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatBoolean,
|
formatBoolean
|
||||||
getProductStatus
|
|
||||||
} from "@/utils/productUtils";
|
} from "@/utils/productUtils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import config from "@/config";
|
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 {
|
interface ProductDetailProps {
|
||||||
productId: number | null;
|
productId: number | null;
|
||||||
@@ -63,17 +90,77 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
transformed.pid = typeof transformed.pid === 'string' ?
|
transformed.pid = typeof transformed.pid === 'string' ?
|
||||||
parseInt(transformed.pid, 10) : transformed.pid;
|
parseInt(transformed.pid, 10) : transformed.pid;
|
||||||
|
|
||||||
// Make sure we have a status
|
console.log("Transformed product data:", transformed);
|
||||||
if (!transformed.status) {
|
|
||||||
transformed.status = getProductStatus(transformed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return transformed;
|
return transformed;
|
||||||
},
|
},
|
||||||
enabled: !!productId, // Only run query when productId is truthy
|
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
|
if (!productId) return null; // Don't render anything if no ID
|
||||||
|
|
||||||
@@ -130,10 +217,11 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : product ? (
|
) : product ? (
|
||||||
<Tabs defaultValue="overview" className="p-4">
|
<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="overview">Overview</TabsTrigger>
|
||||||
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
||||||
<TabsTrigger value="performance">Performance</TabsTrigger>
|
<TabsTrigger value="performance">Performance</TabsTrigger>
|
||||||
|
<TabsTrigger value="orders">Orders</TabsTrigger>
|
||||||
<TabsTrigger value="details">Details</TabsTrigger>
|
<TabsTrigger value="details">Details</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -191,6 +279,68 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="performance" className="space-y-4">
|
<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>
|
<Card>
|
||||||
<CardHeader><CardTitle className="text-base">Sales Performance (30 Days)</CardTitle></CardHeader>
|
<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">
|
<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)} />
|
<InfoItem label="Daily Velocity" value={formatNumber(product.salesVelocityDaily, 2)} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader><CardTitle className="text-base">Inventory Performance (30 Days)</CardTitle></CardHeader>
|
<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">
|
<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)} />
|
<InfoItem label="Avg Lead Time" value={formatDays(product.avgLeadTimeDays, 1)} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
|
||||||
<TabsContent value="details" className="space-y-4">
|
<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">
|
<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="Lead Time" value={formatDays(product.configLeadTime)} />
|
||||||
<InfoItem label="Days of Stock" value={formatDays(product.configDaysOfStock)} />
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { DataManagement } from "@/components/settings/DataManagement";
|
import { DataManagement } from "@/components/settings/DataManagement";
|
||||||
import { StockManagement } from "@/components/settings/StockManagement";
|
import { GlobalSettings } from "@/components/settings/GlobalSettings";
|
||||||
import { PerformanceMetrics } from "@/components/settings/PerformanceMetrics";
|
import { ProductSettings } from "@/components/settings/ProductSettings";
|
||||||
import { CalculationSettings } from "@/components/settings/CalculationSettings";
|
import { VendorSettings } from "@/components/settings/VendorSettings";
|
||||||
import { TemplateManagement } from "@/components/settings/TemplateManagement";
|
import { TemplateManagement } from "@/components/settings/TemplateManagement";
|
||||||
import { UserManagement } from "@/components/settings/UserManagement";
|
import { UserManagement } from "@/components/settings/UserManagement";
|
||||||
import { PromptManagement } from "@/components/settings/PromptManagement";
|
import { PromptManagement } from "@/components/settings/PromptManagement";
|
||||||
@@ -33,9 +33,9 @@ const SETTINGS_GROUPS: SettingsGroup[] = [
|
|||||||
id: "inventory",
|
id: "inventory",
|
||||||
label: "Inventory Settings",
|
label: "Inventory Settings",
|
||||||
tabs: [
|
tabs: [
|
||||||
{ id: "stock-management", permission: "settings:stock_management", label: "Stock Management" },
|
{ id: "global-settings", permission: "settings:global", label: "Global Settings" },
|
||||||
{ id: "performance-metrics", permission: "settings:performance_metrics", label: "Performance Metrics" },
|
{ id: "product-settings", permission: "settings:products", label: "Product Settings" },
|
||||||
{ id: "calculation-settings", permission: "settings:calculation_settings", label: "Calculation Settings" },
|
{ id: "vendor-settings", permission: "settings:vendors", label: "Vendor Settings" },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -160,48 +160,48 @@ export function Settings() {
|
|||||||
</Protected>
|
</Protected>
|
||||||
</TabsContent>
|
</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
|
<Protected
|
||||||
permission="settings:stock_management"
|
permission="settings:global"
|
||||||
fallback={
|
fallback={
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You don't have permission to access Stock Management.
|
You don't have permission to access Global Settings.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StockManagement />
|
<GlobalSettings />
|
||||||
</Protected>
|
</Protected>
|
||||||
</TabsContent>
|
</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
|
<Protected
|
||||||
permission="settings:performance_metrics"
|
permission="settings:products"
|
||||||
fallback={
|
fallback={
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You don't have permission to access Performance Metrics.
|
You don't have permission to access Product Settings.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PerformanceMetrics />
|
<ProductSettings />
|
||||||
</Protected>
|
</Protected>
|
||||||
</TabsContent>
|
</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
|
<Protected
|
||||||
permission="settings:calculation_settings"
|
permission="settings:vendors"
|
||||||
fallback={
|
fallback={
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You don't have permission to access Calculation Settings.
|
You don't have permission to access Vendor Settings.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CalculationSettings />
|
<VendorSettings />
|
||||||
</Protected>
|
</Protected>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user