Update frontend to match part 1
This commit is contained in:
270
docs/schema-update-changes.md
Normal file
270
docs/schema-update-changes.md
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
# Schema Update Changes Required
|
||||||
|
|
||||||
|
## Core Field Name Changes
|
||||||
|
|
||||||
|
### Global Changes
|
||||||
|
- Update all references from `product_id` to `pid` in all tables and queries
|
||||||
|
- This includes foreign key references in related tables
|
||||||
|
- Update TypeScript interfaces and types (e.g., `interface Product { pid: number; ... }`)
|
||||||
|
- Update API request/response types
|
||||||
|
- Update all references from `category_id` to `cat_id` in category-related queries
|
||||||
|
- This affects the `categories` table and all tables with category foreign keys
|
||||||
|
- Update purchase order status to use numeric codes instead of strings
|
||||||
|
- Status codes: 0=canceled, 1=created, 10=electronically_ready_send, 11=ordered, 12=preordered, 13=electronically_sent, 15=receiving_started, 50=done
|
||||||
|
- Receiving status codes: 0=canceled, 1=created, 30=partial_received, 40=full_received, 50=paid
|
||||||
|
- Handle NULL brand values as 'Unbranded'
|
||||||
|
- Add COALESCE(brand, 'Unbranded') in all brand-related queries
|
||||||
|
- Update frontend brand filters to handle 'Unbranded' as a valid value
|
||||||
|
|
||||||
|
## Backend Route Changes
|
||||||
|
|
||||||
|
### Product Routes
|
||||||
|
1. Update ID field references in all product routes:
|
||||||
|
- `/api/products/:id` -> `/api/products/:pid`
|
||||||
|
- All query parameters using `product_id` should be changed to `pid`
|
||||||
|
- Update all SQL queries to use `pid` instead of `product_id`
|
||||||
|
- Update `/api/products/:id/metrics` -> `/api/products/:pid/metrics`
|
||||||
|
- Update `/api/products/:id/time-series` -> `/api/products/:pid/time-series`
|
||||||
|
- Update request parameter validation in routes
|
||||||
|
- Example query change:
|
||||||
|
```sql
|
||||||
|
-- Old
|
||||||
|
SELECT * FROM products WHERE product_id = ?
|
||||||
|
-- New
|
||||||
|
SELECT * FROM products WHERE pid = ?
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update purchase order status checks:
|
||||||
|
- Change `status = 'closed'` to `receiving_status >= 30` in all relevant queries
|
||||||
|
- Update any route logic that checks PO status to use the new numeric status codes
|
||||||
|
- Example status check change:
|
||||||
|
```sql
|
||||||
|
-- Old
|
||||||
|
WHERE po.status = 'closed'
|
||||||
|
-- New
|
||||||
|
WHERE po.receiving_status >= 30 -- Partial or fully received
|
||||||
|
```
|
||||||
|
|
||||||
|
### Category Routes
|
||||||
|
1. Update ID references:
|
||||||
|
- `/api/categories/:id` -> `/api/categories/:cat_id`
|
||||||
|
- Update all SQL queries to use `cat_id` instead of `category_id`
|
||||||
|
- Update join conditions in category-related queries
|
||||||
|
- Example join change:
|
||||||
|
```sql
|
||||||
|
-- Old
|
||||||
|
JOIN categories c ON p.category_id = c.category_id
|
||||||
|
-- New
|
||||||
|
JOIN categories c ON p.cat_id = c.cat_id
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update category metrics queries:
|
||||||
|
- Modify field size handling for financial fields (DECIMAL(15,3) instead of DECIMAL(10,3))
|
||||||
|
- Update category performance calculations to use new field sizes
|
||||||
|
- Example field size change:
|
||||||
|
```sql
|
||||||
|
-- Old
|
||||||
|
total_value DECIMAL(10,3)
|
||||||
|
-- New
|
||||||
|
total_value DECIMAL(15,3)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vendor Routes
|
||||||
|
1. Update product references:
|
||||||
|
- Change all queries to use `pid` instead of `product_id`
|
||||||
|
- Update purchase order status checks to use new numeric codes
|
||||||
|
- Example vendor query change:
|
||||||
|
```sql
|
||||||
|
-- Old
|
||||||
|
SELECT v.*, p.product_id FROM vendors v
|
||||||
|
JOIN products p ON p.vendor = v.name
|
||||||
|
WHERE p.product_id = ?
|
||||||
|
-- New
|
||||||
|
SELECT v.*, p.pid FROM vendors v
|
||||||
|
JOIN products p ON p.vendor = v.name
|
||||||
|
WHERE p.pid = ?
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update vendor metrics queries:
|
||||||
|
- Add COALESCE for NULL brand handling:
|
||||||
|
```sql
|
||||||
|
-- Old
|
||||||
|
GROUP BY brand
|
||||||
|
-- New
|
||||||
|
GROUP BY COALESCE(brand, 'Unbranded')
|
||||||
|
```
|
||||||
|
- Update field references in performance metrics calculations
|
||||||
|
|
||||||
|
### Dashboard Routes
|
||||||
|
1. Update all dashboard endpoints:
|
||||||
|
- `/dashboard/best-sellers`:
|
||||||
|
```typescript
|
||||||
|
interface BestSellerProduct {
|
||||||
|
pid: number; // Changed from product_id
|
||||||
|
sku: string;
|
||||||
|
title: string;
|
||||||
|
units_sold: number;
|
||||||
|
revenue: number; // Now handles larger decimals
|
||||||
|
profit: number; // Now handles larger decimals
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `/dashboard/overstock/products`:
|
||||||
|
```typescript
|
||||||
|
interface OverstockedProduct {
|
||||||
|
pid: number; // Changed from product_id
|
||||||
|
sku: string;
|
||||||
|
title: string;
|
||||||
|
stock_quantity: number;
|
||||||
|
overstocked_amt: number;
|
||||||
|
excess_cost: number; // Now DECIMAL(15,3)
|
||||||
|
excess_retail: number; // Now DECIMAL(15,3)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analytics Routes
|
||||||
|
1. Update analytics endpoints:
|
||||||
|
- `/analytics/stats` - Update all ID references and decimal handling
|
||||||
|
- `/analytics/profit` - Update decimal precision in calculations
|
||||||
|
- `/analytics/vendors` - Add brand NULL handling
|
||||||
|
- Example analytics query change:
|
||||||
|
```sql
|
||||||
|
-- Old
|
||||||
|
SELECT product_id, SUM(price * quantity) as revenue
|
||||||
|
FROM orders
|
||||||
|
GROUP BY product_id
|
||||||
|
-- New
|
||||||
|
SELECT pid, CAST(SUM(price * quantity) AS DECIMAL(15,3)) as revenue
|
||||||
|
FROM orders
|
||||||
|
GROUP BY pid
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Component Changes
|
||||||
|
|
||||||
|
### Product Components
|
||||||
|
1. Update API calls:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
fetch(`/api/products/${product_id}`)
|
||||||
|
// New
|
||||||
|
fetch(`/api/products/${pid}`)
|
||||||
|
```
|
||||||
|
- Update route parameters in React Router:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
<Route path="/products/:product_id" />
|
||||||
|
// New
|
||||||
|
<Route path="/products/:pid" />
|
||||||
|
```
|
||||||
|
- Update useParams usage:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
const { product_id } = useParams();
|
||||||
|
// New
|
||||||
|
const { pid } = useParams();
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update data display:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
<td>{formatCurrency(product.price)}</td>
|
||||||
|
// New
|
||||||
|
<td>{formatCurrency(Number(product.price))}</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dashboard Components
|
||||||
|
1. Update metrics displays:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
interface ProductMetrics {
|
||||||
|
product_id: number;
|
||||||
|
total_value: number;
|
||||||
|
}
|
||||||
|
// New
|
||||||
|
interface ProductMetrics {
|
||||||
|
pid: number;
|
||||||
|
total_value: string; // Handle as string due to DECIMAL(15,3)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update stock status displays:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
const isReceived = po.status === 'closed';
|
||||||
|
// New
|
||||||
|
const isReceived = po.receiving_status >= 30;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Product Filters Component
|
||||||
|
1. Update filter options:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
const productFilter = { id: 'product_id', value: id };
|
||||||
|
// New
|
||||||
|
const productFilter = { id: 'pid', value: id };
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update status filters:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
const poStatusOptions = [
|
||||||
|
{ label: 'Closed', value: 'closed' }
|
||||||
|
];
|
||||||
|
// New
|
||||||
|
const poStatusOptions = [
|
||||||
|
{ label: 'Received', value: '30' } // Using numeric codes
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Type Considerations
|
||||||
|
|
||||||
|
### Financial Fields
|
||||||
|
- Update TypeScript types:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
price: number;
|
||||||
|
// New
|
||||||
|
price: string; // Handle as string due to DECIMAL(15,3)
|
||||||
|
```
|
||||||
|
- Update formatting:
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
formatCurrency(value)
|
||||||
|
// New
|
||||||
|
formatCurrency(Number(value))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Codes
|
||||||
|
- Add status code mapping:
|
||||||
|
```typescript
|
||||||
|
const PO_STATUS_MAP = {
|
||||||
|
0: 'Canceled',
|
||||||
|
1: 'Created',
|
||||||
|
10: 'Ready to Send',
|
||||||
|
11: 'Ordered',
|
||||||
|
12: 'Preordered',
|
||||||
|
13: 'Sent',
|
||||||
|
15: 'Receiving Started',
|
||||||
|
50: 'Done'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
1. API Route Testing:
|
||||||
|
```typescript
|
||||||
|
// Test decimal handling
|
||||||
|
expect(typeof response.total_value).toBe('string');
|
||||||
|
expect(response.total_value).toMatch(/^\d+\.\d{3}$/);
|
||||||
|
|
||||||
|
// Test status codes
|
||||||
|
expect(response.receiving_status).toBeGreaterThanOrEqual(30);
|
||||||
|
|
||||||
|
// Test brand handling
|
||||||
|
expect(response.brand || 'Unbranded').toBeDefined();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- All numeric status code comparisons should use >= for status checks to handle future status codes
|
||||||
|
- All financial values should be handled as strings in TypeScript/JavaScript to preserve precision
|
||||||
|
- Brand grouping should always use COALESCE(brand, 'Unbranded') in SQL queries
|
||||||
|
- All ID parameters in routes should be validated as numbers
|
||||||
@@ -36,7 +36,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
0
|
0
|
||||||
) as averageOrderValue
|
) as averageOrderValue
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -70,12 +70,12 @@ router.get('/profit', async (req, res) => {
|
|||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
) as profitMargin,
|
) as profitMargin,
|
||||||
SUM(o.price * o.quantity) as revenue,
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||||
SUM(p.cost_price * o.quantity) as cost
|
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
GROUP BY c.name
|
GROUP BY c.name
|
||||||
ORDER BY profitMargin DESC
|
ORDER BY profitMargin DESC
|
||||||
@@ -90,10 +90,10 @@ router.get('/profit', async (req, res) => {
|
|||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
) as profitMargin,
|
) as profitMargin,
|
||||||
SUM(o.price * o.quantity) as revenue,
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||||
SUM(p.cost_price * o.quantity) as cost
|
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
CROSS JOIN (
|
CROSS JOIN (
|
||||||
SELECT DATE_FORMAT(o.date, '%Y-%m-%d') as formatted_date
|
SELECT DATE_FORMAT(o.date, '%Y-%m-%d') as formatted_date
|
||||||
FROM orders o
|
FROM orders o
|
||||||
@@ -114,12 +114,12 @@ router.get('/profit', async (req, res) => {
|
|||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
) as profitMargin,
|
) as profitMargin,
|
||||||
SUM(o.price * o.quantity) as revenue,
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||||
SUM(p.cost_price * o.quantity) as cost
|
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
GROUP BY p.product_id, p.title
|
GROUP BY p.pid, p.title
|
||||||
HAVING revenue > 0
|
HAVING revenue > 0
|
||||||
ORDER BY profitMargin DESC
|
ORDER BY profitMargin DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -144,7 +144,7 @@ router.get('/vendors', async (req, res) => {
|
|||||||
SELECT COUNT(DISTINCT p.vendor) as vendor_count,
|
SELECT COUNT(DISTINCT p.vendor) as vendor_count,
|
||||||
COUNT(DISTINCT o.order_number) as order_count
|
COUNT(DISTINCT o.order_number) as order_count
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE p.vendor IS NOT NULL
|
WHERE p.vendor IS NOT NULL
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -155,26 +155,26 @@ router.get('/vendors', async (req, res) => {
|
|||||||
WITH monthly_sales AS (
|
WITH monthly_sales AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
p.vendor,
|
||||||
SUM(CASE
|
CAST(SUM(CASE
|
||||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
THEN o.price * o.quantity
|
THEN o.price * o.quantity
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END) as current_month,
|
END) AS DECIMAL(15,3)) as current_month,
|
||||||
SUM(CASE
|
CAST(SUM(CASE
|
||||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
THEN o.price * o.quantity
|
THEN o.price * o.quantity
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END) as previous_month
|
END) AS DECIMAL(15,3)) as previous_month
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE p.vendor IS NOT NULL
|
WHERE p.vendor IS NOT NULL
|
||||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
GROUP BY p.vendor
|
GROUP BY p.vendor
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
p.vendor,
|
||||||
SUM(o.price * o.quantity) as salesVolume,
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as salesVolume,
|
||||||
COALESCE(ROUND(
|
COALESCE(ROUND(
|
||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
@@ -182,13 +182,13 @@ router.get('/vendors', async (req, res) => {
|
|||||||
COALESCE(ROUND(
|
COALESCE(ROUND(
|
||||||
SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 1
|
SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 1
|
||||||
), 0) as stockTurnover,
|
), 0) as stockTurnover,
|
||||||
COUNT(DISTINCT p.product_id) as productCount,
|
COUNT(DISTINCT p.pid) as productCount,
|
||||||
ROUND(
|
ROUND(
|
||||||
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
||||||
1
|
1
|
||||||
) as growth
|
) as growth
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
LEFT JOIN monthly_sales ms ON p.vendor = ms.vendor
|
LEFT JOIN monthly_sales ms ON p.vendor = ms.vendor
|
||||||
WHERE p.vendor IS NOT NULL
|
WHERE p.vendor IS NOT NULL
|
||||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
@@ -203,11 +203,11 @@ router.get('/vendors', async (req, res) => {
|
|||||||
const [comparison] = await pool.query(`
|
const [comparison] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
p.vendor,
|
||||||
COALESCE(ROUND(SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.product_id), 0), 2), 0) as salesPerProduct,
|
CAST(COALESCE(ROUND(SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.pid), 0), 2), 0) AS DECIMAL(15,3)) as salesPerProduct,
|
||||||
COALESCE(ROUND(AVG((o.price - p.cost_price) / NULLIF(o.price, 0) * 100), 1), 0) as averageMargin,
|
COALESCE(ROUND(AVG((o.price - p.cost_price) / NULLIF(o.price, 0) * 100), 1), 0) as averageMargin,
|
||||||
COUNT(DISTINCT p.product_id) as size
|
COUNT(DISTINCT p.pid) as size
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
LEFT JOIN orders o ON p.pid = o.pid AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
WHERE p.vendor IS NOT NULL
|
WHERE p.vendor IS NOT NULL
|
||||||
GROUP BY p.vendor
|
GROUP BY p.vendor
|
||||||
ORDER BY salesPerProduct DESC
|
ORDER BY salesPerProduct DESC
|
||||||
@@ -221,9 +221,9 @@ router.get('/vendors', async (req, res) => {
|
|||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
p.vendor,
|
||||||
DATE_FORMAT(o.date, '%b %Y') as month,
|
DATE_FORMAT(o.date, '%b %Y') as month,
|
||||||
COALESCE(SUM(o.price * o.quantity), 0) as sales
|
CAST(COALESCE(SUM(o.price * o.quantity), 0) AS DECIMAL(15,3)) as sales
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE p.vendor IS NOT NULL
|
WHERE p.vendor IS NOT NULL
|
||||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
||||||
GROUP BY
|
GROUP BY
|
||||||
@@ -272,9 +272,9 @@ router.get('/stock', async (req, res) => {
|
|||||||
ROUND(AVG(p.stock_quantity), 0) as averageStock,
|
ROUND(AVG(p.stock_quantity), 0) as averageStock,
|
||||||
SUM(o.quantity) as totalSales
|
SUM(o.quantity) as totalSales
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||||
GROUP BY c.name
|
GROUP BY c.name
|
||||||
HAVING turnoverRate > 0
|
HAVING turnoverRate > 0
|
||||||
@@ -290,7 +290,7 @@ router.get('/stock', async (req, res) => {
|
|||||||
SUM(CASE WHEN p.stock_quantity <= ? AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock,
|
SUM(CASE WHEN p.stock_quantity <= ? AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock,
|
||||||
SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
|
SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||||
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
||||||
ORDER BY date
|
ORDER BY date
|
||||||
@@ -304,25 +304,25 @@ router.get('/stock', async (req, res) => {
|
|||||||
const [criticalItems] = await pool.query(`
|
const [criticalItems] = await pool.query(`
|
||||||
WITH product_thresholds AS (
|
WITH product_thresholds AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
(SELECT reorder_days
|
(SELECT reorder_days
|
||||||
FROM stock_thresholds st
|
FROM stock_thresholds st
|
||||||
JOIN product_categories pc ON st.category_id = pc.category_id
|
JOIN product_categories pc ON st.cat_id = pc.cat_id
|
||||||
WHERE pc.product_id = p.product_id
|
WHERE pc.pid = p.pid
|
||||||
AND st.vendor = p.vendor LIMIT 1),
|
AND st.vendor = p.vendor LIMIT 1),
|
||||||
(SELECT reorder_days
|
(SELECT reorder_days
|
||||||
FROM stock_thresholds st
|
FROM stock_thresholds st
|
||||||
JOIN product_categories pc ON st.category_id = pc.category_id
|
JOIN product_categories pc ON st.cat_id = pc.cat_id
|
||||||
WHERE pc.product_id = p.product_id
|
WHERE pc.pid = p.pid
|
||||||
AND st.vendor IS NULL LIMIT 1),
|
AND st.vendor IS NULL LIMIT 1),
|
||||||
(SELECT reorder_days
|
(SELECT reorder_days
|
||||||
FROM stock_thresholds st
|
FROM stock_thresholds st
|
||||||
WHERE st.category_id IS NULL
|
WHERE st.cat_id IS NULL
|
||||||
AND st.vendor = p.vendor LIMIT 1),
|
AND st.vendor = p.vendor LIMIT 1),
|
||||||
(SELECT reorder_days
|
(SELECT reorder_days
|
||||||
FROM stock_thresholds st
|
FROM stock_thresholds st
|
||||||
WHERE st.category_id IS NULL
|
WHERE st.cat_id IS NULL
|
||||||
AND st.vendor IS NULL LIMIT 1),
|
AND st.vendor IS NULL LIMIT 1),
|
||||||
14
|
14
|
||||||
) as reorder_days
|
) as reorder_days
|
||||||
@@ -339,11 +339,11 @@ router.get('/stock', async (req, res) => {
|
|||||||
ELSE ROUND(p.stock_quantity / NULLIF((SUM(o.quantity) / ?), 0))
|
ELSE ROUND(p.stock_quantity / NULLIF((SUM(o.quantity) / ?), 0))
|
||||||
END as daysUntilStockout
|
END as daysUntilStockout
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_thresholds pt ON p.product_id = pt.product_id
|
JOIN product_thresholds pt ON p.pid = pt.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||||
AND p.managing_stock = true
|
AND p.managing_stock = true
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid
|
||||||
HAVING daysUntilStockout < ? AND daysUntilStockout >= 0
|
HAVING daysUntilStockout < ? AND daysUntilStockout >= 0
|
||||||
ORDER BY daysUntilStockout
|
ORDER BY daysUntilStockout
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -374,7 +374,7 @@ router.get('/pricing', async (req, res) => {
|
|||||||
SUM(o.price * o.quantity) as revenue,
|
SUM(o.price * o.quantity) as revenue,
|
||||||
p.categories as category
|
p.categories as category
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
GROUP BY p.price, p.categories
|
GROUP BY p.price, p.categories
|
||||||
HAVING salesVolume > 0
|
HAVING salesVolume > 0
|
||||||
@@ -420,9 +420,9 @@ router.get('/pricing', async (req, res) => {
|
|||||||
ELSE 65
|
ELSE 65
|
||||||
END as confidence
|
END as confidence
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid
|
||||||
HAVING ABS(recommendedPrice - currentPrice) > 0
|
HAVING ABS(recommendedPrice - currentPrice) > 0
|
||||||
ORDER BY potentialRevenue - SUM(o.price * o.quantity) DESC
|
ORDER BY potentialRevenue - SUM(o.price * o.quantity) DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -457,9 +457,9 @@ router.get('/categories', async (req, res) => {
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) as previous_month
|
END) as previous_month
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
GROUP BY c.name
|
GROUP BY c.name
|
||||||
)
|
)
|
||||||
@@ -471,11 +471,11 @@ router.get('/categories', async (req, res) => {
|
|||||||
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
||||||
1
|
1
|
||||||
) as growth,
|
) as growth,
|
||||||
COUNT(DISTINCT p.product_id) as productCount
|
COUNT(DISTINCT p.pid) as productCount
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
LEFT JOIN monthly_sales ms ON c.name = ms.name
|
LEFT JOIN monthly_sales ms ON c.name = ms.name
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
GROUP BY c.name, ms.current_month, ms.previous_month
|
GROUP BY c.name, ms.current_month, ms.previous_month
|
||||||
@@ -490,9 +490,9 @@ router.get('/categories', async (req, res) => {
|
|||||||
c.name as category,
|
c.name as category,
|
||||||
SUM(o.price * o.quantity) as value
|
SUM(o.price * o.quantity) as value
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
GROUP BY c.name
|
GROUP BY c.name
|
||||||
HAVING value > 0
|
HAVING value > 0
|
||||||
@@ -507,9 +507,9 @@ router.get('/categories', async (req, res) => {
|
|||||||
DATE_FORMAT(o.date, '%b %Y') as month,
|
DATE_FORMAT(o.date, '%b %Y') as month,
|
||||||
SUM(o.price * o.quantity) as sales
|
SUM(o.price * o.quantity) as sales
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
||||||
GROUP BY
|
GROUP BY
|
||||||
c.name,
|
c.name,
|
||||||
@@ -536,52 +536,52 @@ router.get('/forecast', async (req, res) => {
|
|||||||
const [results] = await pool.query(`
|
const [results] = await pool.query(`
|
||||||
WITH category_metrics AS (
|
WITH category_metrics AS (
|
||||||
SELECT
|
SELECT
|
||||||
c.id as category_id,
|
c.cat_id as category_id,
|
||||||
c.name as category_name,
|
c.name as category_name,
|
||||||
p.brand,
|
p.brand,
|
||||||
COUNT(DISTINCT p.product_id) as num_products,
|
COUNT(DISTINCT p.pid) as num_products,
|
||||||
COALESCE(ROUND(SUM(o.quantity) / DATEDIFF(?, ?), 2), 0) as avg_daily_sales,
|
COALESCE(ROUND(SUM(o.quantity) / DATEDIFF(?, ?), 2), 0) as avg_daily_sales,
|
||||||
COALESCE(SUM(o.quantity), 0) as total_sold,
|
COALESCE(SUM(o.quantity), 0) as total_sold,
|
||||||
COALESCE(ROUND(SUM(o.quantity) / COUNT(DISTINCT p.product_id), 2), 0) as avgTotalSold,
|
COALESCE(ROUND(SUM(o.quantity) / COUNT(DISTINCT p.pid), 2), 0) as avgTotalSold,
|
||||||
COALESCE(ROUND(AVG(o.price), 2), 0) as avg_price
|
COALESCE(ROUND(AVG(o.price), 2), 0) as avg_price
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN product_categories pc ON c.id = pc.category_id
|
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||||
JOIN products p ON pc.product_id = p.product_id
|
JOIN products p ON pc.pid = p.pid
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
AND o.date BETWEEN ? AND ?
|
AND o.date BETWEEN ? AND ?
|
||||||
AND o.canceled = false
|
AND o.canceled = false
|
||||||
WHERE p.brand = ?
|
WHERE p.brand = ?
|
||||||
AND pm.first_received_date BETWEEN ? AND ?
|
AND pm.first_received_date BETWEEN ? AND ?
|
||||||
GROUP BY c.id, c.name, p.brand
|
GROUP BY c.cat_id, c.name, p.brand
|
||||||
),
|
),
|
||||||
product_metrics AS (
|
product_metrics AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.title,
|
p.title,
|
||||||
p.sku,
|
p.SKU,
|
||||||
p.stock_quantity,
|
p.stock_quantity,
|
||||||
pc.category_id,
|
pc.cat_id,
|
||||||
pm.first_received_date,
|
pm.first_received_date,
|
||||||
COALESCE(SUM(o.quantity), 0) as total_sold,
|
COALESCE(SUM(o.quantity), 0) as total_sold,
|
||||||
COALESCE(ROUND(AVG(o.price), 2), 0) as avg_price
|
COALESCE(ROUND(AVG(o.price), 2), 0) as avg_price
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
AND o.date BETWEEN ? AND ?
|
AND o.date BETWEEN ? AND ?
|
||||||
AND o.canceled = false
|
AND o.canceled = false
|
||||||
WHERE p.brand = ?
|
WHERE p.brand = ?
|
||||||
AND pm.first_received_date BETWEEN ? AND ?
|
AND pm.first_received_date BETWEEN ? AND ?
|
||||||
GROUP BY p.product_id, p.title, p.sku, p.stock_quantity, pc.category_id, pm.first_received_date
|
GROUP BY p.pid, p.title, p.SKU, p.stock_quantity, pc.cat_id, pm.first_received_date
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
cm.*,
|
cm.*,
|
||||||
JSON_ARRAYAGG(
|
JSON_ARRAYAGG(
|
||||||
JSON_OBJECT(
|
JSON_OBJECT(
|
||||||
'product_id', pm.product_id,
|
'pid', pm.pid,
|
||||||
'title', pm.title,
|
'title', pm.title,
|
||||||
'sku', pm.sku,
|
'SKU', pm.SKU,
|
||||||
'stock_quantity', pm.stock_quantity,
|
'stock_quantity', pm.stock_quantity,
|
||||||
'total_sold', pm.total_sold,
|
'total_sold', pm.total_sold,
|
||||||
'avg_price', pm.avg_price,
|
'avg_price', pm.avg_price,
|
||||||
@@ -589,7 +589,7 @@ router.get('/forecast', async (req, res) => {
|
|||||||
)
|
)
|
||||||
) as products
|
) as products
|
||||||
FROM category_metrics cm
|
FROM category_metrics cm
|
||||||
JOIN product_metrics pm ON cm.category_id = pm.category_id
|
JOIN product_metrics pm ON cm.category_id = pm.cat_id
|
||||||
GROUP BY cm.category_id, cm.category_name, cm.brand, cm.num_products, cm.avg_daily_sales, cm.total_sold, cm.avgTotalSold, cm.avg_price
|
GROUP BY cm.category_id, cm.category_name, cm.brand, cm.num_products, cm.avg_daily_sales, cm.total_sold, cm.avgTotalSold, cm.avg_price
|
||||||
ORDER BY cm.total_sold DESC
|
ORDER BY cm.total_sold DESC
|
||||||
`, [startDate, endDate, startDate, endDate, brand, startDate, endDate, startDate, endDate, brand, startDate, endDate]);
|
`, [startDate, endDate, startDate, endDate, brand, startDate, endDate, startDate, endDate, brand, startDate, endDate]);
|
||||||
|
|||||||
@@ -9,58 +9,62 @@ router.get('/', async (req, res) => {
|
|||||||
const [parentCategories] = await pool.query(`
|
const [parentCategories] = await pool.query(`
|
||||||
SELECT DISTINCT c2.name as parent_name
|
SELECT DISTINCT c2.name as parent_name
|
||||||
FROM categories c1
|
FROM categories c1
|
||||||
JOIN categories c2 ON c1.parent_id = c2.id
|
JOIN categories c2 ON c1.parent_cat_id = c2.cat_id
|
||||||
WHERE c1.parent_id IS NOT NULL
|
WHERE c1.parent_cat_id IS NOT NULL
|
||||||
ORDER BY c2.name
|
ORDER BY c2.name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get all categories with metrics
|
// Get all categories with metrics
|
||||||
const [categories] = await pool.query(`
|
const [categories] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
c.id as category_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
c.description,
|
c.description,
|
||||||
COALESCE(p.name, '') as parent_name,
|
COALESCE(p.name, '') as parent_name,
|
||||||
cm.product_count,
|
COALESCE(cm.product_count, 0) as product_count,
|
||||||
cm.total_value,
|
CAST(COALESCE(cm.total_value, 0) AS DECIMAL(15,3)) as total_value,
|
||||||
cm.avg_margin,
|
COALESCE(cm.avg_margin, 0) as avg_margin,
|
||||||
cm.turnover_rate,
|
COALESCE(cm.turnover_rate, 0) as turnover_rate,
|
||||||
cm.growth_rate,
|
COALESCE(cm.growth_rate, 0) as growth_rate,
|
||||||
cm.status
|
COALESCE(cm.status, 'inactive') as status
|
||||||
FROM categories c
|
FROM categories c
|
||||||
LEFT JOIN categories p ON c.parent_id = p.id
|
LEFT JOIN categories p ON c.parent_cat_id = p.cat_id
|
||||||
LEFT JOIN category_metrics cm ON c.id = cm.category_id
|
LEFT JOIN category_metrics cm ON c.cat_id = cm.cat_id
|
||||||
ORDER BY c.name ASC
|
ORDER BY c.name ASC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get overall stats
|
// Get overall stats
|
||||||
const [stats] = await pool.query(`
|
const [stats] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT c.id) as totalCategories,
|
COUNT(DISTINCT c.cat_id) as totalCategories,
|
||||||
COUNT(DISTINCT CASE WHEN cm.status = 'active' THEN c.id END) as activeCategories,
|
COUNT(DISTINCT CASE WHEN cm.status = 'active' THEN c.cat_id END) as activeCategories,
|
||||||
COALESCE(SUM(cm.total_value), 0) as totalValue,
|
CAST(COALESCE(SUM(cm.total_value), 0) AS DECIMAL(15,3)) as totalValue,
|
||||||
COALESCE(ROUND(AVG(NULLIF(cm.avg_margin, 0)), 1), 0) as avgMargin,
|
COALESCE(ROUND(AVG(NULLIF(cm.avg_margin, 0)), 1), 0) as avgMargin,
|
||||||
COALESCE(ROUND(AVG(NULLIF(cm.growth_rate, 0)), 1), 0) as avgGrowth
|
COALESCE(ROUND(AVG(NULLIF(cm.growth_rate, 0)), 1), 0) as avgGrowth
|
||||||
FROM categories c
|
FROM categories c
|
||||||
LEFT JOIN category_metrics cm ON c.id = cm.category_id
|
LEFT JOIN category_metrics cm ON c.cat_id = cm.cat_id
|
||||||
`);
|
`);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
categories: categories.map(cat => ({
|
categories: categories.map(cat => ({
|
||||||
...cat,
|
id: cat.cat_id,
|
||||||
parent_category: cat.parent_name, // Map parent_name to parent_category for frontend compatibility
|
name: cat.name,
|
||||||
product_count: parseInt(cat.product_count || 0),
|
description: cat.description,
|
||||||
total_value: parseFloat(cat.total_value || 0),
|
parent_category: cat.parent_name,
|
||||||
avg_margin: parseFloat(cat.avg_margin || 0),
|
product_count: parseInt(cat.product_count),
|
||||||
turnover_rate: parseFloat(cat.turnover_rate || 0),
|
total_value: parseFloat(cat.total_value),
|
||||||
growth_rate: parseFloat(cat.growth_rate || 0)
|
avg_margin: parseFloat(cat.avg_margin),
|
||||||
|
turnover_rate: parseFloat(cat.turnover_rate),
|
||||||
|
growth_rate: parseFloat(cat.growth_rate),
|
||||||
|
status: cat.status
|
||||||
})),
|
})),
|
||||||
parentCategories: parentCategories.map(p => p.parent_name),
|
parentCategories: parentCategories.map(p => p.parent_name),
|
||||||
stats: {
|
stats: {
|
||||||
...stats[0],
|
totalCategories: parseInt(stats[0].totalCategories),
|
||||||
totalValue: parseFloat(stats[0].totalValue || 0),
|
activeCategories: parseInt(stats[0].activeCategories),
|
||||||
avgMargin: parseFloat(stats[0].avgMargin || 0),
|
totalValue: parseFloat(stats[0].totalValue),
|
||||||
avgGrowth: parseFloat(stats[0].avgGrowth || 0)
|
avgMargin: parseFloat(stats[0].avgMargin),
|
||||||
|
avgGrowth: parseFloat(stats[0].avgGrowth)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -38,15 +38,14 @@ router.get('/stock/metrics', async (req, res) => {
|
|||||||
const [brandValues] = await executeQuery(`
|
const [brandValues] = await executeQuery(`
|
||||||
WITH brand_totals AS (
|
WITH brand_totals AS (
|
||||||
SELECT
|
SELECT
|
||||||
brand,
|
COALESCE(brand, 'Unbranded') as brand,
|
||||||
COUNT(DISTINCT product_id) as variant_count,
|
COUNT(DISTINCT pid) as variant_count,
|
||||||
COALESCE(SUM(stock_quantity), 0) as stock_units,
|
COALESCE(SUM(stock_quantity), 0) as stock_units,
|
||||||
COALESCE(SUM(stock_quantity * cost_price), 0) as stock_cost,
|
CAST(COALESCE(SUM(stock_quantity * cost_price), 0) AS DECIMAL(15,3)) as stock_cost,
|
||||||
COALESCE(SUM(stock_quantity * price), 0) as stock_retail
|
CAST(COALESCE(SUM(stock_quantity * price), 0) AS DECIMAL(15,3)) as stock_retail
|
||||||
FROM products
|
FROM products
|
||||||
WHERE brand IS NOT NULL
|
WHERE stock_quantity > 0
|
||||||
AND stock_quantity > 0
|
GROUP BY COALESCE(brand, 'Unbranded')
|
||||||
GROUP BY brand
|
|
||||||
HAVING stock_cost > 0
|
HAVING stock_cost > 0
|
||||||
),
|
),
|
||||||
other_brands AS (
|
other_brands AS (
|
||||||
@@ -54,8 +53,8 @@ router.get('/stock/metrics', async (req, res) => {
|
|||||||
'Other' as brand,
|
'Other' as brand,
|
||||||
SUM(variant_count) as variant_count,
|
SUM(variant_count) as variant_count,
|
||||||
SUM(stock_units) as stock_units,
|
SUM(stock_units) as stock_units,
|
||||||
SUM(stock_cost) as stock_cost,
|
CAST(SUM(stock_cost) AS DECIMAL(15,3)) as stock_cost,
|
||||||
SUM(stock_retail) as stock_retail
|
CAST(SUM(stock_retail) AS DECIMAL(15,3)) as stock_retail
|
||||||
FROM brand_totals
|
FROM brand_totals
|
||||||
WHERE stock_cost <= 5000
|
WHERE stock_cost <= 5000
|
||||||
),
|
),
|
||||||
@@ -101,24 +100,24 @@ router.get('/purchase/metrics', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const [rows] = await executeQuery(`
|
const [rows] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(COUNT(DISTINCT CASE WHEN po.status = 'open' THEN po.po_id END), 0) as active_pos,
|
COALESCE(COUNT(DISTINCT CASE WHEN po.receiving_status < 30 THEN po.po_id END), 0) as active_pos,
|
||||||
COALESCE(COUNT(DISTINCT CASE
|
COALESCE(COUNT(DISTINCT CASE
|
||||||
WHEN po.status = 'open' AND po.expected_date < CURDATE()
|
WHEN po.receiving_status < 30 AND po.expected_date < CURDATE()
|
||||||
THEN po.po_id
|
THEN po.po_id
|
||||||
END), 0) as overdue_pos,
|
END), 0) as overdue_pos,
|
||||||
COALESCE(SUM(CASE WHEN po.status = 'open' THEN po.ordered ELSE 0 END), 0) as total_units,
|
COALESCE(SUM(CASE WHEN po.receiving_status < 30 THEN po.ordered ELSE 0 END), 0) as total_units,
|
||||||
COALESCE(SUM(CASE
|
CAST(COALESCE(SUM(CASE
|
||||||
WHEN po.status = 'open'
|
WHEN po.receiving_status < 30
|
||||||
THEN po.ordered * po.cost_price
|
THEN po.ordered * po.cost_price
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END), 0) as total_cost,
|
END), 0) AS DECIMAL(15,3)) as total_cost,
|
||||||
COALESCE(SUM(CASE
|
CAST(COALESCE(SUM(CASE
|
||||||
WHEN po.status = 'open'
|
WHEN po.receiving_status < 30
|
||||||
THEN po.ordered * p.price
|
THEN po.ordered * p.price
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END), 0) as total_retail
|
END), 0) AS DECIMAL(15,3)) as total_retail
|
||||||
FROM purchase_orders po
|
FROM purchase_orders po
|
||||||
JOIN products p ON po.product_id = p.product_id
|
JOIN products p ON po.pid = p.pid
|
||||||
`);
|
`);
|
||||||
const poMetrics = rows[0];
|
const poMetrics = rows[0];
|
||||||
|
|
||||||
@@ -134,11 +133,11 @@ router.get('/purchase/metrics', async (req, res) => {
|
|||||||
po.vendor,
|
po.vendor,
|
||||||
COUNT(DISTINCT po.po_id) as order_count,
|
COUNT(DISTINCT po.po_id) as order_count,
|
||||||
COALESCE(SUM(po.ordered), 0) as ordered_units,
|
COALESCE(SUM(po.ordered), 0) as ordered_units,
|
||||||
COALESCE(SUM(po.ordered * po.cost_price), 0) as order_cost,
|
CAST(COALESCE(SUM(po.ordered * po.cost_price), 0) AS DECIMAL(15,3)) as order_cost,
|
||||||
COALESCE(SUM(po.ordered * p.price), 0) as order_retail
|
CAST(COALESCE(SUM(po.ordered * p.price), 0) AS DECIMAL(15,3)) as order_retail
|
||||||
FROM purchase_orders po
|
FROM purchase_orders po
|
||||||
JOIN products p ON po.product_id = p.product_id
|
JOIN products p ON po.pid = p.pid
|
||||||
WHERE po.status = 'open'
|
WHERE po.receiving_status < 30
|
||||||
GROUP BY po.vendor
|
GROUP BY po.vendor
|
||||||
HAVING order_cost > 0
|
HAVING order_cost > 0
|
||||||
ORDER BY order_cost DESC
|
ORDER BY order_cost DESC
|
||||||
@@ -173,21 +172,21 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
// Get summary metrics
|
// Get summary metrics
|
||||||
const [metrics] = await executeQuery(`
|
const [metrics] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT p.product_id) as products_to_replenish,
|
COUNT(DISTINCT p.pid) as products_to_replenish,
|
||||||
COALESCE(SUM(CASE
|
COALESCE(SUM(CASE
|
||||||
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
||||||
ELSE pm.reorder_qty
|
ELSE pm.reorder_qty
|
||||||
END), 0) as total_units_needed,
|
END), 0) as total_units_needed,
|
||||||
COALESCE(SUM(CASE
|
CAST(COALESCE(SUM(CASE
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
|
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
|
||||||
ELSE pm.reorder_qty * p.cost_price
|
ELSE pm.reorder_qty * p.cost_price
|
||||||
END), 0) as total_cost,
|
END), 0) AS DECIMAL(15,3)) as total_cost,
|
||||||
COALESCE(SUM(CASE
|
CAST(COALESCE(SUM(CASE
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
|
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
|
||||||
ELSE pm.reorder_qty * p.price
|
ELSE pm.reorder_qty * p.price
|
||||||
END), 0) as total_retail
|
END), 0) AS DECIMAL(15,3)) as total_retail
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.replenishable = true
|
WHERE p.replenishable = true
|
||||||
AND (pm.stock_status IN ('Critical', 'Reorder')
|
AND (pm.stock_status IN ('Critical', 'Reorder')
|
||||||
OR p.stock_quantity < 0)
|
OR p.stock_quantity < 0)
|
||||||
@@ -197,24 +196,24 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
// Get top variants to replenish
|
// Get top variants to replenish
|
||||||
const [variants] = await executeQuery(`
|
const [variants] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.title,
|
p.title,
|
||||||
p.stock_quantity as current_stock,
|
p.stock_quantity as current_stock,
|
||||||
CASE
|
CASE
|
||||||
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
||||||
ELSE pm.reorder_qty
|
ELSE pm.reorder_qty
|
||||||
END as replenish_qty,
|
END as replenish_qty,
|
||||||
CASE
|
CAST(CASE
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
|
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
|
||||||
ELSE pm.reorder_qty * p.cost_price
|
ELSE pm.reorder_qty * p.cost_price
|
||||||
END as replenish_cost,
|
END AS DECIMAL(15,3)) as replenish_cost,
|
||||||
CASE
|
CAST(CASE
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
|
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
|
||||||
ELSE pm.reorder_qty * p.price
|
ELSE pm.reorder_qty * p.price
|
||||||
END as replenish_retail,
|
END AS DECIMAL(15,3)) as replenish_retail,
|
||||||
pm.stock_status
|
pm.stock_status
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.replenishable = true
|
WHERE p.replenishable = true
|
||||||
AND (pm.stock_status IN ('Critical', 'Reorder')
|
AND (pm.stock_status IN ('Critical', 'Reorder')
|
||||||
OR p.stock_quantity < 0)
|
OR p.stock_quantity < 0)
|
||||||
@@ -235,7 +234,7 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
replenishmentCost: parseFloat(metrics[0].total_cost) || 0,
|
replenishmentCost: parseFloat(metrics[0].total_cost) || 0,
|
||||||
replenishmentRetail: parseFloat(metrics[0].total_retail) || 0,
|
replenishmentRetail: parseFloat(metrics[0].total_retail) || 0,
|
||||||
topVariants: variants.map(v => ({
|
topVariants: variants.map(v => ({
|
||||||
id: v.product_id,
|
id: v.pid,
|
||||||
title: v.title,
|
title: v.title,
|
||||||
currentStock: parseInt(v.current_stock) || 0,
|
currentStock: parseInt(v.current_stock) || 0,
|
||||||
replenishQty: parseInt(v.replenish_qty) || 0,
|
replenishQty: parseInt(v.replenish_qty) || 0,
|
||||||
@@ -287,9 +286,9 @@ router.get('/forecast/metrics', async (req, res) => {
|
|||||||
COALESCE(SUM(cf.forecast_revenue), 0) as revenue,
|
COALESCE(SUM(cf.forecast_revenue), 0) as revenue,
|
||||||
COALESCE(AVG(cf.confidence_level), 0) as confidence
|
COALESCE(AVG(cf.confidence_level), 0) as confidence
|
||||||
FROM category_forecasts cf
|
FROM category_forecasts cf
|
||||||
JOIN categories c ON cf.category_id = c.id
|
JOIN categories c ON cf.cat_id = c.cat_id
|
||||||
WHERE cf.forecast_date BETWEEN ? AND ?
|
WHERE cf.forecast_date BETWEEN ? AND ?
|
||||||
GROUP BY c.id, c.name
|
GROUP BY c.cat_id, c.name
|
||||||
ORDER BY revenue DESC
|
ORDER BY revenue DESC
|
||||||
`, [startDate, endDate]);
|
`, [startDate, endDate]);
|
||||||
|
|
||||||
@@ -325,11 +324,11 @@ router.get('/overstock/metrics', async (req, res) => {
|
|||||||
const [rows] = await executeQuery(`
|
const [rows] = await executeQuery(`
|
||||||
WITH category_overstock AS (
|
WITH category_overstock AS (
|
||||||
SELECT
|
SELECT
|
||||||
c.id as category_id,
|
c.cat_id,
|
||||||
c.name as category_name,
|
c.name as category_name,
|
||||||
COUNT(DISTINCT CASE
|
COUNT(DISTINCT CASE
|
||||||
WHEN pm.stock_status = 'Overstocked'
|
WHEN pm.stock_status = 'Overstocked'
|
||||||
THEN p.product_id
|
THEN p.pid
|
||||||
END) as overstocked_products,
|
END) as overstocked_products,
|
||||||
SUM(CASE
|
SUM(CASE
|
||||||
WHEN pm.stock_status = 'Overstocked'
|
WHEN pm.stock_status = 'Overstocked'
|
||||||
@@ -347,10 +346,10 @@ router.get('/overstock/metrics', async (req, res) => {
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) as total_excess_retail
|
END) as total_excess_retail
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN product_categories pc ON c.id = pc.category_id
|
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||||
JOIN products p ON pc.product_id = p.product_id
|
JOIN products p ON pc.pid = p.pid
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
GROUP BY c.id, c.name
|
GROUP BY c.cat_id, c.name
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
SUM(overstocked_products) as total_overstocked,
|
SUM(overstocked_products) as total_overstocked,
|
||||||
@@ -405,7 +404,7 @@ router.get('/overstock/products', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const [rows] = await executeQuery(`
|
const [rows] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.SKU,
|
p.SKU,
|
||||||
p.title,
|
p.title,
|
||||||
p.brand,
|
p.brand,
|
||||||
@@ -420,11 +419,11 @@ router.get('/overstock/products', async (req, res) => {
|
|||||||
(pm.overstocked_amt * p.price) as excess_retail,
|
(pm.overstocked_amt * p.price) as excess_retail,
|
||||||
GROUP_CONCAT(c.name) as categories
|
GROUP_CONCAT(c.name) as categories
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||||
LEFT JOIN categories c ON pc.category_id = c.id
|
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE pm.stock_status = 'Overstocked'
|
WHERE pm.stock_status = 'Overstocked'
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid
|
||||||
ORDER BY excess_cost DESC
|
ORDER BY excess_cost DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`, [limit]);
|
`, [limit]);
|
||||||
@@ -442,7 +441,7 @@ router.get('/best-sellers', async (req, res) => {
|
|||||||
const [products] = await executeQuery(`
|
const [products] = await executeQuery(`
|
||||||
WITH product_sales AS (
|
WITH product_sales AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.SKU as sku,
|
p.SKU as sku,
|
||||||
p.title,
|
p.title,
|
||||||
-- Current period (last 30 days)
|
-- Current period (last 30 days)
|
||||||
@@ -468,13 +467,13 @@ router.get('/best-sellers', async (req, res) => {
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) as previous_revenue
|
END) as previous_revenue
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN orders o ON p.product_id = o.product_id
|
JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
||||||
GROUP BY p.product_id, p.SKU, p.title
|
GROUP BY p.pid, p.SKU, p.title
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
product_id,
|
pid,
|
||||||
sku,
|
sku,
|
||||||
title,
|
title,
|
||||||
units_sold,
|
units_sold,
|
||||||
@@ -520,7 +519,7 @@ router.get('/best-sellers', async (req, res) => {
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) as previous_revenue
|
END) as previous_revenue
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN orders o ON p.product_id = o.product_id
|
JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
||||||
AND p.brand IS NOT NULL
|
AND p.brand IS NOT NULL
|
||||||
@@ -547,7 +546,7 @@ router.get('/best-sellers', async (req, res) => {
|
|||||||
const [categories] = await executeQuery(`
|
const [categories] = await executeQuery(`
|
||||||
WITH category_sales AS (
|
WITH category_sales AS (
|
||||||
SELECT
|
SELECT
|
||||||
c.id as category_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
-- Current period (last 30 days)
|
-- Current period (last 30 days)
|
||||||
SUM(CASE
|
SUM(CASE
|
||||||
@@ -572,15 +571,15 @@ router.get('/best-sellers', async (req, res) => {
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) as previous_revenue
|
END) as previous_revenue
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN product_categories pc ON c.id = pc.category_id
|
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||||
JOIN products p ON pc.product_id = p.product_id
|
JOIN products p ON pc.pid = p.pid
|
||||||
JOIN orders o ON p.product_id = o.product_id
|
JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
||||||
GROUP BY c.id, c.name
|
GROUP BY c.cat_id, c.name
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
category_id,
|
cat_id as category_id,
|
||||||
name,
|
name,
|
||||||
units_sold,
|
units_sold,
|
||||||
revenue,
|
revenue,
|
||||||
@@ -616,7 +615,7 @@ router.get('/best-sellers', async (req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const formattedCategories = categories.map(c => ({
|
const formattedCategories = categories.map(c => ({
|
||||||
category_id: c.category_id,
|
category_id: c.cat_id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
units_sold: parseInt(c.units_sold) || 0,
|
units_sold: parseInt(c.units_sold) || 0,
|
||||||
revenue: parseFloat(c.revenue) || 0,
|
revenue: parseFloat(c.revenue) || 0,
|
||||||
@@ -650,7 +649,7 @@ router.get('/sales/metrics', async (req, res) => {
|
|||||||
SUM(p.cost_price * o.quantity) as total_cogs,
|
SUM(p.cost_price * o.quantity) as total_cogs,
|
||||||
SUM((o.price - p.cost_price) * o.quantity) as total_profit
|
SUM((o.price - p.cost_price) * o.quantity) as total_profit
|
||||||
FROM orders o
|
FROM orders o
|
||||||
JOIN products p ON o.product_id = p.product_id
|
JOIN products p ON o.pid = p.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
AND o.date BETWEEN ? AND ?
|
AND o.date BETWEEN ? AND ?
|
||||||
GROUP BY DATE(o.date)
|
GROUP BY DATE(o.date)
|
||||||
@@ -666,7 +665,7 @@ router.get('/sales/metrics', async (req, res) => {
|
|||||||
SUM(p.cost_price * o.quantity) as total_cogs,
|
SUM(p.cost_price * o.quantity) as total_cogs,
|
||||||
SUM((o.price - p.cost_price) * o.quantity) as total_profit
|
SUM((o.price - p.cost_price) * o.quantity) as total_profit
|
||||||
FROM orders o
|
FROM orders o
|
||||||
JOIN products p ON o.product_id = p.product_id
|
JOIN products p ON o.pid = p.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
AND o.date BETWEEN ? AND ?
|
AND o.date BETWEEN ? AND ?
|
||||||
`, [startDate, endDate]);
|
`, [startDate, endDate]);
|
||||||
@@ -698,7 +697,7 @@ router.get('/low-stock/products', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const [rows] = await executeQuery(`
|
const [rows] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.SKU,
|
p.SKU,
|
||||||
p.title,
|
p.title,
|
||||||
p.brand,
|
p.brand,
|
||||||
@@ -712,12 +711,12 @@ router.get('/low-stock/products', async (req, res) => {
|
|||||||
(pm.reorder_qty * p.cost_price) as reorder_cost,
|
(pm.reorder_qty * p.cost_price) as reorder_cost,
|
||||||
GROUP_CONCAT(c.name) as categories
|
GROUP_CONCAT(c.name) as categories
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||||
LEFT JOIN categories c ON pc.category_id = c.id
|
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE pm.stock_status IN ('Critical', 'Reorder')
|
WHERE pm.stock_status IN ('Critical', 'Reorder')
|
||||||
AND p.replenishable = true
|
AND p.replenishable = true
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE pm.stock_status
|
CASE pm.stock_status
|
||||||
WHEN 'Critical' THEN 1
|
WHEN 'Critical' THEN 1
|
||||||
@@ -742,17 +741,17 @@ router.get('/trending/products', async (req, res) => {
|
|||||||
const [rows] = await executeQuery(`
|
const [rows] = await executeQuery(`
|
||||||
WITH recent_sales AS (
|
WITH recent_sales AS (
|
||||||
SELECT
|
SELECT
|
||||||
o.product_id,
|
o.pid,
|
||||||
COUNT(DISTINCT o.order_number) as recent_orders,
|
COUNT(DISTINCT o.order_number) as recent_orders,
|
||||||
SUM(o.quantity) as recent_units,
|
SUM(o.quantity) as recent_units,
|
||||||
SUM(o.price * o.quantity) as recent_revenue
|
SUM(o.price * o.quantity) as recent_revenue
|
||||||
FROM orders o
|
FROM orders o
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||||
GROUP BY o.product_id
|
GROUP BY o.pid
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.SKU,
|
p.SKU,
|
||||||
p.title,
|
p.title,
|
||||||
p.brand,
|
p.brand,
|
||||||
@@ -767,11 +766,11 @@ router.get('/trending/products', async (req, res) => {
|
|||||||
((rs.recent_units / ?) - pm.daily_sales_avg) / pm.daily_sales_avg * 100 as velocity_change,
|
((rs.recent_units / ?) - pm.daily_sales_avg) / pm.daily_sales_avg * 100 as velocity_change,
|
||||||
GROUP_CONCAT(c.name) as categories
|
GROUP_CONCAT(c.name) as categories
|
||||||
FROM recent_sales rs
|
FROM recent_sales rs
|
||||||
JOIN products p ON rs.product_id = p.product_id
|
JOIN products p ON rs.pid = p.pid
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||||
LEFT JOIN categories c ON pc.category_id = c.id
|
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid
|
||||||
HAVING velocity_change > 0
|
HAVING velocity_change > 0
|
||||||
ORDER BY velocity_change DESC
|
ORDER BY velocity_change DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -859,7 +858,7 @@ router.get('/key-metrics', async (req, res) => {
|
|||||||
COUNT(CASE WHEN pm.stock_status = 'Critical' THEN 1 END) as critical_stock_count,
|
COUNT(CASE WHEN pm.stock_status = 'Critical' THEN 1 END) as critical_stock_count,
|
||||||
COUNT(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 END) as overstock_count
|
COUNT(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 END) as overstock_count
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
),
|
),
|
||||||
sales_summary AS (
|
sales_summary AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -909,7 +908,7 @@ router.get('/inventory-health', async (req, res) => {
|
|||||||
AVG(pm.turnover_rate) as avg_turnover_rate,
|
AVG(pm.turnover_rate) as avg_turnover_rate,
|
||||||
AVG(pm.days_of_inventory) as avg_days_inventory
|
AVG(pm.days_of_inventory) as avg_days_inventory
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.replenishable = true
|
WHERE p.replenishable = true
|
||||||
),
|
),
|
||||||
value_distribution AS (
|
value_distribution AS (
|
||||||
@@ -931,7 +930,7 @@ router.get('/inventory-health', async (req, res) => {
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) * 100.0 / SUM(p.stock_quantity * p.cost_price) as overstock_value_percent
|
END) * 100.0 / SUM(p.stock_quantity * p.cost_price) as overstock_value_percent
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
),
|
),
|
||||||
category_health AS (
|
category_health AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -940,11 +939,11 @@ router.get('/inventory-health', async (req, res) => {
|
|||||||
SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as category_healthy_percent,
|
SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as category_healthy_percent,
|
||||||
AVG(pm.turnover_rate) as category_turnover_rate
|
AVG(pm.turnover_rate) as category_turnover_rate
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN product_categories pc ON c.id = pc.category_id
|
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||||
JOIN products p ON pc.product_id = p.product_id
|
JOIN products p ON pc.pid = p.pid
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.replenishable = true
|
WHERE p.replenishable = true
|
||||||
GROUP BY c.id, c.name
|
GROUP BY c.cat_id, c.name
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
sd.*,
|
sd.*,
|
||||||
@@ -975,20 +974,15 @@ router.get('/replenish/products', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const [products] = await executeQuery(`
|
const [products] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.SKU,
|
p.SKU as sku,
|
||||||
p.title,
|
p.title,
|
||||||
p.stock_quantity as current_stock,
|
p.stock_quantity,
|
||||||
pm.reorder_qty as replenish_qty,
|
pm.daily_sales_avg,
|
||||||
(pm.reorder_qty * p.cost_price) as replenish_cost,
|
pm.reorder_qty,
|
||||||
(pm.reorder_qty * p.price) as replenish_retail,
|
pm.last_purchase_date
|
||||||
CASE
|
|
||||||
WHEN pm.daily_sales_avg > 0
|
|
||||||
THEN FLOOR(p.stock_quantity / pm.daily_sales_avg)
|
|
||||||
ELSE NULL
|
|
||||||
END as days_until_stockout
|
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.replenishable = true
|
WHERE p.replenishable = true
|
||||||
AND pm.stock_status IN ('Critical', 'Reorder')
|
AND pm.stock_status IN ('Critical', 'Reorder')
|
||||||
AND pm.reorder_qty > 0
|
AND pm.reorder_qty > 0
|
||||||
@@ -997,23 +991,16 @@ router.get('/replenish/products', async (req, res) => {
|
|||||||
WHEN 'Critical' THEN 1
|
WHEN 'Critical' THEN 1
|
||||||
WHEN 'Reorder' THEN 2
|
WHEN 'Reorder' THEN 2
|
||||||
END,
|
END,
|
||||||
replenish_cost DESC
|
pm.reorder_qty * p.cost_price DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`, [limit]);
|
`, [limit]);
|
||||||
|
|
||||||
// Format response
|
res.json(products.map(p => ({
|
||||||
const response = products.map(p => ({
|
...p,
|
||||||
product_id: p.product_id,
|
stock_quantity: parseInt(p.stock_quantity) || 0,
|
||||||
SKU: p.SKU,
|
daily_sales_avg: parseFloat(p.daily_sales_avg) || 0,
|
||||||
title: p.title,
|
reorder_qty: parseInt(p.reorder_qty) || 0
|
||||||
current_stock: parseInt(p.current_stock) || 0,
|
})));
|
||||||
replenish_qty: parseInt(p.replenish_qty) || 0,
|
|
||||||
replenish_cost: parseFloat(p.replenish_cost) || 0,
|
|
||||||
replenish_retail: parseFloat(p.replenish_retail) || 0,
|
|
||||||
days_until_stockout: p.days_until_stockout
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching products to replenish:', err);
|
console.error('Error fetching products to replenish:', err);
|
||||||
res.status(500).json({ error: 'Failed to fetch products to replenish' });
|
res.status(500).json({ error: 'Failed to fetch products to replenish' });
|
||||||
|
|||||||
@@ -9,25 +9,25 @@ router.get('/trends', async (req, res) => {
|
|||||||
WITH MonthlyMetrics AS (
|
WITH MonthlyMetrics AS (
|
||||||
SELECT
|
SELECT
|
||||||
DATE(CONCAT(pta.year, '-', LPAD(pta.month, 2, '0'), '-01')) as date,
|
DATE(CONCAT(pta.year, '-', LPAD(pta.month, 2, '0'), '-01')) as date,
|
||||||
SUM(pta.total_revenue) as revenue,
|
CAST(COALESCE(SUM(pta.total_revenue), 0) AS DECIMAL(15,3)) as revenue,
|
||||||
SUM(pta.total_cost) as cost,
|
CAST(COALESCE(SUM(pta.total_cost), 0) AS DECIMAL(15,3)) as cost,
|
||||||
SUM(pm.inventory_value) as inventory_value,
|
CAST(COALESCE(SUM(pm.inventory_value), 0) AS DECIMAL(15,3)) as inventory_value,
|
||||||
CASE
|
CASE
|
||||||
WHEN SUM(pm.inventory_value) > 0
|
WHEN SUM(pm.inventory_value) > 0
|
||||||
THEN (SUM(pta.total_revenue - pta.total_cost) / SUM(pm.inventory_value)) * 100
|
THEN CAST((SUM(pta.total_revenue - pta.total_cost) / SUM(pm.inventory_value)) * 100 AS DECIMAL(15,3))
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END as gmroi
|
END as gmroi
|
||||||
FROM product_time_aggregates pta
|
FROM product_time_aggregates pta
|
||||||
JOIN product_metrics pm ON pta.product_id = pm.product_id
|
JOIN product_metrics pm ON pta.pid = pm.pid
|
||||||
WHERE (pta.year * 100 + pta.month) >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 12 MONTH), '%Y%m')
|
WHERE (pta.year * 100 + pta.month) >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 12 MONTH), '%Y%m')
|
||||||
GROUP BY pta.year, pta.month
|
GROUP BY pta.year, pta.month
|
||||||
ORDER BY date ASC
|
ORDER BY date ASC
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
DATE_FORMAT(date, '%b %y') as date,
|
DATE_FORMAT(date, '%b %y') as date,
|
||||||
ROUND(revenue, 2) as revenue,
|
revenue,
|
||||||
ROUND(inventory_value, 2) as inventory_value,
|
inventory_value,
|
||||||
ROUND(gmroi, 2) as gmroi
|
gmroi
|
||||||
FROM MonthlyMetrics
|
FROM MonthlyMetrics
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -37,15 +37,15 @@ router.get('/trends', async (req, res) => {
|
|||||||
const transformedData = {
|
const transformedData = {
|
||||||
revenue: rows.map(row => ({
|
revenue: rows.map(row => ({
|
||||||
date: row.date,
|
date: row.date,
|
||||||
value: parseFloat(row.revenue || 0)
|
value: parseFloat(row.revenue)
|
||||||
})),
|
})),
|
||||||
inventory_value: rows.map(row => ({
|
inventory_value: rows.map(row => ({
|
||||||
date: row.date,
|
date: row.date,
|
||||||
value: parseFloat(row.inventory_value || 0)
|
value: parseFloat(row.inventory_value)
|
||||||
})),
|
})),
|
||||||
gmroi: rows.map(row => ({
|
gmroi: rows.map(row => ({
|
||||||
date: row.date,
|
date: row.date,
|
||||||
value: parseFloat(row.gmroi || 0)
|
value: parseFloat(row.gmroi)
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ router.get('/', async (req, res) => {
|
|||||||
o1.status,
|
o1.status,
|
||||||
o1.payment_method,
|
o1.payment_method,
|
||||||
o1.shipping_method,
|
o1.shipping_method,
|
||||||
COUNT(o2.product_id) as items_count,
|
COUNT(o2.pid) as items_count,
|
||||||
SUM(o2.price * o2.quantity) as total_amount
|
CAST(SUM(o2.price * o2.quantity) AS DECIMAL(15,3)) as total_amount
|
||||||
FROM orders o1
|
FROM orders o1
|
||||||
JOIN orders o2 ON o1.order_number = o2.order_number
|
JOIN orders o2 ON o1.order_number = o2.order_number
|
||||||
WHERE ${conditions.join(' AND ')}
|
WHERE ${conditions.join(' AND ')}
|
||||||
@@ -101,7 +101,7 @@ router.get('/', async (req, res) => {
|
|||||||
WITH CurrentStats AS (
|
WITH CurrentStats AS (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT order_number) as total_orders,
|
COUNT(DISTINCT order_number) as total_orders,
|
||||||
SUM(price * quantity) as total_revenue
|
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as total_revenue
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE canceled = false
|
WHERE canceled = false
|
||||||
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
@@ -109,7 +109,7 @@ router.get('/', async (req, res) => {
|
|||||||
PreviousStats AS (
|
PreviousStats AS (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT order_number) as prev_orders,
|
COUNT(DISTINCT order_number) as prev_orders,
|
||||||
SUM(price * quantity) as prev_revenue
|
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as prev_revenue
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE canceled = false
|
WHERE canceled = false
|
||||||
AND DATE(date) BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
AND DATE(date) BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
@@ -117,7 +117,7 @@ router.get('/', async (req, res) => {
|
|||||||
OrderValues AS (
|
OrderValues AS (
|
||||||
SELECT
|
SELECT
|
||||||
order_number,
|
order_number,
|
||||||
SUM(price * quantity) as order_value
|
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as order_value
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE canceled = false
|
WHERE canceled = false
|
||||||
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
@@ -138,12 +138,12 @@ router.get('/', async (req, res) => {
|
|||||||
END as revenue_growth,
|
END as revenue_growth,
|
||||||
CASE
|
CASE
|
||||||
WHEN cs.total_orders > 0
|
WHEN cs.total_orders > 0
|
||||||
THEN (cs.total_revenue / cs.total_orders)
|
THEN CAST((cs.total_revenue / cs.total_orders) AS DECIMAL(15,3))
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END as average_order_value,
|
END as average_order_value,
|
||||||
CASE
|
CASE
|
||||||
WHEN ps.prev_orders > 0
|
WHEN ps.prev_orders > 0
|
||||||
THEN (ps.prev_revenue / ps.prev_orders)
|
THEN CAST((ps.prev_revenue / ps.prev_orders) AS DECIMAL(15,3))
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END as prev_average_order_value
|
END as prev_average_order_value
|
||||||
FROM CurrentStats cs
|
FROM CurrentStats cs
|
||||||
@@ -199,8 +199,8 @@ router.get('/:orderNumber', async (req, res) => {
|
|||||||
o1.shipping_method,
|
o1.shipping_method,
|
||||||
o1.shipping_address,
|
o1.shipping_address,
|
||||||
o1.billing_address,
|
o1.billing_address,
|
||||||
COUNT(o2.product_id) as items_count,
|
COUNT(o2.pid) as items_count,
|
||||||
SUM(o2.price * o2.quantity) as total_amount
|
CAST(SUM(o2.price * o2.quantity) AS DECIMAL(15,3)) as total_amount
|
||||||
FROM orders o1
|
FROM orders o1
|
||||||
JOIN orders o2 ON o1.order_number = o2.order_number
|
JOIN orders o2 ON o1.order_number = o2.order_number
|
||||||
WHERE o1.order_number = ? AND o1.canceled = false
|
WHERE o1.order_number = ? AND o1.canceled = false
|
||||||
@@ -222,14 +222,14 @@ router.get('/:orderNumber', async (req, res) => {
|
|||||||
// Get order items
|
// Get order items
|
||||||
const [itemRows] = await pool.query(`
|
const [itemRows] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
o.product_id,
|
o.pid,
|
||||||
p.title,
|
p.title,
|
||||||
p.sku,
|
p.SKU,
|
||||||
o.quantity,
|
o.quantity,
|
||||||
o.price,
|
o.price,
|
||||||
(o.price * o.quantity) as total
|
CAST((o.price * o.quantity) AS DECIMAL(15,3)) as total
|
||||||
FROM orders o
|
FROM orders o
|
||||||
JOIN products p ON o.product_id = p.product_id
|
JOIN products p ON o.pid = p.pid
|
||||||
WHERE o.order_number = ? AND o.canceled = false
|
WHERE o.order_number = ? AND o.canceled = false
|
||||||
`, [req.params.orderNumber]);
|
`, [req.params.orderNumber]);
|
||||||
|
|
||||||
|
|||||||
@@ -20,15 +20,13 @@ router.get('/brands', async (req, res) => {
|
|||||||
console.log('Fetching brands from database...');
|
console.log('Fetching brands from database...');
|
||||||
|
|
||||||
const [results] = await pool.query(`
|
const [results] = await pool.query(`
|
||||||
SELECT DISTINCT p.brand
|
SELECT DISTINCT COALESCE(p.brand, 'Unbranded') as brand
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN purchase_orders po ON p.product_id = po.product_id
|
JOIN purchase_orders po ON p.pid = po.pid
|
||||||
WHERE p.brand IS NOT NULL
|
WHERE p.visible = true
|
||||||
AND p.brand != ''
|
GROUP BY COALESCE(p.brand, 'Unbranded')
|
||||||
AND p.visible = true
|
|
||||||
GROUP BY p.brand
|
|
||||||
HAVING SUM(po.cost_price * po.received) >= 500
|
HAVING SUM(po.cost_price * po.received) >= 500
|
||||||
ORDER BY p.brand
|
ORDER BY COALESCE(p.brand, 'Unbranded')
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log(`Found ${results.length} brands:`, results.slice(0, 3));
|
console.log(`Found ${results.length} brands:`, results.slice(0, 3));
|
||||||
@@ -147,9 +145,9 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
// Get total count for pagination
|
// Get total count for pagination
|
||||||
const countQuery = `
|
const countQuery = `
|
||||||
SELECT COUNT(DISTINCT p.product_id) as total
|
SELECT COUNT(DISTINCT p.pid) as total
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
${whereClause}
|
${whereClause}
|
||||||
`;
|
`;
|
||||||
const [countResult] = await pool.query(countQuery, params);
|
const [countResult] = await pool.query(countQuery, params);
|
||||||
@@ -163,26 +161,26 @@ router.get('/', async (req, res) => {
|
|||||||
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
|
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
|
||||||
);
|
);
|
||||||
const [brands] = await pool.query(
|
const [brands] = await pool.query(
|
||||||
'SELECT DISTINCT brand FROM products WHERE visible = true AND brand IS NOT NULL AND brand != "" ORDER BY brand'
|
'SELECT DISTINCT COALESCE(brand, \'Unbranded\') as brand FROM products WHERE visible = true ORDER BY brand'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Main query with all fields
|
// Main query with all fields
|
||||||
const query = `
|
const query = `
|
||||||
WITH product_thresholds AS (
|
WITH product_thresholds AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
(SELECT overstock_days FROM stock_thresholds st
|
(SELECT overstock_days FROM stock_thresholds st
|
||||||
WHERE st.category_id IN (
|
WHERE st.cat_id IN (
|
||||||
SELECT pc.category_id
|
SELECT pc.cat_id
|
||||||
FROM product_categories pc
|
FROM product_categories pc
|
||||||
WHERE pc.product_id = p.product_id
|
WHERE pc.pid = p.pid
|
||||||
)
|
)
|
||||||
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
||||||
ORDER BY st.vendor IS NULL
|
ORDER BY st.vendor IS NULL
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
(SELECT overstock_days FROM stock_thresholds st
|
(SELECT overstock_days FROM stock_thresholds st
|
||||||
WHERE st.category_id IS NULL
|
WHERE st.cat_id IS NULL
|
||||||
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
||||||
ORDER BY st.vendor IS NULL
|
ORDER BY st.vendor IS NULL
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
@@ -192,6 +190,7 @@ router.get('/', async (req, res) => {
|
|||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.*,
|
p.*,
|
||||||
|
COALESCE(p.brand, 'Unbranded') as brand,
|
||||||
GROUP_CONCAT(DISTINCT c.name) as categories,
|
GROUP_CONCAT(DISTINCT c.name) as categories,
|
||||||
pm.daily_sales_avg,
|
pm.daily_sales_avg,
|
||||||
pm.weekly_sales_avg,
|
pm.weekly_sales_avg,
|
||||||
@@ -205,10 +204,10 @@ router.get('/', async (req, res) => {
|
|||||||
pm.reorder_point,
|
pm.reorder_point,
|
||||||
pm.safety_stock,
|
pm.safety_stock,
|
||||||
pm.avg_margin_percent,
|
pm.avg_margin_percent,
|
||||||
pm.total_revenue,
|
CAST(pm.total_revenue AS DECIMAL(15,3)) as total_revenue,
|
||||||
pm.inventory_value,
|
CAST(pm.inventory_value AS DECIMAL(15,3)) as inventory_value,
|
||||||
pm.cost_of_goods_sold,
|
CAST(pm.cost_of_goods_sold AS DECIMAL(15,3)) as cost_of_goods_sold,
|
||||||
pm.gross_profit,
|
CAST(pm.gross_profit AS DECIMAL(15,3)) as gross_profit,
|
||||||
pm.gmroi,
|
pm.gmroi,
|
||||||
pm.avg_lead_time_days,
|
pm.avg_lead_time_days,
|
||||||
pm.last_purchase_date,
|
pm.last_purchase_date,
|
||||||
@@ -223,12 +222,12 @@ router.get('/', async (req, res) => {
|
|||||||
pm.overstocked_amt,
|
pm.overstocked_amt,
|
||||||
COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
|
COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||||
LEFT JOIN categories c ON pc.category_id = c.id
|
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
LEFT JOIN product_thresholds pt ON p.product_id = pt.product_id
|
LEFT JOIN product_thresholds pt ON p.pid = pt.pid
|
||||||
${whereClause}
|
${whereClause}
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid
|
||||||
ORDER BY ${sortColumn} ${sortDirection}
|
ORDER BY ${sortColumn} ${sortDirection}
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`;
|
`;
|
||||||
@@ -308,7 +307,7 @@ router.get('/trending', async (req, res) => {
|
|||||||
SELECT COUNT(*) as count,
|
SELECT COUNT(*) as count,
|
||||||
MAX(total_revenue) as max_revenue,
|
MAX(total_revenue) as max_revenue,
|
||||||
MAX(daily_sales_avg) as max_daily_sales,
|
MAX(daily_sales_avg) as max_daily_sales,
|
||||||
COUNT(DISTINCT product_id) as products_with_metrics
|
COUNT(DISTINCT pid) as products_with_metrics
|
||||||
FROM product_metrics
|
FROM product_metrics
|
||||||
WHERE total_revenue > 0 OR daily_sales_avg > 0
|
WHERE total_revenue > 0 OR daily_sales_avg > 0
|
||||||
`);
|
`);
|
||||||
@@ -322,7 +321,7 @@ router.get('/trending', async (req, res) => {
|
|||||||
// Get trending products
|
// Get trending products
|
||||||
const [rows] = await pool.query(`
|
const [rows] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.sku,
|
p.sku,
|
||||||
p.title,
|
p.title,
|
||||||
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
||||||
@@ -334,7 +333,7 @@ router.get('/trending', async (req, res) => {
|
|||||||
END as growth_rate,
|
END as growth_rate,
|
||||||
COALESCE(pm.total_revenue, 0) as total_revenue
|
COALESCE(pm.total_revenue, 0) as total_revenue
|
||||||
FROM products p
|
FROM products p
|
||||||
INNER JOIN product_metrics pm ON p.product_id = pm.product_id
|
INNER JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0)
|
WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0)
|
||||||
AND p.visible = true
|
AND p.visible = true
|
||||||
ORDER BY growth_rate DESC
|
ORDER BY growth_rate DESC
|
||||||
@@ -378,11 +377,11 @@ router.get('/:id', async (req, res) => {
|
|||||||
pm.cost_of_goods_sold,
|
pm.cost_of_goods_sold,
|
||||||
pm.gross_profit
|
pm.gross_profit
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||||
LEFT JOIN categories c ON pc.category_id = c.id
|
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE p.product_id = ? AND p.visible = true
|
WHERE p.pid = ? AND p.visible = true
|
||||||
GROUP BY p.product_id`,
|
GROUP BY p.pid`,
|
||||||
[req.params.id]
|
[req.params.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -399,7 +398,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
// Transform the data to match frontend expectations
|
// Transform the data to match frontend expectations
|
||||||
const product = {
|
const product = {
|
||||||
// Basic product info
|
// Basic product info
|
||||||
product_id: rows[0].product_id,
|
pid: rows[0].pid,
|
||||||
title: rows[0].title,
|
title: rows[0].title,
|
||||||
SKU: rows[0].SKU,
|
SKU: rows[0].SKU,
|
||||||
barcode: rows[0].barcode,
|
barcode: rows[0].barcode,
|
||||||
@@ -532,7 +531,7 @@ router.put('/:id', async (req, res) => {
|
|||||||
categories = ?,
|
categories = ?,
|
||||||
visible = ?,
|
visible = ?,
|
||||||
managing_stock = ?
|
managing_stock = ?
|
||||||
WHERE product_id = ?`,
|
WHERE pid = ?`,
|
||||||
[
|
[
|
||||||
title,
|
title,
|
||||||
sku,
|
sku,
|
||||||
@@ -570,7 +569,7 @@ router.get('/:id/metrics', async (req, res) => {
|
|||||||
const [metrics] = await pool.query(`
|
const [metrics] = await pool.query(`
|
||||||
WITH inventory_status AS (
|
WITH inventory_status AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
CASE
|
CASE
|
||||||
WHEN pm.daily_sales_avg = 0 THEN 'New'
|
WHEN pm.daily_sales_avg = 0 THEN 'New'
|
||||||
WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * 7) THEN 'Critical'
|
WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * 7) THEN 'Critical'
|
||||||
@@ -579,8 +578,8 @@ router.get('/:id/metrics', async (req, res) => {
|
|||||||
ELSE 'Healthy'
|
ELSE 'Healthy'
|
||||||
END as calculated_status
|
END as calculated_status
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.product_id = ?
|
WHERE p.pid = ?
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
||||||
@@ -604,9 +603,9 @@ router.get('/:id/metrics', async (req, res) => {
|
|||||||
COALESCE(pm.reorder_qty, 0) as reorder_qty,
|
COALESCE(pm.reorder_qty, 0) as reorder_qty,
|
||||||
COALESCE(pm.overstocked_amt, 0) as overstocked_amt
|
COALESCE(pm.overstocked_amt, 0) as overstocked_amt
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN inventory_status is ON p.product_id = is.product_id
|
LEFT JOIN inventory_status is ON p.pid = is.pid
|
||||||
WHERE p.product_id = ?
|
WHERE p.pid = ?
|
||||||
`, [id]);
|
`, [id]);
|
||||||
|
|
||||||
if (!metrics.length) {
|
if (!metrics.length) {
|
||||||
@@ -660,7 +659,7 @@ router.get('/:id/time-series', async (req, res) => {
|
|||||||
profit_margin,
|
profit_margin,
|
||||||
inventory_value
|
inventory_value
|
||||||
FROM product_time_aggregates
|
FROM product_time_aggregates
|
||||||
WHERE product_id = ?
|
WHERE pid = ?
|
||||||
ORDER BY year DESC, month DESC
|
ORDER BY year DESC, month DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
)
|
)
|
||||||
@@ -707,7 +706,7 @@ router.get('/:id/time-series', async (req, res) => {
|
|||||||
status,
|
status,
|
||||||
payment_method
|
payment_method
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE product_id = ?
|
WHERE pid = ?
|
||||||
AND canceled = false
|
AND canceled = false
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -733,7 +732,7 @@ router.get('/:id/time-series', async (req, res) => {
|
|||||||
ELSE NULL
|
ELSE NULL
|
||||||
END as lead_time_days
|
END as lead_time_days
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE product_id = ?
|
WHERE pid = ?
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`, [id]);
|
`, [id]);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ router.get('/', async (req, res) => {
|
|||||||
po_id,
|
po_id,
|
||||||
SUM(ordered) as total_ordered,
|
SUM(ordered) as total_ordered,
|
||||||
SUM(received) as total_received,
|
SUM(received) as total_received,
|
||||||
SUM(ordered * cost_price) as total_cost
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost
|
||||||
FROM purchase_orders po
|
FROM purchase_orders po
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
GROUP BY po_id
|
GROUP BY po_id
|
||||||
@@ -54,8 +54,8 @@ router.get('/', async (req, res) => {
|
|||||||
ROUND(
|
ROUND(
|
||||||
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
||||||
) as fulfillment_rate,
|
) as fulfillment_rate,
|
||||||
SUM(total_cost) as total_value,
|
CAST(SUM(total_cost) AS DECIMAL(15,3)) as total_value,
|
||||||
ROUND(AVG(total_cost), 2) as avg_cost
|
CAST(AVG(total_cost) AS DECIMAL(15,3)) as avg_cost
|
||||||
FROM po_totals
|
FROM po_totals
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
@@ -78,9 +78,9 @@ router.get('/', async (req, res) => {
|
|||||||
vendor,
|
vendor,
|
||||||
date,
|
date,
|
||||||
status,
|
status,
|
||||||
COUNT(DISTINCT product_id) as total_items,
|
COUNT(DISTINCT pid) as total_items,
|
||||||
SUM(ordered) as total_quantity,
|
SUM(ordered) as total_quantity,
|
||||||
SUM(ordered * cost_price) as total_cost,
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost,
|
||||||
SUM(received) as total_received,
|
SUM(received) as total_received,
|
||||||
ROUND(
|
ROUND(
|
||||||
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
||||||
@@ -104,8 +104,8 @@ router.get('/', async (req, res) => {
|
|||||||
CASE
|
CASE
|
||||||
WHEN ? = 'order_date' THEN date
|
WHEN ? = 'order_date' THEN date
|
||||||
WHEN ? = 'vendor_name' THEN vendor
|
WHEN ? = 'vendor_name' THEN vendor
|
||||||
WHEN ? = 'total_cost' THEN CAST(total_cost AS DECIMAL(15,2))
|
WHEN ? = 'total_cost' THEN CAST(total_cost AS DECIMAL(15,3))
|
||||||
WHEN ? = 'total_received' THEN CAST(total_received AS DECIMAL(15,2))
|
WHEN ? = 'total_received' THEN CAST(total_received AS DECIMAL(15,3))
|
||||||
WHEN ? = 'total_items' THEN CAST(total_items AS SIGNED)
|
WHEN ? = 'total_items' THEN CAST(total_items AS SIGNED)
|
||||||
WHEN ? = 'total_quantity' THEN CAST(total_quantity AS SIGNED)
|
WHEN ? = 'total_quantity' THEN CAST(total_quantity AS SIGNED)
|
||||||
WHEN ? = 'fulfillment_rate' THEN CAST(fulfillment_rate AS DECIMAL(5,3))
|
WHEN ? = 'fulfillment_rate' THEN CAST(fulfillment_rate AS DECIMAL(5,3))
|
||||||
@@ -203,10 +203,10 @@ router.get('/vendor-metrics', async (req, res) => {
|
|||||||
ROUND(
|
ROUND(
|
||||||
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
||||||
) as fulfillment_rate,
|
) as fulfillment_rate,
|
||||||
ROUND(
|
CAST(ROUND(
|
||||||
SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2
|
SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2
|
||||||
) as avg_unit_cost,
|
) AS DECIMAL(15,3)) as avg_unit_cost,
|
||||||
SUM(ordered * cost_price) as total_spend,
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend,
|
||||||
ROUND(
|
ROUND(
|
||||||
AVG(NULLIF(delivery_days, 0)), 1
|
AVG(NULLIF(delivery_days, 0)), 1
|
||||||
) as avg_delivery_days
|
) as avg_delivery_days
|
||||||
@@ -244,18 +244,15 @@ router.get('/cost-analysis', async (req, res) => {
|
|||||||
const [analysis] = await pool.query(`
|
const [analysis] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
c.name as categories,
|
c.name as categories,
|
||||||
COUNT(DISTINCT po.product_id) as unique_products,
|
COUNT(DISTINCT po.pid) as unique_products,
|
||||||
ROUND(AVG(po.cost_price), 2) as avg_cost,
|
CAST(AVG(po.cost_price) AS DECIMAL(15,3)) as avg_cost,
|
||||||
MIN(po.cost_price) as min_cost,
|
CAST(MIN(po.cost_price) AS DECIMAL(15,3)) as min_cost,
|
||||||
MAX(po.cost_price) as max_cost,
|
CAST(MAX(po.cost_price) AS DECIMAL(15,3)) as max_cost,
|
||||||
ROUND(
|
CAST(STDDEV(po.cost_price) AS DECIMAL(15,3)) as cost_std_dev,
|
||||||
STDDEV(po.cost_price), 2
|
CAST(SUM(po.ordered * po.cost_price) AS DECIMAL(15,3)) as total_spend
|
||||||
) as cost_variance,
|
|
||||||
SUM(po.ordered * po.cost_price) as total_spend
|
|
||||||
FROM purchase_orders po
|
FROM purchase_orders po
|
||||||
JOIN products p ON po.product_id = p.product_id
|
JOIN product_categories pc ON po.pid = pc.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
JOIN categories c ON pc.category_id = c.id
|
|
||||||
GROUP BY c.name
|
GROUP BY c.name
|
||||||
ORDER BY total_spend DESC
|
ORDER BY total_spend DESC
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ router.get('/', async (req, res) => {
|
|||||||
const [costMetrics] = await pool.query(`
|
const [costMetrics] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vendor,
|
vendor,
|
||||||
ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost,
|
CAST(ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) AS DECIMAL(15,3)) as avg_unit_cost,
|
||||||
SUM(ordered * cost_price) as total_spend
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE status = 'closed'
|
WHERE status = 'closed'
|
||||||
AND cost_price IS NOT NULL
|
AND cost_price IS NOT NULL
|
||||||
@@ -56,9 +56,9 @@ router.get('/', async (req, res) => {
|
|||||||
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75
|
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75
|
||||||
THEN p.vendor
|
THEN p.vendor
|
||||||
END) as activeVendors,
|
END) as activeVendors,
|
||||||
ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1) as avgLeadTime,
|
COALESCE(ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1), 0) as avgLeadTime,
|
||||||
ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1) as avgFillRate,
|
COALESCE(ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1), 0) as avgFillRate,
|
||||||
ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1) as avgOnTimeDelivery
|
COALESCE(ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1), 0) as avgOnTimeDelivery
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
|
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
|
||||||
WHERE p.vendor IS NOT NULL AND p.vendor != ''
|
WHERE p.vendor IS NOT NULL AND p.vendor != ''
|
||||||
@@ -67,8 +67,8 @@ router.get('/', async (req, res) => {
|
|||||||
// Get overall cost metrics
|
// Get overall cost metrics
|
||||||
const [overallCostMetrics] = await pool.query(`
|
const [overallCostMetrics] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost,
|
CAST(ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) AS DECIMAL(15,3)) as avg_unit_cost,
|
||||||
SUM(ordered * cost_price) as total_spend
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE status = 'closed'
|
WHERE status = 'closed'
|
||||||
AND cost_price IS NOT NULL
|
AND cost_price IS NOT NULL
|
||||||
@@ -78,25 +78,25 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
vendors: vendors.map(vendor => ({
|
vendors: vendors.map(vendor => ({
|
||||||
vendor_id: vendor.vendor_id || vendor.name,
|
vendor_id: vendor.name,
|
||||||
name: vendor.name,
|
name: vendor.name,
|
||||||
status: vendor.status,
|
status: vendor.status,
|
||||||
avg_lead_time_days: parseFloat(vendor.avg_lead_time_days || 0),
|
avg_lead_time_days: parseFloat(vendor.avg_lead_time_days),
|
||||||
on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate || 0),
|
on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate),
|
||||||
order_fill_rate: parseFloat(vendor.order_fill_rate || 0),
|
order_fill_rate: parseFloat(vendor.order_fill_rate),
|
||||||
total_orders: parseInt(vendor.total_orders || 0),
|
total_orders: parseInt(vendor.total_orders),
|
||||||
active_products: parseInt(vendor.active_products || 0),
|
active_products: parseInt(vendor.active_products),
|
||||||
avg_unit_cost: parseFloat(costMetricsMap[vendor.name]?.avg_unit_cost || 0),
|
avg_unit_cost: parseFloat(costMetricsMap[vendor.name]?.avg_unit_cost || 0),
|
||||||
total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0)
|
total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0)
|
||||||
})),
|
})),
|
||||||
stats: {
|
stats: {
|
||||||
totalVendors: parseInt(stats[0].totalVendors || 0),
|
totalVendors: parseInt(stats[0].totalVendors),
|
||||||
activeVendors: parseInt(stats[0].activeVendors || 0),
|
activeVendors: parseInt(stats[0].activeVendors),
|
||||||
avgLeadTime: parseFloat(stats[0].avgLeadTime || 0),
|
avgLeadTime: parseFloat(stats[0].avgLeadTime),
|
||||||
avgFillRate: parseFloat(stats[0].avgFillRate || 0),
|
avgFillRate: parseFloat(stats[0].avgFillRate),
|
||||||
avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery || 0),
|
avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery),
|
||||||
avgUnitCost: parseFloat(overallCostMetrics[0].avg_unit_cost || 0),
|
avgUnitCost: parseFloat(overallCostMetrics[0].avg_unit_cost),
|
||||||
totalSpend: parseFloat(overallCostMetrics[0].total_spend || 0)
|
totalSpend: parseFloat(overallCostMetrics[0].total_spend)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,14 +6,21 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import { formatCurrency } from "@/lib/utils"
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
|
||||||
interface BestSellerProduct {
|
interface Product {
|
||||||
product_id: number
|
pid: number;
|
||||||
sku: string
|
sku: string;
|
||||||
title: string
|
title: string;
|
||||||
units_sold: number
|
units_sold: number;
|
||||||
revenue: number
|
revenue: number;
|
||||||
profit: number
|
profit: number;
|
||||||
growth_rate: number
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
cat_id: number;
|
||||||
|
name: string;
|
||||||
|
total_revenue: number;
|
||||||
|
total_profit: number;
|
||||||
|
total_units: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BestSellerBrand {
|
interface BestSellerBrand {
|
||||||
@@ -25,18 +32,18 @@ interface BestSellerBrand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BestSellerCategory {
|
interface BestSellerCategory {
|
||||||
category_id: number
|
cat_id: number;
|
||||||
name: string
|
name: string;
|
||||||
units_sold: number
|
units_sold: number;
|
||||||
revenue: number
|
revenue: number;
|
||||||
profit: number
|
profit: number;
|
||||||
growth_rate: number
|
growth_rate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BestSellersData {
|
interface BestSellersData {
|
||||||
products: BestSellerProduct[]
|
products: Product[]
|
||||||
brands: BestSellerBrand[]
|
brands: BestSellerBrand[]
|
||||||
categories: BestSellerCategory[]
|
categories: Category[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BestSellers() {
|
export function BestSellers() {
|
||||||
@@ -70,41 +77,29 @@ export function BestSellers() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[40%]">Product</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
<TableHead className="text-right">Units Sold</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
<TableHead className="text-right">Revenue</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
<TableHead className="text-right">Profit</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.products.map((product) => (
|
{data?.products.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell className="w-[40%]">
|
<TableCell>
|
||||||
<div>
|
|
||||||
<a
|
<a
|
||||||
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-medium hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{product.title}
|
{product.title}
|
||||||
</a>
|
</a>
|
||||||
<p className="text-sm text-muted-foreground">{product.sku}</p>
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{product.units_sold.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{formatCurrency(product.revenue)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{formatCurrency(product.profit)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{product.growth_rate > 0 ? '+' : ''}{product.growth_rate.toFixed(1)}%
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{product.units_sold}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(product.revenue)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(product.profit)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -154,31 +149,19 @@ export function BestSellers() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[40%]">Category</TableHead>
|
<TableHead>Category</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
<TableHead className="text-right">Units Sold</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
<TableHead className="text-right">Revenue</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
<TableHead className="text-right">Profit</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.categories.map((category) => (
|
{data?.categories.map((category) => (
|
||||||
<TableRow key={category.category_id}>
|
<TableRow key={category.cat_id}>
|
||||||
<TableCell className="w-[40%]">
|
<TableCell>{category.name}</TableCell>
|
||||||
<p className="font-medium">{category.name}</p>
|
<TableCell className="text-right">{category.total_units}</TableCell>
|
||||||
</TableCell>
|
<TableCell className="text-right">{formatCurrency(category.total_revenue)}</TableCell>
|
||||||
<TableCell className="w-[15%] text-right">
|
<TableCell className="text-right">{formatCurrency(category.total_profit)}</TableCell>
|
||||||
{category.units_sold.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{formatCurrency(category.revenue)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{formatCurrency(category.profit)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{category.growth_rate > 0 ? '+' : ''}{category.growth_rate.toFixed(1)}%
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -12,19 +12,20 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { AlertCircle, AlertTriangle } from "lucide-react"
|
import { AlertCircle, AlertTriangle } from "lucide-react"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
|
|
||||||
interface LowStockProduct {
|
interface Product {
|
||||||
product_id: number
|
pid: number;
|
||||||
SKU: string
|
sku: string;
|
||||||
title: string
|
title: string;
|
||||||
stock_quantity: number
|
stock_quantity: number;
|
||||||
reorder_qty: number
|
daily_sales_avg: number;
|
||||||
days_of_inventory: number
|
days_of_inventory: number;
|
||||||
stock_status: "Critical" | "Reorder"
|
reorder_qty: number;
|
||||||
daily_sales_avg: number
|
last_purchase_date: string | null;
|
||||||
|
lead_time_status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LowStockAlerts() {
|
export function LowStockAlerts() {
|
||||||
const { data: products } = useQuery<LowStockProduct[]>({
|
const { data: products } = useQuery<Product[]>({
|
||||||
queryKey: ["low-stock"],
|
queryKey: ["low-stock"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/low-stock/products`)
|
const response = await fetch(`${config.apiUrl}/dashboard/low-stock/products`)
|
||||||
@@ -45,35 +46,37 @@ export function LowStockAlerts() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>SKU</TableHead>
|
|
||||||
<TableHead>Product</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead className="text-right">Stock</TableHead>
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
<TableHead className="text-right">Status</TableHead>
|
<TableHead className="text-right">Daily Sales</TableHead>
|
||||||
|
<TableHead className="text-right">Days Left</TableHead>
|
||||||
|
<TableHead className="text-right">Reorder Qty</TableHead>
|
||||||
|
<TableHead>Last Purchase</TableHead>
|
||||||
|
<TableHead>Lead Time</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products?.map((product) => (
|
{products?.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell className="font-medium">{product.SKU}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{product.title}</TableCell>
|
<a
|
||||||
<TableCell className="text-right">
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
{product.stock_quantity} / {product.reorder_qty}
|
target="_blank"
|
||||||
</TableCell>
|
rel="noopener noreferrer"
|
||||||
<TableCell className="text-right">
|
className="hover:underline"
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={
|
|
||||||
product.stock_status === "Critical"
|
|
||||||
? "border-destructive text-destructive"
|
|
||||||
: "border-warning text-warning"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{product.stock_status === "Critical" ? (
|
{product.title}
|
||||||
<AlertCircle className="mr-1 h-3 w-3" />
|
</a>
|
||||||
) : (
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
</TableCell>
|
||||||
)}
|
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||||
{product.stock_status}
|
<TableCell className="text-right">{product.daily_sales_avg.toFixed(1)}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.days_of_inventory.toFixed(1)}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.reorder_qty}</TableCell>
|
||||||
|
<TableCell>{product.last_purchase_date ? formatDate(product.last_purchase_date) : '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={getLeadTimeVariant(product.lead_time_status)}>
|
||||||
|
{product.lead_time_status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import { formatCurrency } from "@/lib/utils"
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
|
||||||
interface OverstockedProduct {
|
interface Product {
|
||||||
product_id: number
|
pid: number;
|
||||||
SKU: string
|
sku: string;
|
||||||
title: string
|
title: string;
|
||||||
stock_quantity: number
|
stock_quantity: number;
|
||||||
overstocked_amt: number
|
overstocked_amt: number;
|
||||||
excess_cost: number
|
excess_cost: number;
|
||||||
excess_retail: number
|
excess_retail: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopOverstockedProducts() {
|
export function TopOverstockedProducts() {
|
||||||
const { data } = useQuery<OverstockedProduct[]>({
|
const { data } = useQuery<Product[]>({
|
||||||
queryKey: ["top-overstocked-products"],
|
queryKey: ["top-overstocked-products"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`)
|
const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`)
|
||||||
@@ -38,40 +38,30 @@ export function TopOverstockedProducts() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Product</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead className="text-right">Current Stock</TableHead>
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
<TableHead className="text-right">Overstock Amt</TableHead>
|
<TableHead className="text-right">Excess</TableHead>
|
||||||
<TableHead className="text-right">Overstock Cost</TableHead>
|
<TableHead className="text-right">Cost</TableHead>
|
||||||
<TableHead className="text-right">Overstock Retail</TableHead>
|
<TableHead className="text-right">Retail</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.map((product) => (
|
{data?.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
|
||||||
<a
|
<a
|
||||||
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-medium hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{product.title}
|
{product.title}
|
||||||
</a>
|
</a>
|
||||||
<p className="text-sm text-muted-foreground">{product.SKU}</p>
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{product.stock_quantity.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{product.overstocked_amt.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatCurrency(product.excess_cost)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatCurrency(product.excess_retail)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.overstocked_amt}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(product.excess_cost)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(product.excess_retail)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -3,20 +3,19 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import { formatCurrency } from "@/lib/utils"
|
|
||||||
|
|
||||||
interface ReplenishProduct {
|
interface Product {
|
||||||
product_id: number
|
pid: number;
|
||||||
SKU: string
|
sku: string;
|
||||||
title: string
|
title: string;
|
||||||
current_stock: number
|
stock_quantity: number;
|
||||||
replenish_qty: number
|
daily_sales_avg: number;
|
||||||
replenish_cost: number
|
reorder_qty: number;
|
||||||
replenish_retail: number
|
last_purchase_date: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopReplenishProducts() {
|
export function TopReplenishProducts() {
|
||||||
const { data } = useQuery<ReplenishProduct[]>({
|
const { data } = useQuery<Product[]>({
|
||||||
queryKey: ["top-replenish-products"],
|
queryKey: ["top-replenish-products"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`)
|
const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`)
|
||||||
@@ -39,39 +38,29 @@ export function TopReplenishProducts() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Product</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead className="text-right">Stock</TableHead>
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
<TableHead className="text-right">Replenish</TableHead>
|
<TableHead className="text-right">Daily Sales</TableHead>
|
||||||
<TableHead className="text-right">Cost</TableHead>
|
<TableHead className="text-right">Reorder Qty</TableHead>
|
||||||
<TableHead className="text-right">Retail</TableHead>
|
<TableHead>Last Purchase</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.map((product) => (
|
{data?.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
|
||||||
<a
|
<a
|
||||||
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-medium hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{product.title}
|
{product.title}
|
||||||
</a>
|
</a>
|
||||||
<p className="text-sm text-muted-foreground">{product.SKU}</p>
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{product.current_stock.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{product.replenish_qty.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatCurrency(product.replenish_cost)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatCurrency(product.replenish_retail)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.daily_sales_avg.toFixed(1)}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.reorder_qty}</TableCell>
|
||||||
|
<TableCell>{product.last_purchase_date ? product.last_purchase_date : '-'}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -11,18 +11,18 @@ import {
|
|||||||
import { TrendingUp, TrendingDown } from "lucide-react"
|
import { TrendingUp, TrendingDown } from "lucide-react"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
|
|
||||||
interface TrendingProduct {
|
interface Product {
|
||||||
product_id: number
|
pid: number;
|
||||||
sku: string
|
sku: string;
|
||||||
title: string
|
title: string;
|
||||||
daily_sales_avg: string
|
daily_sales_avg: number;
|
||||||
weekly_sales_avg: string
|
weekly_sales_avg: number;
|
||||||
growth_rate: string
|
growth_rate: number;
|
||||||
total_revenue: string
|
total_revenue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrendingProducts() {
|
export function TrendingProducts() {
|
||||||
const { data: products } = useQuery<TrendingProduct[]>({
|
const { data: products } = useQuery<Product[]>({
|
||||||
queryKey: ["trending-products"],
|
queryKey: ["trending-products"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/products/trending`)
|
const response = await fetch(`${config.apiUrl}/products/trending`)
|
||||||
@@ -33,7 +33,6 @@ export function TrendingProducts() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const formatPercent = (value: number) =>
|
const formatPercent = (value: number) =>
|
||||||
new Intl.NumberFormat("en-US", {
|
new Intl.NumberFormat("en-US", {
|
||||||
style: "percent",
|
style: "percent",
|
||||||
@@ -42,6 +41,14 @@ export function TrendingProducts() {
|
|||||||
signDisplay: "exceptZero",
|
signDisplay: "exceptZero",
|
||||||
}).format(value / 100)
|
}).format(value / 100)
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) =>
|
||||||
|
new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -59,7 +66,7 @@ export function TrendingProducts() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products?.map((product) => (
|
{products?.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{product.title}</span>
|
<span className="font-medium">{product.title}</span>
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import { ArrowUpDown, ChevronDown, ChevronRight } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
interface ProductDetail {
|
|
||||||
product_id: string;
|
interface Product {
|
||||||
name: string;
|
pid: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
|
title: string;
|
||||||
stock_quantity: number;
|
stock_quantity: number;
|
||||||
total_sold: number;
|
daily_sales_avg: number;
|
||||||
avg_price: number;
|
forecast_units: number;
|
||||||
first_received_date: string;
|
forecast_revenue: number;
|
||||||
|
confidence_level: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForecastItem {
|
export interface ForecastItem {
|
||||||
@@ -20,7 +22,7 @@ export interface ForecastItem {
|
|||||||
numProducts: number;
|
numProducts: number;
|
||||||
avgPrice: number;
|
avgPrice: number;
|
||||||
avgTotalSold: number;
|
avgTotalSold: number;
|
||||||
products?: ProductDetail[];
|
products?: Product[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const columns: ColumnDef<ForecastItem>[] = [
|
export const columns: ColumnDef<ForecastItem>[] = [
|
||||||
@@ -147,23 +149,33 @@ export const renderSubComponent = ({ row }: { row: any }) => {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Product Name</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead>SKU</TableHead>
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
<TableHead>First Received</TableHead>
|
<TableHead className="text-right">Daily Sales</TableHead>
|
||||||
<TableHead>Stock Quantity</TableHead>
|
<TableHead className="text-right">Forecast Units</TableHead>
|
||||||
<TableHead>Total Sold</TableHead>
|
<TableHead className="text-right">Forecast Revenue</TableHead>
|
||||||
<TableHead>Average Price</TableHead>
|
<TableHead className="text-right">Confidence</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products.map((product: ProductDetail) => (
|
{products.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell>{product.name}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{product.sku}</TableCell>
|
<a
|
||||||
<TableCell>{product.first_received_date}</TableCell>
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
<TableCell>{product.stock_quantity.toLocaleString()}</TableCell>
|
target="_blank"
|
||||||
<TableCell>{product.total_sold.toLocaleString()}</TableCell>
|
rel="noopener noreferrer"
|
||||||
<TableCell>${product.avg_price.toFixed(2)}</TableCell>
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{product.title}
|
||||||
|
</a>
|
||||||
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.daily_sales_avg.toFixed(1)}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.forecast_units.toFixed(1)}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.forecast_revenue.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.confidence_level.toFixed(1)}%</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
|||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
product_id: number;
|
pid: number;
|
||||||
title: string;
|
title: string;
|
||||||
SKU: string;
|
SKU: string;
|
||||||
barcode: string;
|
barcode: string;
|
||||||
@@ -38,7 +38,7 @@ interface Product {
|
|||||||
// Vendor info
|
// Vendor info
|
||||||
vendor: string;
|
vendor: string;
|
||||||
vendor_reference: string;
|
vendor_reference: string;
|
||||||
brand: string;
|
brand: string | 'Unbranded';
|
||||||
|
|
||||||
// URLs
|
// URLs
|
||||||
permalink: string;
|
permalink: string;
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export function ProductTable({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{Array.from(new Set(value as string[])).map((category) => (
|
{Array.from(new Set(value as string[])).map((category) => (
|
||||||
<Badge key={`${product.product_id}-${category}`} variant="outline">{category}</Badge>
|
<Badge key={`${product.pid}-${category}`} variant="outline">{category}</Badge>
|
||||||
)) || '-'}
|
)) || '-'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -297,12 +297,12 @@ export function ProductTable({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={product.product_id}
|
key={product.pid}
|
||||||
onClick={() => onRowClick?.(product)}
|
onClick={() => onRowClick?.(product)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
{orderedColumns.map((column) => (
|
{orderedColumns.map((column) => (
|
||||||
<TableCell key={`${product.product_id}-${column}`}>
|
<TableCell key={`${product.pid}-${column}`}>
|
||||||
{formatColumnValue(product, column)}
|
{formatColumnValue(product, column)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import config from '../../config';
|
|||||||
|
|
||||||
interface SalesVelocityConfig {
|
interface SalesVelocityConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
daily_window_days: number;
|
daily_window_days: number;
|
||||||
weekly_window_days: number;
|
weekly_window_days: number;
|
||||||
@@ -18,7 +18,7 @@ interface SalesVelocityConfig {
|
|||||||
export function CalculationSettings() {
|
export function CalculationSettings() {
|
||||||
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
daily_window_days: 30,
|
daily_window_days: 30,
|
||||||
weekly_window_days: 7,
|
weekly_window_days: 7,
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
|
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
interface StockThreshold {
|
interface StockThreshold {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
critical_days: number;
|
critical_days: number;
|
||||||
reorder_days: number;
|
reorder_days: number;
|
||||||
@@ -22,7 +23,7 @@ interface StockThreshold {
|
|||||||
|
|
||||||
interface LeadTimeThreshold {
|
interface LeadTimeThreshold {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
target_days: number;
|
target_days: number;
|
||||||
warning_days: number;
|
warning_days: number;
|
||||||
@@ -31,7 +32,7 @@ interface LeadTimeThreshold {
|
|||||||
|
|
||||||
interface SalesVelocityConfig {
|
interface SalesVelocityConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
daily_window_days: number;
|
daily_window_days: number;
|
||||||
weekly_window_days: number;
|
weekly_window_days: number;
|
||||||
@@ -47,7 +48,7 @@ interface ABCClassificationConfig {
|
|||||||
|
|
||||||
interface SafetyStockConfig {
|
interface SafetyStockConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
coverage_days: number;
|
coverage_days: number;
|
||||||
service_level: number;
|
service_level: number;
|
||||||
@@ -55,7 +56,7 @@ interface SafetyStockConfig {
|
|||||||
|
|
||||||
interface TurnoverConfig {
|
interface TurnoverConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
calculation_period_days: number;
|
calculation_period_days: number;
|
||||||
target_rate: number;
|
target_rate: number;
|
||||||
@@ -64,7 +65,7 @@ interface TurnoverConfig {
|
|||||||
export function Configuration() {
|
export function Configuration() {
|
||||||
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
critical_days: 7,
|
critical_days: 7,
|
||||||
reorder_days: 14,
|
reorder_days: 14,
|
||||||
@@ -75,7 +76,7 @@ export function Configuration() {
|
|||||||
|
|
||||||
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
target_days: 14,
|
target_days: 14,
|
||||||
warning_days: 21,
|
warning_days: 21,
|
||||||
@@ -84,7 +85,7 @@ export function Configuration() {
|
|||||||
|
|
||||||
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
daily_window_days: 30,
|
daily_window_days: 30,
|
||||||
weekly_window_days: 7,
|
weekly_window_days: 7,
|
||||||
@@ -100,7 +101,7 @@ export function Configuration() {
|
|||||||
|
|
||||||
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
coverage_days: 14,
|
coverage_days: 14,
|
||||||
service_level: 95.0
|
service_level: 95.0
|
||||||
@@ -108,7 +109,7 @@ export function Configuration() {
|
|||||||
|
|
||||||
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
|
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
calculation_period_days: 30,
|
calculation_period_days: 30,
|
||||||
target_rate: 1.0
|
target_rate: 1.0
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
|
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
interface LeadTimeThreshold {
|
interface LeadTimeThreshold {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
target_days: number;
|
target_days: number;
|
||||||
warning_days: number;
|
warning_days: number;
|
||||||
@@ -17,6 +18,8 @@ interface LeadTimeThreshold {
|
|||||||
|
|
||||||
interface ABCClassificationConfig {
|
interface ABCClassificationConfig {
|
||||||
id: number;
|
id: number;
|
||||||
|
cat_id: number | null;
|
||||||
|
vendor: string | null;
|
||||||
a_threshold: number;
|
a_threshold: number;
|
||||||
b_threshold: number;
|
b_threshold: number;
|
||||||
classification_period_days: number;
|
classification_period_days: number;
|
||||||
@@ -24,7 +27,7 @@ interface ABCClassificationConfig {
|
|||||||
|
|
||||||
interface TurnoverConfig {
|
interface TurnoverConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
calculation_period_days: number;
|
calculation_period_days: number;
|
||||||
target_rate: number;
|
target_rate: number;
|
||||||
@@ -33,27 +36,16 @@ interface TurnoverConfig {
|
|||||||
export function PerformanceMetrics() {
|
export function PerformanceMetrics() {
|
||||||
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
target_days: 14,
|
target_days: 14,
|
||||||
warning_days: 21,
|
warning_days: 21,
|
||||||
critical_days: 30
|
critical_days: 30
|
||||||
});
|
});
|
||||||
|
|
||||||
const [abcConfig, setAbcConfig] = useState<ABCClassificationConfig>({
|
const [abcConfigs, setAbcConfigs] = useState<ABCClassificationConfig[]>([]);
|
||||||
id: 1,
|
|
||||||
a_threshold: 20.0,
|
|
||||||
b_threshold: 50.0,
|
|
||||||
classification_period_days: 90
|
|
||||||
});
|
|
||||||
|
|
||||||
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
|
const [turnoverConfigs, setTurnoverConfigs] = useState<TurnoverConfig[]>([]);
|
||||||
id: 1,
|
|
||||||
category_id: null,
|
|
||||||
vendor: null,
|
|
||||||
calculation_period_days: 30,
|
|
||||||
target_rate: 1.0
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -66,8 +58,8 @@ export function PerformanceMetrics() {
|
|||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setLeadTimeThresholds(data.leadTimeThresholds);
|
setLeadTimeThresholds(data.leadTimeThresholds);
|
||||||
setAbcConfig(data.abcConfig);
|
setAbcConfigs(data.abcConfigs);
|
||||||
setTurnoverConfig(data.turnoverConfig);
|
setTurnoverConfigs(data.turnoverConfigs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(`Failed to load configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
toast.error(`Failed to load configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
@@ -105,7 +97,7 @@ export function PerformanceMetrics() {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify(abcConfig)
|
body: JSON.stringify(abcConfigs)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -127,7 +119,7 @@ export function PerformanceMetrics() {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify(turnoverConfig)
|
body: JSON.stringify(turnoverConfigs)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -210,54 +202,28 @@ export function PerformanceMetrics() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<Table>
|
||||||
<div>
|
<TableHeader>
|
||||||
<Label htmlFor="a-threshold">A Threshold (%)</Label>
|
<TableRow>
|
||||||
<Input
|
<TableHead>Category</TableHead>
|
||||||
id="a-threshold"
|
<TableHead>Vendor</TableHead>
|
||||||
type="number"
|
<TableHead className="text-right">A Threshold</TableHead>
|
||||||
min="0"
|
<TableHead className="text-right">B Threshold</TableHead>
|
||||||
max="100"
|
<TableHead className="text-right">Period Days</TableHead>
|
||||||
step="0.1"
|
</TableRow>
|
||||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
</TableHeader>
|
||||||
value={abcConfig.a_threshold}
|
<TableBody>
|
||||||
onChange={(e) => setAbcConfig(prev => ({
|
{abcConfigs.map((config) => (
|
||||||
...prev,
|
<TableRow key={`${config.cat_id}-${config.vendor}`}>
|
||||||
a_threshold: parseFloat(e.target.value) || 0
|
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
|
||||||
}))}
|
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
|
||||||
/>
|
<TableCell className="text-right">{config.a_threshold}%</TableCell>
|
||||||
</div>
|
<TableCell className="text-right">{config.b_threshold}%</TableCell>
|
||||||
<div>
|
<TableCell className="text-right">{config.classification_period_days}</TableCell>
|
||||||
<Label htmlFor="b-threshold">B Threshold (%)</Label>
|
</TableRow>
|
||||||
<Input
|
))}
|
||||||
id="b-threshold"
|
</TableBody>
|
||||||
type="number"
|
</Table>
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="0.1"
|
|
||||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
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"
|
|
||||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
value={abcConfig.classification_period_days}
|
|
||||||
onChange={(e) => setAbcConfig(prev => ({
|
|
||||||
...prev,
|
|
||||||
classification_period_days: parseInt(e.target.value) || 1
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleUpdateABCConfig}>
|
<Button onClick={handleUpdateABCConfig}>
|
||||||
Update ABC Classification
|
Update ABC Classification
|
||||||
</Button>
|
</Button>
|
||||||
@@ -273,37 +239,26 @@ export function PerformanceMetrics() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<Table>
|
||||||
<div>
|
<TableHeader>
|
||||||
<Label htmlFor="calculation-period">Calculation Period (days)</Label>
|
<TableRow>
|
||||||
<Input
|
<TableHead>Category</TableHead>
|
||||||
id="calculation-period"
|
<TableHead>Vendor</TableHead>
|
||||||
type="number"
|
<TableHead className="text-right">Period Days</TableHead>
|
||||||
min="1"
|
<TableHead className="text-right">Target Rate</TableHead>
|
||||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
</TableRow>
|
||||||
value={turnoverConfig.calculation_period_days}
|
</TableHeader>
|
||||||
onChange={(e) => setTurnoverConfig(prev => ({
|
<TableBody>
|
||||||
...prev,
|
{turnoverConfigs.map((config) => (
|
||||||
calculation_period_days: parseInt(e.target.value) || 1
|
<TableRow key={`${config.cat_id}-${config.vendor}`}>
|
||||||
}))}
|
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
|
||||||
/>
|
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
|
||||||
</div>
|
<TableCell className="text-right">{config.calculation_period_days}</TableCell>
|
||||||
<div>
|
<TableCell className="text-right">{config.target_rate.toFixed(2)}</TableCell>
|
||||||
<Label htmlFor="target-rate">Target Rate</Label>
|
</TableRow>
|
||||||
<Input
|
))}
|
||||||
id="target-rate"
|
</TableBody>
|
||||||
type="number"
|
</Table>
|
||||||
min="0"
|
|
||||||
step="0.1"
|
|
||||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
value={turnoverConfig.target_rate}
|
|
||||||
onChange={(e) => setTurnoverConfig(prev => ({
|
|
||||||
...prev,
|
|
||||||
target_rate: parseFloat(e.target.value) || 0
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleUpdateTurnoverConfig}>
|
<Button onClick={handleUpdateTurnoverConfig}>
|
||||||
Update Turnover Configuration
|
Update Turnover Configuration
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
|
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
interface StockThreshold {
|
interface StockThreshold {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
critical_days: number;
|
critical_days: number;
|
||||||
reorder_days: number;
|
reorder_days: number;
|
||||||
@@ -19,7 +20,7 @@ interface StockThreshold {
|
|||||||
|
|
||||||
interface SafetyStockConfig {
|
interface SafetyStockConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
coverage_days: number;
|
coverage_days: number;
|
||||||
service_level: number;
|
service_level: number;
|
||||||
@@ -28,7 +29,7 @@ interface SafetyStockConfig {
|
|||||||
export function StockManagement() {
|
export function StockManagement() {
|
||||||
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
critical_days: 7,
|
critical_days: 7,
|
||||||
reorder_days: 14,
|
reorder_days: 14,
|
||||||
@@ -39,7 +40,7 @@ export function StockManagement() {
|
|||||||
|
|
||||||
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
coverage_days: 14,
|
coverage_days: 14,
|
||||||
service_level: 95.0
|
service_level: 95.0
|
||||||
@@ -243,6 +244,54 @@ export function StockManagement() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Vendor</TableHead>
|
||||||
|
<TableHead className="text-right">Critical Days</TableHead>
|
||||||
|
<TableHead className="text-right">Reorder Days</TableHead>
|
||||||
|
<TableHead className="text-right">Overstock Days</TableHead>
|
||||||
|
<TableHead className="text-right">Low Stock</TableHead>
|
||||||
|
<TableHead className="text-right">Min Reorder</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{stockThresholds.map((threshold) => (
|
||||||
|
<TableRow key={`${threshold.cat_id}-${threshold.vendor}`}>
|
||||||
|
<TableCell>{threshold.cat_id ? getCategoryName(threshold.cat_id) : 'Global'}</TableCell>
|
||||||
|
<TableCell>{threshold.vendor || 'All Vendors'}</TableCell>
|
||||||
|
<TableCell className="text-right">{threshold.critical_days}</TableCell>
|
||||||
|
<TableCell className="text-right">{threshold.reorder_days}</TableCell>
|
||||||
|
<TableCell className="text-right">{threshold.overstock_days}</TableCell>
|
||||||
|
<TableCell className="text-right">{threshold.low_stock_threshold}</TableCell>
|
||||||
|
<TableCell className="text-right">{threshold.min_reorder_quantity}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Vendor</TableHead>
|
||||||
|
<TableHead className="text-right">Coverage Days</TableHead>
|
||||||
|
<TableHead className="text-right">Service Level</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{safetyStockConfigs.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.coverage_days}</TableCell>
|
||||||
|
<TableCell className="text-right">{config.service_level}%</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,16 +10,22 @@ import { motion } from "motion/react";
|
|||||||
import config from "../config";
|
import config from "../config";
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
category_id: number;
|
cat_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
type: number;
|
||||||
parent_category?: string;
|
parent_id: number | null;
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
status: string;
|
||||||
|
metrics?: {
|
||||||
product_count: number;
|
product_count: number;
|
||||||
|
active_products: number;
|
||||||
total_value: number;
|
total_value: number;
|
||||||
avg_margin: number;
|
avg_margin: number;
|
||||||
turnover_rate: number;
|
turnover_rate: number;
|
||||||
growth_rate: number;
|
growth_rate: number;
|
||||||
status: string;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryFilters {
|
interface CategoryFilters {
|
||||||
@@ -71,16 +77,16 @@ export function Categories() {
|
|||||||
// Apply parent filter
|
// Apply parent filter
|
||||||
if (filters.parent !== 'all') {
|
if (filters.parent !== 'all') {
|
||||||
if (filters.parent === 'none') {
|
if (filters.parent === 'none') {
|
||||||
filtered = filtered.filter(category => !category.parent_category);
|
filtered = filtered.filter(category => !category.parent_id);
|
||||||
} else {
|
} else {
|
||||||
filtered = filtered.filter(category => category.parent_category === filters.parent);
|
filtered = filtered.filter(category => category.parent_id === Number(filters.parent));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply performance filter
|
// Apply performance filter
|
||||||
if (filters.performance !== 'all') {
|
if (filters.performance !== 'all') {
|
||||||
filtered = filtered.filter(category => {
|
filtered = filtered.filter(category => {
|
||||||
const growth = category.growth_rate ?? 0;
|
const growth = category.metrics?.growth_rate ?? 0;
|
||||||
switch (filters.performance) {
|
switch (filters.performance) {
|
||||||
case 'high_growth': return growth >= 20;
|
case 'high_growth': return growth >= 20;
|
||||||
case 'growing': return growth >= 5 && growth < 20;
|
case 'growing': return growth >= 5 && growth < 20;
|
||||||
@@ -123,9 +129,9 @@ export function Categories() {
|
|||||||
if (!filteredData.length) return data?.stats;
|
if (!filteredData.length) return data?.stats;
|
||||||
|
|
||||||
const activeCategories = filteredData.filter(c => c.status === 'active').length;
|
const activeCategories = filteredData.filter(c => c.status === 'active').length;
|
||||||
const totalValue = filteredData.reduce((sum, c) => sum + (c.total_value || 0), 0);
|
const totalValue = filteredData.reduce((sum, c) => sum + (c.metrics?.total_value || 0), 0);
|
||||||
const margins = filteredData.map(c => c.avg_margin || 0).filter(m => m !== 0);
|
const margins = filteredData.map(c => c.metrics?.avg_margin || 0).filter(m => m !== 0);
|
||||||
const growthRates = filteredData.map(c => c.growth_rate || 0).filter(g => g !== 0);
|
const growthRates = filteredData.map(c => c.metrics?.growth_rate || 0).filter(g => g !== 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalCategories: filteredData.length,
|
totalCategories: filteredData.length,
|
||||||
@@ -281,14 +287,16 @@ export function Categories() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead onClick={() => handleSort("parent_category")} className="cursor-pointer">Parent</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
<TableHead onClick={() => handleSort("product_count")} className="cursor-pointer">Products</TableHead>
|
<TableHead>Parent</TableHead>
|
||||||
<TableHead onClick={() => handleSort("total_value")} className="cursor-pointer">Value</TableHead>
|
<TableHead className="text-right">Products</TableHead>
|
||||||
<TableHead onClick={() => handleSort("avg_margin")} className="cursor-pointer">Margin</TableHead>
|
<TableHead className="text-right">Active</TableHead>
|
||||||
<TableHead onClick={() => handleSort("turnover_rate")} className="cursor-pointer">Turnover</TableHead>
|
<TableHead className="text-right">Value</TableHead>
|
||||||
<TableHead onClick={() => handleSort("growth_rate")} className="cursor-pointer">Growth</TableHead>
|
<TableHead className="text-right">Margin</TableHead>
|
||||||
<TableHead onClick={() => handleSort("status")} className="cursor-pointer">Status</TableHead>
|
<TableHead className="text-right">Turnover</TableHead>
|
||||||
|
<TableHead className="text-right">Growth</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -299,25 +307,21 @@ export function Categories() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : paginatedData.map((category: Category) => (
|
) : paginatedData.map((category: Category) => (
|
||||||
<TableRow key={category.category_id}>
|
<TableRow key={category.cat_id}>
|
||||||
|
<TableCell>{category.name}</TableCell>
|
||||||
|
<TableCell>{getPerformanceBadge(category.metrics?.growth_rate ?? 0)}</TableCell>
|
||||||
|
<TableCell>{category.parent_id ? getParentName(category.parent_id) : '-'}</TableCell>
|
||||||
|
<TableCell className="text-right">{category.metrics?.product_count || 0}</TableCell>
|
||||||
|
<TableCell className="text-right">{category.metrics?.active_products || 0}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(category.metrics?.total_value || 0)}</TableCell>
|
||||||
|
<TableCell className="text-right">{category.metrics?.avg_margin?.toFixed(1)}%</TableCell>
|
||||||
|
<TableCell className="text-right">{category.metrics?.turnover_rate?.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{category.metrics?.growth_rate?.toFixed(1)}%</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium">{category.name}</div>
|
<Badge variant={getCategoryStatusVariant(category.status)}>
|
||||||
<div className="text-sm text-muted-foreground">{category.description}</div>
|
{category.status}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{category.parent_category || "—"}</TableCell>
|
|
||||||
<TableCell>{category.product_count?.toLocaleString() ?? 0}</TableCell>
|
|
||||||
<TableCell>{formatCurrency(category.total_value ?? 0)}</TableCell>
|
|
||||||
<TableCell>{typeof category.avg_margin === 'number' ? category.avg_margin.toFixed(1) : "0.0"}%</TableCell>
|
|
||||||
<TableCell>{typeof category.turnover_rate === 'number' ? category.turnover_rate.toFixed(1) : "0.0"}x</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2" style={{ minWidth: '120px' }}>
|
|
||||||
<div style={{ width: '50px', textAlign: 'right' }}>
|
|
||||||
{typeof category.growth_rate === 'number' ? category.growth_rate.toFixed(1) : "0.0"}%
|
|
||||||
</div>
|
|
||||||
{getPerformanceBadge(category.growth_rate ?? 0)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{category.status}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!isLoading && !paginatedData.length && (
|
{!isLoading && !paginatedData.length && (
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function Forecasting() {
|
|||||||
avgPrice: Number(item.avg_price) || 0,
|
avgPrice: Number(item.avg_price) || 0,
|
||||||
avgTotalSold: Number(item.avgTotalSold) || 0,
|
avgTotalSold: Number(item.avgTotalSold) || 0,
|
||||||
products: item.products?.map((p: any) => ({
|
products: item.products?.map((p: any) => ({
|
||||||
product_id: p.product_id,
|
pid: p.pid,
|
||||||
name: p.title,
|
name: p.title,
|
||||||
sku: p.sku,
|
sku: p.sku,
|
||||||
stock_quantity: Number(p.stock_quantity) || 0,
|
stock_quantity: Number(p.stock_quantity) || 0,
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ export function Products() {
|
|||||||
columnDefs={AVAILABLE_COLUMNS}
|
columnDefs={AVAILABLE_COLUMNS}
|
||||||
columnOrder={columnOrder}
|
columnOrder={columnOrder}
|
||||||
onColumnOrderChange={handleColumnOrderChange}
|
onColumnOrderChange={handleColumnOrderChange}
|
||||||
onRowClick={(product) => setSelectedProductId(product.product_id)}
|
onRowClick={(product) => setSelectedProductId(product.pid)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export interface Product {
|
export interface Product {
|
||||||
product_id: number;
|
pid: number;
|
||||||
title: string;
|
title: string;
|
||||||
SKU: string;
|
SKU: string;
|
||||||
stock_quantity: number;
|
stock_quantity: number;
|
||||||
@@ -10,7 +10,7 @@ export interface Product {
|
|||||||
barcode: string;
|
barcode: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
vendor_reference: string;
|
vendor_reference: string;
|
||||||
brand: string;
|
brand: string | 'Unbranded';
|
||||||
categories: string[];
|
categories: string[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
options: Record<string, any>;
|
options: Record<string, any>;
|
||||||
|
|||||||
Reference in New Issue
Block a user