Compare commits
16 Commits
merge-dash
...
ab998fb7c4
| Author | SHA1 | Date | |
|---|---|---|---|
| ab998fb7c4 | |||
| faaa8cc47a | |||
| 459c5092d2 | |||
| 6c9fd062e9 | |||
| 5d7d7a8671 | |||
| 54f55b06a1 | |||
| 4935cfe3bb | |||
| 5e2ee73e2d | |||
| 4dfe85231a | |||
| 9e7aac836e | |||
| d35c7dd6cf | |||
| ad1ebeefe1 | |||
| a0c442d1af | |||
| 7938c50762 | |||
| 5dcd19e7f3 | |||
| 075e7253a0 |
@@ -7,12 +7,13 @@ This document outlines the permission system implemented in the Inventory Manage
|
|||||||
Permissions follow this naming convention:
|
Permissions follow this naming convention:
|
||||||
|
|
||||||
- Page access: `access:{page_name}`
|
- Page access: `access:{page_name}`
|
||||||
- Actions: `{action}:{resource}`
|
- Settings sections: `settings:{section_name}`
|
||||||
|
- Admin features: `admin:{feature}`
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- `access:products` - Can access the Products page
|
- `access:products` - Can access the Products page
|
||||||
- `create:products` - Can create new products
|
- `settings:user_management` - Can access User Management settings
|
||||||
- `edit:users` - Can edit user accounts
|
- `admin:debug` - Can see debug information
|
||||||
|
|
||||||
## Permission Components
|
## Permission Components
|
||||||
|
|
||||||
@@ -22,10 +23,10 @@ The core component that conditionally renders content based on permissions.
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<PermissionGuard
|
<PermissionGuard
|
||||||
permission="create:products"
|
permission="settings:user_management"
|
||||||
fallback={<p>No permission</p>}
|
fallback={<p>No permission</p>}
|
||||||
>
|
>
|
||||||
<button>Create Product</button>
|
<button>Manage Users</button>
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ Specific component for settings with built-in permission checks.
|
|||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="System Settings"
|
title="System Settings"
|
||||||
description="Configure global settings"
|
description="Configure global settings"
|
||||||
permission="edit:system_settings"
|
permission="settings:global"
|
||||||
>
|
>
|
||||||
{/* Settings content */}
|
{/* Settings content */}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
@@ -95,8 +96,8 @@ Core hook for checking any permission.
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const { hasPermission, hasPageAccess, isAdmin } = usePermissions();
|
const { hasPermission, hasPageAccess, isAdmin } = usePermissions();
|
||||||
if (hasPermission('delete:products')) {
|
if (hasPermission('settings:user_management')) {
|
||||||
// Can delete products
|
// Can access user management
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,8 +107,8 @@ Specialized hook for page-level permissions.
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const { canView, canCreate, canEdit, canDelete } = usePagePermission('products');
|
const { canView, canCreate, canEdit, canDelete } = usePagePermission('products');
|
||||||
if (canEdit()) {
|
if (canView()) {
|
||||||
// Can edit products
|
// Can view products
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -119,18 +120,43 @@ Permissions are stored in the database:
|
|||||||
|
|
||||||
Admin users automatically have all permissions.
|
Admin users automatically have all permissions.
|
||||||
|
|
||||||
## Common Permission Codes
|
## Implemented Permission Codes
|
||||||
|
|
||||||
|
### Page Access Permissions
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `access:dashboard` | Access to Dashboard page |
|
| `access:dashboard` | Access to Dashboard page |
|
||||||
|
| `access:overview` | Access to Overview page |
|
||||||
| `access:products` | Access to Products page |
|
| `access:products` | Access to Products page |
|
||||||
| `create:products` | Create new products |
|
| `access:categories` | Access to Categories page |
|
||||||
| `edit:products` | Edit existing products |
|
| `access:brands` | Access to Brands page |
|
||||||
| `delete:products` | Delete products |
|
| `access:vendors` | Access to Vendors page |
|
||||||
| `view:users` | View user accounts |
|
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||||
| `edit:users` | Edit user accounts |
|
| `access:analytics` | Access to Analytics page |
|
||||||
| `manage:permissions` | Assign permissions to users |
|
| `access:forecasting` | Access to Forecasting page |
|
||||||
|
| `access:import` | Access to Import page |
|
||||||
|
| `access:settings` | Access to Settings page |
|
||||||
|
| `access:chat` | Access to Chat Archive page |
|
||||||
|
|
||||||
|
### Settings Permissions
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `settings:global` | Access to Global Settings section |
|
||||||
|
| `settings:products` | Access to Product Settings section |
|
||||||
|
| `settings:vendors` | Access to Vendor Settings section |
|
||||||
|
| `settings:data_management` | Access to Data Management settings |
|
||||||
|
| `settings:calculation_settings` | Access to Calculation Settings |
|
||||||
|
| `settings:library_management` | Access to Image Library Management |
|
||||||
|
| `settings:performance_metrics` | Access to Performance Metrics |
|
||||||
|
| `settings:prompt_management` | Access to AI Prompt Management |
|
||||||
|
| `settings:stock_management` | Access to Stock Management |
|
||||||
|
| `settings:templates` | Access to Template Management |
|
||||||
|
| `settings:user_management` | Access to User Management |
|
||||||
|
|
||||||
|
### Admin Permissions
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `admin:debug` | Can see debug information and features |
|
||||||
|
|
||||||
## Implementation Examples
|
## Implementation Examples
|
||||||
|
|
||||||
@@ -148,25 +174,31 @@ In `App.tsx`:
|
|||||||
### Component Level Protection
|
### Component Level Protection
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const { canEdit } = usePagePermission('products');
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
function handleEdit() {
|
function handleAction() {
|
||||||
if (!canEdit()) {
|
if (!hasPermission('settings:user_management')) {
|
||||||
toast.error("You don't have permission");
|
toast.error("You don't have permission");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Edit logic
|
// Action logic
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### UI Element Protection
|
### UI Element Protection
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<PermissionButton
|
<PermissionGuard permission="settings:user_management">
|
||||||
page="products"
|
<button onClick={handleManageUsers}>
|
||||||
action="delete"
|
Manage Users
|
||||||
onClick={handleDelete}
|
</button>
|
||||||
>
|
</PermissionGuard>
|
||||||
Delete
|
|
||||||
</PermissionButton>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Page Access**: These permissions control which pages a user can navigate to
|
||||||
|
- **Settings Access**: These permissions control access to different sections within the Settings page
|
||||||
|
- **Admin Features**: Special permissions for administrative functions
|
||||||
|
- **CRUD Operations**: The application currently focuses on viewing and managing data rather than creating/editing/deleting individual records
|
||||||
|
- **User Management**: User CRUD operations are handled through the settings interface rather than dedicated user management pages
|
||||||
@@ -34,10 +34,12 @@ const authenticate = async (req, res, next) => {
|
|||||||
|
|
||||||
// Get user from database
|
// Get user from database
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT id, username, is_admin FROM users WHERE id = $1',
|
'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1',
|
||||||
[decoded.userId]
|
[decoded.userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('Database query result for user', decoded.userId, ':', result.rows[0]);
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
return res.status(401).json({ error: 'User not found' });
|
return res.status(401).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
@@ -58,7 +60,7 @@ router.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
// Get user from database
|
// Get user from database
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT id, username, password, is_admin, is_active FROM users WHERE username = $1',
|
'SELECT id, username, password, is_admin, is_active, rocket_chat_user_id FROM users WHERE username = $1',
|
||||||
[username]
|
[username]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -101,6 +103,7 @@ router.post('/login', async (req, res) => {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
is_admin: user.is_admin,
|
is_admin: user.is_admin,
|
||||||
|
rocket_chat_user_id: user.rocket_chat_user_id,
|
||||||
permissions
|
permissions
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -119,8 +122,13 @@ router.get('/me', authenticate, async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
id: req.user.id,
|
id: req.user.id,
|
||||||
username: req.user.username,
|
username: req.user.username,
|
||||||
|
email: req.user.email,
|
||||||
is_admin: req.user.is_admin,
|
is_admin: req.user.is_admin,
|
||||||
permissions
|
rocket_chat_user_id: req.user.rocket_chat_user_id,
|
||||||
|
permissions,
|
||||||
|
// Debug info
|
||||||
|
_debug_raw_user: req.user,
|
||||||
|
_server_identifier: "INVENTORY_AUTH_SERVER_MODIFIED"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting current user:', error);
|
console.error('Error getting current user:', error);
|
||||||
@@ -132,7 +140,7 @@ router.get('/me', authenticate, async (req, res) => {
|
|||||||
router.get('/users', authenticate, requirePermission('view:users'), async (req, res) => {
|
router.get('/users', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT id, username, email, is_admin, is_active, created_at, last_login
|
SELECT id, username, email, is_admin, is_active, rocket_chat_user_id, created_at, last_login
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY username
|
ORDER BY username
|
||||||
`);
|
`);
|
||||||
@@ -151,7 +159,7 @@ router.get('/users/:id', authenticate, requirePermission('view:users'), async (r
|
|||||||
|
|
||||||
// Get user details
|
// Get user details
|
||||||
const userResult = await pool.query(`
|
const userResult = await pool.query(`
|
||||||
SELECT id, username, email, is_admin, is_active, created_at, last_login
|
SELECT id, username, email, is_admin, is_active, rocket_chat_user_id, created_at, last_login
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, [userId]);
|
`, [userId]);
|
||||||
@@ -187,13 +195,14 @@ router.post('/users', authenticate, requirePermission('create:users'), async (re
|
|||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { username, email, password, is_admin, is_active, permissions } = req.body;
|
const { username, email, password, is_admin, is_active, rocket_chat_user_id, permissions } = req.body;
|
||||||
|
|
||||||
console.log("Create user request:", {
|
console.log("Create user request:", {
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
is_admin,
|
is_admin,
|
||||||
is_active,
|
is_active,
|
||||||
|
rocket_chat_user_id,
|
||||||
permissions: permissions || []
|
permissions: permissions || []
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,10 +230,10 @@ router.post('/users', authenticate, requirePermission('create:users'), async (re
|
|||||||
|
|
||||||
// Insert new user
|
// Insert new user
|
||||||
const userResult = await client.query(`
|
const userResult = await client.query(`
|
||||||
INSERT INTO users (username, email, password, is_admin, is_active, created_at)
|
INSERT INTO users (username, email, password, is_admin, is_active, rocket_chat_user_id, created_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, [username, email || null, hashedPassword, !!is_admin, is_active !== false]);
|
`, [username, email || null, hashedPassword, !!is_admin, is_active !== false, rocket_chat_user_id || null]);
|
||||||
|
|
||||||
const userId = userResult.rows[0].id;
|
const userId = userResult.rows[0].id;
|
||||||
|
|
||||||
@@ -299,7 +308,7 @@ router.put('/users/:id', authenticate, requirePermission('edit:users'), async (r
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const userId = req.params.id;
|
const userId = req.params.id;
|
||||||
const { username, email, password, is_admin, is_active, permissions } = req.body;
|
const { username, email, password, is_admin, is_active, rocket_chat_user_id, permissions } = req.body;
|
||||||
|
|
||||||
console.log("Update user request:", {
|
console.log("Update user request:", {
|
||||||
userId,
|
userId,
|
||||||
@@ -307,6 +316,7 @@ router.put('/users/:id', authenticate, requirePermission('edit:users'), async (r
|
|||||||
email,
|
email,
|
||||||
is_admin,
|
is_admin,
|
||||||
is_active,
|
is_active,
|
||||||
|
rocket_chat_user_id,
|
||||||
permissions: permissions || []
|
permissions: permissions || []
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,6 +358,11 @@ router.put('/users/:id', authenticate, requirePermission('edit:users'), async (r
|
|||||||
updateValues.push(!!is_active);
|
updateValues.push(!!is_active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rocket_chat_user_id !== undefined) {
|
||||||
|
updateFields.push(`rocket_chat_user_id = $${paramIndex++}`);
|
||||||
|
updateValues.push(rocket_chat_user_id || null);
|
||||||
|
}
|
||||||
|
|
||||||
// Update password if provided
|
// Update password if provided
|
||||||
if (password) {
|
if (password) {
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ app.get('/me', async (req, res) => {
|
|||||||
|
|
||||||
// Get user details from database
|
// Get user details from database
|
||||||
const userResult = await pool.query(
|
const userResult = await pool.query(
|
||||||
'SELECT id, username, email, is_admin, is_active FROM users WHERE id = $1',
|
'SELECT id, username, email, is_admin, rocket_chat_user_id, is_active FROM users WHERE id = $1',
|
||||||
[decoded.userId]
|
[decoded.userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -135,6 +135,7 @@ app.get('/me', async (req, res) => {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
rocket_chat_user_id: user.rocket_chat_user_id,
|
||||||
is_admin: user.is_admin,
|
is_admin: user.is_admin,
|
||||||
permissions: permissions
|
permissions: permissions
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,169 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Forecasting: summarize sales for products received in a period by brand
|
||||||
|
router.get('/forecast', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
const brand = (req.query.brand || '').toString();
|
||||||
|
const titleSearch = (req.query.search || req.query.q || '').toString().trim() || null;
|
||||||
|
const startDateStr = req.query.startDate;
|
||||||
|
const endDateStr = req.query.endDate;
|
||||||
|
|
||||||
|
if (!brand) {
|
||||||
|
return res.status(400).json({ error: 'Missing required parameter: brand' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to last 30 days if no dates provided
|
||||||
|
const endDate = endDateStr ? new Date(endDateStr) : new Date();
|
||||||
|
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Normalize to date boundaries for consistency
|
||||||
|
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
|
||||||
|
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
WITH params AS (
|
||||||
|
SELECT
|
||||||
|
$1::date AS start_date,
|
||||||
|
$2::date AS end_date,
|
||||||
|
$3::text AS brand,
|
||||||
|
$4::text AS title_search,
|
||||||
|
(($2::date - $1::date) + 1)::int AS days
|
||||||
|
),
|
||||||
|
category_path AS (
|
||||||
|
WITH RECURSIVE cp AS (
|
||||||
|
SELECT c.cat_id, c.name, c.parent_id, c.name::text AS path
|
||||||
|
FROM categories c WHERE c.parent_id IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.cat_id, c.name, c.parent_id, (cp.path || ' > ' || c.name)::text
|
||||||
|
FROM categories c
|
||||||
|
JOIN cp ON c.parent_id = cp.cat_id
|
||||||
|
)
|
||||||
|
SELECT * FROM cp
|
||||||
|
),
|
||||||
|
product_first_received AS (
|
||||||
|
SELECT
|
||||||
|
p.pid,
|
||||||
|
COALESCE(p.first_received::date, MIN(r.received_date)::date) AS first_received_date
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN receivings r ON r.pid = p.pid
|
||||||
|
GROUP BY p.pid, p.first_received
|
||||||
|
),
|
||||||
|
recent_products AS (
|
||||||
|
SELECT p.pid
|
||||||
|
FROM products p
|
||||||
|
JOIN product_first_received fr ON fr.pid = p.pid
|
||||||
|
JOIN params pr ON 1=1
|
||||||
|
WHERE p.visible = true
|
||||||
|
AND COALESCE(p.brand,'Unbranded') = pr.brand
|
||||||
|
AND fr.first_received_date BETWEEN pr.start_date AND pr.end_date
|
||||||
|
AND (pr.title_search IS NULL OR p.title ILIKE '%' || pr.title_search || '%')
|
||||||
|
),
|
||||||
|
product_pick_category AS (
|
||||||
|
(
|
||||||
|
SELECT DISTINCT ON (pc.pid)
|
||||||
|
pc.pid,
|
||||||
|
c.name AS category_name,
|
||||||
|
COALESCE(cp.path, c.name) AS path
|
||||||
|
FROM product_categories pc
|
||||||
|
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
|
||||||
|
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
|
||||||
|
WHERE pc.pid IN (SELECT pid FROM recent_products)
|
||||||
|
AND (cp.path IS NULL OR (
|
||||||
|
cp.path NOT ILIKE '%Black Friday%'
|
||||||
|
AND cp.path NOT ILIKE '%Deals%'
|
||||||
|
))
|
||||||
|
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
|
||||||
|
ORDER BY pc.pid, length(COALESCE(cp.path,'')) DESC
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
rp.pid,
|
||||||
|
'Uncategorized'::text AS category_name,
|
||||||
|
'Uncategorized'::text AS path
|
||||||
|
FROM recent_products rp
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM product_categories pc
|
||||||
|
JOIN categories c ON c.cat_id = pc.cat_id AND (c.type IS NULL OR c.type NOT IN (20,21))
|
||||||
|
LEFT JOIN category_path cp ON cp.cat_id = c.cat_id
|
||||||
|
WHERE pc.pid = rp.pid
|
||||||
|
AND (cp.path IS NULL OR (
|
||||||
|
cp.path NOT ILIKE '%Black Friday%'
|
||||||
|
AND cp.path NOT ILIKE '%Deals%'
|
||||||
|
))
|
||||||
|
AND COALESCE(c.name, '') NOT IN ('Black Friday', 'Deals')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
product_sales AS (
|
||||||
|
SELECT
|
||||||
|
p.pid,
|
||||||
|
p.title,
|
||||||
|
p.sku,
|
||||||
|
COALESCE(p.stock_quantity, 0) AS stock_quantity,
|
||||||
|
COALESCE(p.price, 0) AS price,
|
||||||
|
COALESCE(SUM(o.quantity), 0) AS total_sold
|
||||||
|
FROM recent_products rp
|
||||||
|
JOIN products p ON p.pid = rp.pid
|
||||||
|
LEFT JOIN params pr ON true
|
||||||
|
LEFT JOIN orders o ON o.pid = p.pid
|
||||||
|
AND o.date::date BETWEEN pr.start_date AND pr.end_date
|
||||||
|
AND (o.canceled IS DISTINCT FROM TRUE)
|
||||||
|
GROUP BY p.pid, p.title, p.sku, p.stock_quantity, p.price
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ppc.category_name,
|
||||||
|
ppc.path,
|
||||||
|
COUNT(ps.pid) AS num_products,
|
||||||
|
SUM(ps.total_sold) AS total_sold,
|
||||||
|
ROUND(AVG(COALESCE(ps.total_sold,0) / NULLIF(pr.days,0)), 2) AS avg_daily_sales,
|
||||||
|
ROUND(AVG(COALESCE(ps.total_sold,0)), 2) AS avg_total_sold,
|
||||||
|
MIN(ps.total_sold) AS min_total_sold,
|
||||||
|
MAX(ps.total_sold) AS max_total_sold,
|
||||||
|
JSON_AGG(
|
||||||
|
JSON_BUILD_OBJECT(
|
||||||
|
'pid', ps.pid,
|
||||||
|
'title', ps.title,
|
||||||
|
'sku', ps.sku,
|
||||||
|
'total_sold', ps.total_sold,
|
||||||
|
'categoryPath', ppc.path
|
||||||
|
)
|
||||||
|
) AS products
|
||||||
|
FROM product_sales ps
|
||||||
|
JOIN product_pick_category ppc ON ppc.pid = ps.pid
|
||||||
|
JOIN params pr ON true
|
||||||
|
GROUP BY ppc.category_name, ppc.path
|
||||||
|
HAVING SUM(ps.total_sold) >= 0
|
||||||
|
ORDER BY (ppc.category_name = 'Uncategorized') ASC, avg_total_sold DESC NULLS LAST
|
||||||
|
LIMIT 200;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(sql, [startISO, endISO, brand, titleSearch]);
|
||||||
|
|
||||||
|
// Normalize/shape response keys to match front-end expectations
|
||||||
|
const shaped = rows.map(r => ({
|
||||||
|
category_name: r.category_name,
|
||||||
|
path: r.path,
|
||||||
|
avg_daily_sales: Number(r.avg_daily_sales) || 0,
|
||||||
|
total_sold: Number(r.total_sold) || 0,
|
||||||
|
num_products: Number(r.num_products) || 0,
|
||||||
|
avgTotalSold: Number(r.avg_total_sold) || 0,
|
||||||
|
minSold: Number(r.min_total_sold) || 0,
|
||||||
|
maxSold: Number(r.max_total_sold) || 0,
|
||||||
|
products: Array.isArray(r.products) ? r.products : []
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(shaped);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching forecast data:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch forecast data' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get overall analytics stats
|
// Get overall analytics stats
|
||||||
router.get('/stats', async (req, res) => {
|
router.get('/stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -107,10 +107,10 @@ router.get('/stats', async (req, res) => {
|
|||||||
// Get overall cost metrics from purchase orders
|
// Get overall cost metrics from purchase orders
|
||||||
const { rows: [overallCostMetrics] } = await pool.query(`
|
const { rows: [overallCostMetrics] } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
|
ROUND((SUM(ordered * po_cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
|
||||||
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
|
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_spend
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE cost_price IS NOT NULL
|
WHERE po_cost_price IS NOT NULL
|
||||||
AND ordered > 0
|
AND ordered > 0
|
||||||
AND vendor IS NOT NULL AND vendor != ''
|
AND vendor IS NOT NULL AND vendor != ''
|
||||||
`);
|
`);
|
||||||
@@ -261,10 +261,10 @@ router.get('/', async (req, res) => {
|
|||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
vendor,
|
vendor,
|
||||||
ROUND((SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
|
ROUND((SUM(ordered * po_cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
|
||||||
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
|
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_spend
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE cost_price IS NOT NULL AND ordered > 0
|
WHERE po_cost_price IS NOT NULL AND ordered > 0
|
||||||
GROUP BY vendor
|
GROUP BY vendor
|
||||||
) po ON vm.vendor_name = po.vendor
|
) po ON vm.vendor_name = po.vendor
|
||||||
${whereClause}
|
${whereClause}
|
||||||
|
|||||||
6
inventory/package-lock.json
generated
6
inventory/package-lock.json
generated
@@ -3763,9 +3763,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001700",
|
"version": "1.0.30001739",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz",
|
||||||
"integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
|
"integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"mount": "../mountremote.command"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
@@ -103,14 +103,7 @@ function App() {
|
|||||||
}>
|
}>
|
||||||
{/* Core inventory app routes - will be lazy loaded */}
|
{/* Core inventory app routes - will be lazy loaded */}
|
||||||
<Route index element={
|
<Route index element={
|
||||||
<Protected page="dashboard" fallback={<FirstAccessiblePage />}>
|
<Protected page="overview" fallback={<FirstAccessiblePage />}>
|
||||||
<Suspense fallback={<PageLoading />}>
|
|
||||||
<Overview />
|
|
||||||
</Suspense>
|
|
||||||
</Protected>
|
|
||||||
} />
|
|
||||||
<Route path="/" element={
|
|
||||||
<Protected page="dashboard">
|
|
||||||
<Suspense fallback={<PageLoading />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
<Overview />
|
<Overview />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,67 +1,162 @@
|
|||||||
# Permission System Documentation
|
# Permission System Documentation
|
||||||
|
|
||||||
This document outlines the simplified permission system implemented in the Inventory Manager application.
|
This document outlines the permission system implemented in the Inventory Manager application.
|
||||||
|
|
||||||
## Permission Structure
|
## Permission Structure
|
||||||
|
|
||||||
Permissions follow this naming convention:
|
Permissions follow this naming convention:
|
||||||
|
|
||||||
- Page access: `access:{page_name}`
|
- Page access: `access:{page_name}`
|
||||||
- Actions: `{action}:{resource}`
|
- Settings sections: `settings:{section_name}`
|
||||||
|
- Admin features: `admin:{feature}`
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- `access:products` - Can access the Products page
|
- `access:products` - Can access the Products page
|
||||||
- `create:products` - Can create new products
|
- `settings:user_management` - Can access User Management settings
|
||||||
- `edit:users` - Can edit user accounts
|
- `admin:debug` - Can see debug information
|
||||||
|
|
||||||
## Permission Component
|
## Permission Components
|
||||||
|
|
||||||
### Protected
|
### PermissionGuard
|
||||||
|
|
||||||
The core component that conditionally renders content based on permissions.
|
The core component that conditionally renders content based on permissions.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<Protected
|
<PermissionGuard
|
||||||
permission="create:products"
|
permission="settings:user_management"
|
||||||
fallback={<p>No permission</p>}
|
fallback={<p>No permission</p>}
|
||||||
>
|
>
|
||||||
<button>Create Product</button>
|
<button>Manage Users</button>
|
||||||
</Protected>
|
</PermissionGuard>
|
||||||
```
|
```
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- `permission`: Single permission code (e.g., "create:products")
|
- `permission`: Single permission code
|
||||||
- `page`: Page name (checks `access:{page}` permission)
|
- `anyPermissions`: Array of permissions (ANY match grants access)
|
||||||
- `resource` + `action`: Resource and action (checks `{action}:{resource}` permission)
|
- `allPermissions`: Array of permissions (ALL required)
|
||||||
- `adminOnly`: For admin-only sections
|
- `adminOnly`: For admin-only sections
|
||||||
|
- `page`: Page name (checks `access:{page}` permission)
|
||||||
- `fallback`: Content to show if permission check fails
|
- `fallback`: Content to show if permission check fails
|
||||||
|
|
||||||
### RequireAuth
|
### PermissionProtectedRoute
|
||||||
|
|
||||||
Used for basic authentication checks (is user logged in?).
|
Protects entire pages based on page access permissions.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<Route element={
|
<Route path="/products" element={
|
||||||
<RequireAuth>
|
<PermissionProtectedRoute page="products">
|
||||||
<MainLayout />
|
<Products />
|
||||||
</RequireAuth>
|
</PermissionProtectedRoute>
|
||||||
}>
|
} />
|
||||||
{/* Protected routes */}
|
|
||||||
</Route>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Common Permission Codes
|
### ProtectedSection
|
||||||
|
|
||||||
|
Protects sections within a page based on action permissions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ProtectedSection page="products" action="create">
|
||||||
|
<button>Add Product</button>
|
||||||
|
</ProtectedSection>
|
||||||
|
```
|
||||||
|
|
||||||
|
### PermissionButton
|
||||||
|
|
||||||
|
Button that automatically handles permissions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PermissionButton
|
||||||
|
page="products"
|
||||||
|
action="create"
|
||||||
|
onClick={handleCreateProduct}
|
||||||
|
>
|
||||||
|
Add Product
|
||||||
|
</PermissionButton>
|
||||||
|
```
|
||||||
|
|
||||||
|
### SettingsSection
|
||||||
|
|
||||||
|
Specific component for settings with built-in permission checks.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SettingsSection
|
||||||
|
title="System Settings"
|
||||||
|
description="Configure global settings"
|
||||||
|
permission="settings:global"
|
||||||
|
>
|
||||||
|
{/* Settings content */}
|
||||||
|
</SettingsSection>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permission Hooks
|
||||||
|
|
||||||
|
### usePermissions
|
||||||
|
|
||||||
|
Core hook for checking any permission.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { hasPermission, hasPageAccess, isAdmin } = usePermissions();
|
||||||
|
if (hasPermission('settings:user_management')) {
|
||||||
|
// Can access user management
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### usePagePermission
|
||||||
|
|
||||||
|
Specialized hook for page-level permissions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { canView, canCreate, canEdit, canDelete } = usePagePermission('products');
|
||||||
|
if (canView()) {
|
||||||
|
// Can view products
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
Permissions are stored in the database:
|
||||||
|
- `permissions` table: Stores all available permissions
|
||||||
|
- `user_permissions` junction table: Maps permissions to users
|
||||||
|
|
||||||
|
Admin users automatically have all permissions.
|
||||||
|
|
||||||
|
## Implemented Permission Codes
|
||||||
|
|
||||||
|
### Page Access Permissions
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `access:dashboard` | Access to Dashboard page |
|
| `access:dashboard` | Access to Dashboard page |
|
||||||
|
| `access:overview` | Access to Overview page |
|
||||||
| `access:products` | Access to Products page |
|
| `access:products` | Access to Products page |
|
||||||
| `create:products` | Create new products |
|
| `access:categories` | Access to Categories page |
|
||||||
| `edit:products` | Edit existing products |
|
| `access:brands` | Access to Brands page |
|
||||||
| `delete:products` | Delete products |
|
| `access:vendors` | Access to Vendors page |
|
||||||
| `view:users` | View user accounts |
|
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||||
| `edit:users` | Edit user accounts |
|
| `access:analytics` | Access to Analytics page |
|
||||||
| `manage:permissions` | Assign permissions to users |
|
| `access:forecasting` | Access to Forecasting page |
|
||||||
|
| `access:import` | Access to Import page |
|
||||||
|
| `access:settings` | Access to Settings page |
|
||||||
|
| `access:chat` | Access to Chat Archive page |
|
||||||
|
|
||||||
|
### Settings Permissions
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `settings:global` | Access to Global Settings section |
|
||||||
|
| `settings:products` | Access to Product Settings section |
|
||||||
|
| `settings:vendors` | Access to Vendor Settings section |
|
||||||
|
| `settings:data_management` | Access to Data Management settings |
|
||||||
|
| `settings:calculation_settings` | Access to Calculation Settings |
|
||||||
|
| `settings:library_management` | Access to Image Library Management |
|
||||||
|
| `settings:performance_metrics` | Access to Performance Metrics |
|
||||||
|
| `settings:prompt_management` | Access to AI Prompt Management |
|
||||||
|
| `settings:stock_management` | Access to Stock Management |
|
||||||
|
| `settings:templates` | Access to Template Management |
|
||||||
|
| `settings:user_management` | Access to User Management |
|
||||||
|
|
||||||
|
### Admin Permissions
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `admin:debug` | Can see debug information and features |
|
||||||
|
|
||||||
## Implementation Examples
|
## Implementation Examples
|
||||||
|
|
||||||
@@ -70,35 +165,40 @@ Used for basic authentication checks (is user logged in?).
|
|||||||
In `App.tsx`:
|
In `App.tsx`:
|
||||||
```tsx
|
```tsx
|
||||||
<Route path="/products" element={
|
<Route path="/products" element={
|
||||||
<Protected page="products" fallback={<Navigate to="/" />}>
|
<PermissionProtectedRoute page="products">
|
||||||
<Products />
|
<Products />
|
||||||
</Protected>
|
</PermissionProtectedRoute>
|
||||||
} />
|
} />
|
||||||
```
|
```
|
||||||
|
|
||||||
### Component Level Protection
|
### Component Level Protection
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<Protected permission="edit:products">
|
const { hasPermission } = usePermissions();
|
||||||
<form>
|
|
||||||
{/* Form fields */}
|
function handleAction() {
|
||||||
<button type="submit">Save Changes</button>
|
if (!hasPermission('settings:user_management')) {
|
||||||
</form>
|
toast.error("You don't have permission");
|
||||||
</Protected>
|
return;
|
||||||
|
}
|
||||||
|
// Action logic
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Button Protection
|
### UI Element Protection
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<Button
|
<PermissionGuard permission="settings:user_management">
|
||||||
onClick={handleDelete}
|
<button onClick={handleManageUsers}>
|
||||||
disabled={!hasPermission('delete:products')}
|
Manage Users
|
||||||
>
|
</button>
|
||||||
Delete
|
</PermissionGuard>
|
||||||
</Button>
|
|
||||||
|
|
||||||
// With Protected component
|
|
||||||
<Protected permission="delete:products" fallback={null}>
|
|
||||||
<Button onClick={handleDelete}>Delete</Button>
|
|
||||||
</Protected>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Page Access**: These permissions control which pages a user can navigate to
|
||||||
|
- **Settings Access**: These permissions control access to different sections within the Settings page
|
||||||
|
- **Admin Features**: Special permissions for administrative functions
|
||||||
|
- **CRUD Operations**: The application currently focuses on viewing and managing data rather than creating/editing/deleting individual records
|
||||||
|
- **User Management**: User CRUD operations are handled through the settings interface rather than dedicated user management pages
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { format, addDays, addMonths } from "date-fns";
|
||||||
|
import { Calendar as CalendarIcon, Info } from "lucide-react";
|
||||||
|
import { DateRange } from "react-day-picker";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
interface DateRangePickerQuickProps {
|
||||||
|
value: DateRange;
|
||||||
|
onChange: (range: DateRange | undefined) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateRangePickerQuick({ value, onChange, className }: DateRangePickerQuickProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("grid gap-2", className)}>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
id="date"
|
||||||
|
variant={"outline"}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-[300px] justify-start text-left font-normal",
|
||||||
|
!value && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{value?.from ? (
|
||||||
|
value.to ? (
|
||||||
|
<>
|
||||||
|
{format(value.from, "LLL dd, y")} -{" "}
|
||||||
|
{format(value.to, "LLL dd, y")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
format(value.from, "LLL dd, y")
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span>Pick a date range</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-2" align="start">
|
||||||
|
<div className="flex justify-center"><Badge variant="secondary"><Info className="mr-1 h-3 w-3" /> Only products received during the selected date range will be shown</Badge></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Calendar
|
||||||
|
initialFocus
|
||||||
|
mode="range"
|
||||||
|
defaultMonth={value?.from}
|
||||||
|
selected={value}
|
||||||
|
onSelect={(range) => {
|
||||||
|
if (range) onChange(range);
|
||||||
|
}}
|
||||||
|
numberOfMonths={2}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -1), 1), to: new Date() })}>
|
||||||
|
Last Month
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -3), 1), to: new Date() })}>
|
||||||
|
Last 3 Months
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -6), 1), to: new Date() })}>
|
||||||
|
Last 6 Months
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -12), 1), to: new Date() })}>
|
||||||
|
Last Year
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
956
inventory/src/components/forecasting/QuickOrderBuilder.tsx
Normal file
956
inventory/src/components/forecasting/QuickOrderBuilder.tsx
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, useTransition, useCallback, memo } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Code } from "@/components/ui/code";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { X as XIcon } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
export interface CategorySummary {
|
||||||
|
category: string;
|
||||||
|
categoryPath: string;
|
||||||
|
avgTotalSold: number;
|
||||||
|
minSold: number;
|
||||||
|
maxSold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedRow = {
|
||||||
|
product: string;
|
||||||
|
sku?: string;
|
||||||
|
categoryHint?: string;
|
||||||
|
moq?: number;
|
||||||
|
upc?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrderRow = ParsedRow & {
|
||||||
|
matchedCategoryPath?: string;
|
||||||
|
matchedCategoryName?: string;
|
||||||
|
baseSuggestion?: number; // from category avg
|
||||||
|
finalQty: number; // adjusted for MOQ
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeaderMap = {
|
||||||
|
// Stores generated column ids like "col-0" instead of raw header text
|
||||||
|
product?: string;
|
||||||
|
sku?: string;
|
||||||
|
categoryHint?: string;
|
||||||
|
moq?: string;
|
||||||
|
upc?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRODUCT_HEADER_SYNONYMS = [
|
||||||
|
"product",
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"item",
|
||||||
|
"item name",
|
||||||
|
"sku description",
|
||||||
|
"product name",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SKU_HEADER_SYNONYMS = [
|
||||||
|
"sku",
|
||||||
|
"item#",
|
||||||
|
"item number",
|
||||||
|
"supplier #",
|
||||||
|
"supplier no",
|
||||||
|
"supplier_no",
|
||||||
|
"product code",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORY_HEADER_SYNONYMS = [
|
||||||
|
"category",
|
||||||
|
"categories",
|
||||||
|
"line",
|
||||||
|
"collection",
|
||||||
|
"type",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOQ_HEADER_SYNONYMS = [
|
||||||
|
"moq",
|
||||||
|
"min qty",
|
||||||
|
"min. order qty",
|
||||||
|
"min order qty",
|
||||||
|
"qty per unit",
|
||||||
|
"unit qty",
|
||||||
|
"inner pack",
|
||||||
|
"case pack",
|
||||||
|
"pack",
|
||||||
|
];
|
||||||
|
|
||||||
|
const UPC_HEADER_SYNONYMS = [
|
||||||
|
"upc",
|
||||||
|
"barcode",
|
||||||
|
"bar code",
|
||||||
|
"ean",
|
||||||
|
"jan",
|
||||||
|
"upc code",
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeHeader(h: string) {
|
||||||
|
return h.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoMapHeaderNames(headers: string[]): { product?: string; sku?: string; categoryHint?: string; moq?: string; upc?: string } {
|
||||||
|
const norm = headers.map((h) => normalizeHeader(h));
|
||||||
|
const findFirst = (syns: string[]) => {
|
||||||
|
for (const s of syns) {
|
||||||
|
const idx = norm.findIndex((h) => h === s || h.includes(s));
|
||||||
|
if (idx >= 0) return headers[idx];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
product: findFirst(PRODUCT_HEADER_SYNONYMS) || headers[0],
|
||||||
|
sku: findFirst(SKU_HEADER_SYNONYMS),
|
||||||
|
categoryHint: findFirst(CATEGORY_HEADER_SYNONYMS),
|
||||||
|
moq: findFirst(MOQ_HEADER_SYNONYMS),
|
||||||
|
upc: findFirst(UPC_HEADER_SYNONYMS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectDelimiter(text: string): string {
|
||||||
|
// Very simple heuristic: prefer tab, then comma, then semicolon
|
||||||
|
const lines = text.split(/\r?\n/).slice(0, 5);
|
||||||
|
const counts = { "\t": 0, ",": 0, ";": 0 } as Record<string, number>;
|
||||||
|
for (const line of lines) {
|
||||||
|
counts["\t"] += (line.match(/\t/g) || []).length;
|
||||||
|
counts[","] += (line.match(/,/g) || []).length;
|
||||||
|
counts[";"] += (line.match(/;/g) || []).length;
|
||||||
|
}
|
||||||
|
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePasted(text: string): { headers: string[]; rows: string[][] } {
|
||||||
|
const delimiter = detectDelimiter(text);
|
||||||
|
const lines = text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (lines.length === 0) return { headers: [], rows: [] };
|
||||||
|
const headers = lines[0].split(delimiter).map((s) => s.trim());
|
||||||
|
const rows = lines.slice(1).map((l) => {
|
||||||
|
const parts = l.split(delimiter).map((s) => s.trim());
|
||||||
|
// Preserve empty trailing columns by padding to headers length
|
||||||
|
while (parts.length < headers.length) parts.push("");
|
||||||
|
return parts;
|
||||||
|
});
|
||||||
|
return { headers, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIntOrUndefined(v: any): number | undefined {
|
||||||
|
if (v === null || v === undefined) return undefined;
|
||||||
|
const n = Number(String(v).replace(/[^0-9.-]/g, ""));
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCategoryMatch(catText: string, name: string, hint?: string): number {
|
||||||
|
const base = catText.toLowerCase();
|
||||||
|
const tokens = (name || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/)
|
||||||
|
.filter((t) => t.length >= 3);
|
||||||
|
let score = 0;
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (base.includes(t)) score += 2;
|
||||||
|
}
|
||||||
|
if (hint) {
|
||||||
|
const h = hint.toLowerCase();
|
||||||
|
if (base.includes(h)) score += 5;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestFromCategory(avgTotalSold?: number, scalePct: number = 100): number {
|
||||||
|
const scaled = (avgTotalSold || 0) * (isFinite(scalePct) ? scalePct : 100) / 100;
|
||||||
|
const base = Math.max(1, Math.round(scaled));
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMOQ(qty: number, moq?: number): number {
|
||||||
|
if (!moq || moq <= 1) return Math.max(0, qty);
|
||||||
|
if (qty <= 0) return 0;
|
||||||
|
const mult = Math.ceil(qty / moq);
|
||||||
|
return mult * moq;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickOrderBuilder({
|
||||||
|
categories,
|
||||||
|
brand,
|
||||||
|
}: {
|
||||||
|
categories: CategorySummary[];
|
||||||
|
brand?: string;
|
||||||
|
}) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const [pasted, setPasted] = useState("");
|
||||||
|
const [headers, setHeaders] = useState<string[]>([]);
|
||||||
|
const [rawRows, setRawRows] = useState<string[][]>([]);
|
||||||
|
const [headerMap, setHeaderMap] = useState<HeaderMap>({});
|
||||||
|
const [orderRows, setOrderRows] = useState<OrderRow[]>([]);
|
||||||
|
const [showJson, setShowJson] = useState(false);
|
||||||
|
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(undefined);
|
||||||
|
const [scalePct, setScalePct] = useState<number>(100);
|
||||||
|
const [scaleInput, setScaleInput] = useState<string>("100");
|
||||||
|
const [showExcludedOnly, setShowExcludedOnly] = useState<boolean>(false);
|
||||||
|
const [parsed, setParsed] = useState<boolean>(false);
|
||||||
|
const [showMapping, setShowMapping] = useState<boolean>(false);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [initialCategories, setInitialCategories] = useState<CategorySummary[] | null>(null);
|
||||||
|
|
||||||
|
// Local storage draft persistence
|
||||||
|
const DRAFT_KEY = "quickOrderBuilderDraft";
|
||||||
|
const restoringRef = useRef(false);
|
||||||
|
|
||||||
|
// Load suppliers from existing endpoint used elsewhere in the app
|
||||||
|
const { data: fieldOptions } = useQuery({
|
||||||
|
queryKey: ["field-options"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/import/field-options");
|
||||||
|
if (!res.ok) throw new Error("Failed to load field options");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const supplierOptions: { label: string; value: string }[] = fieldOptions?.suppliers || [];
|
||||||
|
|
||||||
|
// Default supplier to the brand name if an exact label match exists
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supplierOptions?.length) return;
|
||||||
|
if (selectedSupplierId) return;
|
||||||
|
if (brand) {
|
||||||
|
const match = supplierOptions.find((s) => s.label?.toLowerCase?.() === brand.toLowerCase());
|
||||||
|
if (match) setSelectedSupplierId(String(match.value));
|
||||||
|
}
|
||||||
|
}, [supplierOptions, brand, selectedSupplierId]);
|
||||||
|
|
||||||
|
// Restore draft on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(DRAFT_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const draft = JSON.parse(raw);
|
||||||
|
restoringRef.current = true;
|
||||||
|
setPasted(draft.pasted ?? "");
|
||||||
|
setHeaders(Array.isArray(draft.headers) ? draft.headers : []);
|
||||||
|
setRawRows(Array.isArray(draft.rawRows) ? draft.rawRows : []);
|
||||||
|
setHeaderMap(draft.headerMap ?? {});
|
||||||
|
setOrderRows(Array.isArray(draft.orderRows) ? draft.orderRows : []);
|
||||||
|
setSelectedSupplierId(draft.selectedSupplierId ?? undefined);
|
||||||
|
const restoredScale = typeof draft.scalePct === 'number' ? draft.scalePct : 100;
|
||||||
|
setScalePct(restoredScale);
|
||||||
|
setScaleInput(String(restoredScale));
|
||||||
|
setParsed(Array.isArray(draft.headers) && draft.headers.length > 0);
|
||||||
|
setShowMapping(!(Array.isArray(draft.orderRows) && draft.orderRows.length > 0));
|
||||||
|
if (Array.isArray(draft.categoriesSnapshot)) {
|
||||||
|
setInitialCategories(draft.categoriesSnapshot);
|
||||||
|
}
|
||||||
|
// brand is passed via props; we don't override it here
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to restore draft", e);
|
||||||
|
} finally {
|
||||||
|
// Defer toggling off to next tick to allow state batching
|
||||||
|
setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save draft on changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (restoringRef.current) return;
|
||||||
|
const draft = {
|
||||||
|
pasted,
|
||||||
|
headers,
|
||||||
|
rawRows,
|
||||||
|
headerMap,
|
||||||
|
orderRows,
|
||||||
|
selectedSupplierId,
|
||||||
|
scalePct,
|
||||||
|
brand,
|
||||||
|
categoriesSnapshot: categories,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
|
||||||
|
} catch (e) {
|
||||||
|
// ignore storage quota errors silently
|
||||||
|
}
|
||||||
|
}, [pasted, headers, rawRows, headerMap, orderRows, selectedSupplierId, scalePct, brand]);
|
||||||
|
|
||||||
|
// Debounce scale input -> numeric scalePct
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
const v = Math.max(1, Math.min(500, Math.round(Number(scaleInput) || 0)));
|
||||||
|
setScalePct(v);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [scaleInput]);
|
||||||
|
|
||||||
|
const effectiveCategories = (categories && categories.length > 0) ? categories : (initialCategories || []);
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const arr = (effectiveCategories || [])
|
||||||
|
.map((c) => ({
|
||||||
|
value: c.categoryPath || c.category,
|
||||||
|
label: c.categoryPath ? `${c.category} — ${c.categoryPath}` : c.category,
|
||||||
|
}))
|
||||||
|
.filter((o) => !!o.value && String(o.value).trim() !== "");
|
||||||
|
// dedupe by value to avoid duplicate Select values
|
||||||
|
const dedup = new Map<string, string>();
|
||||||
|
for (const o of arr) {
|
||||||
|
if (!dedup.has(o.value)) dedup.set(o.value, o.label);
|
||||||
|
}
|
||||||
|
return Array.from(dedup.entries()).map(([value, label]) => ({ value, label }));
|
||||||
|
}, [effectiveCategories]);
|
||||||
|
|
||||||
|
const categoryByKey = useMemo(() => {
|
||||||
|
const map = new Map<string, CategorySummary>();
|
||||||
|
for (const c of effectiveCategories || []) {
|
||||||
|
map.set(c.categoryPath || c.category, c);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [effectiveCategories]);
|
||||||
|
|
||||||
|
// Build header option list with generated ids so values are never empty and keys are unique
|
||||||
|
const headerOptions = useMemo(
|
||||||
|
() => headers.map((h, i) => ({ id: `col-${i}`, index: i, label: h && h.trim() ? h : `Column ${i + 1}` })),
|
||||||
|
[headers]
|
||||||
|
);
|
||||||
|
const idToIndex = useMemo(() => new Map(headerOptions.map((o) => [o.id, o.index])), [headerOptions]);
|
||||||
|
|
||||||
|
function headerNameToId(name?: string): string | undefined {
|
||||||
|
if (!name) return undefined;
|
||||||
|
const idx = headers.findIndex((h) => h === name);
|
||||||
|
return idx >= 0 ? `col-${idx}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
const ext = f.name.split(".").pop()?.toLowerCase();
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
let wb: XLSX.WorkBook | null = null;
|
||||||
|
if (ext === "xlsx" || ext === "xls") {
|
||||||
|
const data = new Uint8Array(reader.result as ArrayBuffer);
|
||||||
|
wb = XLSX.read(data, { type: "array" });
|
||||||
|
} else if (ext === "csv" || ext === "tsv") {
|
||||||
|
const text = reader.result as string;
|
||||||
|
wb = XLSX.read(text, { type: "string" });
|
||||||
|
} else {
|
||||||
|
// Try naive string read
|
||||||
|
const text = reader.result as string;
|
||||||
|
wb = XLSX.read(text, { type: "string" });
|
||||||
|
}
|
||||||
|
if (!wb) throw new Error("Unable to parse file");
|
||||||
|
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
const rows: any[][] = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" });
|
||||||
|
if (!rows.length) throw new Error("Empty file");
|
||||||
|
const hdrs = (rows[0] as string[]).map((h) => String(h || "").trim());
|
||||||
|
const body = rows.slice(1).map((r) => (r as any[]).map((v) => String(v ?? "").trim()));
|
||||||
|
// Build mapping based on detected names -> ids
|
||||||
|
const mappedNames = autoMapHeaderNames(hdrs);
|
||||||
|
const mappedIds: HeaderMap = {
|
||||||
|
product: headerNameToId(mappedNames.product) ?? (hdrs.length > 0 ? `col-0` : undefined),
|
||||||
|
sku: headerNameToId(mappedNames.sku),
|
||||||
|
categoryHint: headerNameToId(mappedNames.categoryHint),
|
||||||
|
moq: headerNameToId(mappedNames.moq),
|
||||||
|
upc: headerNameToId(mappedNames.upc),
|
||||||
|
};
|
||||||
|
setHeaders(hdrs);
|
||||||
|
setRawRows(body);
|
||||||
|
setHeaderMap(mappedIds);
|
||||||
|
setPasted("");
|
||||||
|
setParsed(true);
|
||||||
|
setShowMapping(true);
|
||||||
|
toast.success("File parsed");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("Could not parse file");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ext === "xlsx" || ext === "xls") {
|
||||||
|
reader.readAsArrayBuffer(f);
|
||||||
|
} else {
|
||||||
|
reader.readAsText(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePasteParse() {
|
||||||
|
try {
|
||||||
|
const { headers: hdrs, rows } = parsePasted(pasted);
|
||||||
|
if (!hdrs.length || !rows.length) {
|
||||||
|
toast.error("No data detected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mappedNames = autoMapHeaderNames(hdrs);
|
||||||
|
const mappedIds: HeaderMap = {
|
||||||
|
product: headerNameToId(mappedNames.product) ?? (hdrs.length > 0 ? `col-0` : undefined),
|
||||||
|
sku: headerNameToId(mappedNames.sku),
|
||||||
|
categoryHint: headerNameToId(mappedNames.categoryHint),
|
||||||
|
moq: headerNameToId(mappedNames.moq),
|
||||||
|
upc: headerNameToId(mappedNames.upc),
|
||||||
|
};
|
||||||
|
setHeaders(hdrs);
|
||||||
|
setRawRows(rows);
|
||||||
|
setHeaderMap(mappedIds);
|
||||||
|
setParsed(true);
|
||||||
|
setShowMapping(true);
|
||||||
|
toast.success("Pasted data parsed");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Paste parse failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParsedRows(): ParsedRow[] {
|
||||||
|
if (!headers.length || !rawRows.length) return [];
|
||||||
|
const idx = (id?: string) => (id ? idToIndex.get(id) ?? -1 : -1);
|
||||||
|
const iProduct = idx(headerMap.product);
|
||||||
|
const iSku = idx(headerMap.sku);
|
||||||
|
const iCat = idx(headerMap.categoryHint);
|
||||||
|
const iMoq = idx(headerMap.moq);
|
||||||
|
const iUpc = idx(headerMap.upc);
|
||||||
|
const out: ParsedRow[] = [];
|
||||||
|
for (const r of rawRows) {
|
||||||
|
const product = String(iProduct >= 0 ? r[iProduct] ?? "" : "").trim();
|
||||||
|
const upc = iUpc >= 0 ? String(r[iUpc] ?? "") : undefined;
|
||||||
|
if (!product && !(upc && upc.trim())) continue;
|
||||||
|
const sku = iSku >= 0 ? String(r[iSku] ?? "") : undefined;
|
||||||
|
const categoryHint = iCat >= 0 ? String(r[iCat] ?? "") : undefined;
|
||||||
|
const moq = iMoq >= 0 ? toIntOrUndefined(r[iMoq]) : undefined;
|
||||||
|
out.push({ product, sku, categoryHint, moq, upc });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchCategory(row: ParsedRow): { key?: string; name?: string } {
|
||||||
|
if (!categories?.length) return {};
|
||||||
|
let bestKey: string | undefined;
|
||||||
|
let bestName: string | undefined;
|
||||||
|
let bestScore = -1;
|
||||||
|
for (const c of categories) {
|
||||||
|
const key = c.categoryPath || c.category;
|
||||||
|
const text = `${c.category} ${c.categoryPath || ""}`;
|
||||||
|
const s = scoreCategoryMatch(text, row.product, row.categoryHint);
|
||||||
|
if (s > bestScore) {
|
||||||
|
bestScore = s;
|
||||||
|
bestKey = key;
|
||||||
|
bestName = c.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestScore > 0 ? { key: bestKey, name: bestName } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrderRows() {
|
||||||
|
const parsed = buildParsedRows();
|
||||||
|
if (!parsed.length) {
|
||||||
|
toast.error("Nothing to process");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next: OrderRow[] = parsed.map((r) => {
|
||||||
|
const m = matchCategory(r);
|
||||||
|
const cat = m.key ? categoryByKey.get(m.key) : undefined;
|
||||||
|
const base = suggestFromCategory(cat?.avgTotalSold, scalePct);
|
||||||
|
const finalQty = applyMOQ(base, r.moq);
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
matchedCategoryPath: m.key,
|
||||||
|
matchedCategoryName: m.name,
|
||||||
|
baseSuggestion: base,
|
||||||
|
finalQty,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setOrderRows(next);
|
||||||
|
setShowMapping(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply scaling dynamically to suggested rows
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderRows.length) return;
|
||||||
|
startTransition(() => {
|
||||||
|
setOrderRows((rows) =>
|
||||||
|
rows.map((row) => {
|
||||||
|
const cat = row.matchedCategoryPath ? categoryByKey.get(row.matchedCategoryPath) : undefined;
|
||||||
|
if (!cat) return row; // nothing to scale when no category
|
||||||
|
const prevAuto = applyMOQ(row.baseSuggestion || 0, row.moq);
|
||||||
|
const nextBase = suggestFromCategory(cat.avgTotalSold, scalePct);
|
||||||
|
const nextAuto = applyMOQ(nextBase, row.moq);
|
||||||
|
const isAuto = row.finalQty === prevAuto;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
baseSuggestion: nextBase,
|
||||||
|
finalQty: isAuto ? nextAuto : row.finalQty,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [scalePct, categoryByKey]);
|
||||||
|
|
||||||
|
// After categories load (e.g. after refresh), recompute base suggestions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderRows.length) return;
|
||||||
|
startTransition(() => {
|
||||||
|
setOrderRows((rows) =>
|
||||||
|
rows.map((row) => {
|
||||||
|
const cat = row.matchedCategoryPath ? categoryByKey.get(row.matchedCategoryPath) : undefined;
|
||||||
|
if (!cat) return row;
|
||||||
|
const nextBase = suggestFromCategory(cat.avgTotalSold, scalePct);
|
||||||
|
const nextAuto = applyMOQ(nextBase, row.moq);
|
||||||
|
const prevAuto = applyMOQ(row.baseSuggestion || 0, row.moq);
|
||||||
|
const isAuto = row.finalQty === prevAuto || !row.baseSuggestion; // treat empty base as auto
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
baseSuggestion: nextBase,
|
||||||
|
finalQty: isAuto ? nextAuto : row.finalQty,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [categoryByKey]);
|
||||||
|
|
||||||
|
const changeCategory = useCallback((idx: number, newKey?: string) => {
|
||||||
|
setOrderRows((rows) => {
|
||||||
|
const copy = [...rows];
|
||||||
|
const row = { ...copy[idx] };
|
||||||
|
row.matchedCategoryPath = newKey;
|
||||||
|
if (newKey) {
|
||||||
|
const cat = categoryByKey.get(newKey);
|
||||||
|
row.matchedCategoryName = cat?.category;
|
||||||
|
row.baseSuggestion = suggestFromCategory(cat?.avgTotalSold, scalePct);
|
||||||
|
row.finalQty = applyMOQ(row.baseSuggestion || 0, row.moq);
|
||||||
|
} else {
|
||||||
|
row.matchedCategoryName = undefined;
|
||||||
|
row.baseSuggestion = undefined;
|
||||||
|
row.finalQty = row.moq ? row.moq : 0;
|
||||||
|
}
|
||||||
|
copy[idx] = row;
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}, [categoryByKey, scalePct]);
|
||||||
|
|
||||||
|
const changeQty = useCallback((idx: number, value: string) => {
|
||||||
|
const n = Number(value);
|
||||||
|
startTransition(() => setOrderRows((rows) => {
|
||||||
|
const copy = [...rows];
|
||||||
|
const row = { ...copy[idx] };
|
||||||
|
const raw = Number.isFinite(n) ? Math.round(n) : 0;
|
||||||
|
row.finalQty = raw; // do not enforce MOQ on manual edits
|
||||||
|
copy[idx] = row;
|
||||||
|
return copy;
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeRow = useCallback((idx: number) => {
|
||||||
|
setOrderRows((rows) => rows.filter((_, i) => i !== idx));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visibleRows = useMemo(() => (
|
||||||
|
showExcludedOnly
|
||||||
|
? orderRows.filter((r) => !(r.finalQty > 0 && r.upc && r.upc.trim()))
|
||||||
|
: orderRows
|
||||||
|
), [orderRows, showExcludedOnly]);
|
||||||
|
|
||||||
|
const OrderRowsTable = useMemo(() => memo(function OrderRowsTableInner({
|
||||||
|
rows,
|
||||||
|
}: { rows: OrderRow[] }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead>SKU</TableHead>
|
||||||
|
<TableHead>UPC</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead className="text-right">Avg Sold</TableHead>
|
||||||
|
<TableHead className="text-right">MOQ</TableHead>
|
||||||
|
<TableHead className="text-right">Order Qty</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r, idx) => {
|
||||||
|
const cat = r.matchedCategoryPath ? categoryByKey.get(r.matchedCategoryPath) : undefined;
|
||||||
|
const isExcluded = !(r.finalQty > 0 && r.upc && r.upc.trim());
|
||||||
|
return (
|
||||||
|
<TableRow key={`${r.product || r.upc || 'row'}-${idx}`} className={isExcluded ? 'bg-destructive/10' : undefined}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{r.product}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">{r.sku || ""}</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">{r.upc || ""}</TableCell>
|
||||||
|
<TableCell className="min-w-[280px]">
|
||||||
|
<Select
|
||||||
|
value={r.matchedCategoryPath ?? "__none"}
|
||||||
|
onValueChange={(v) => changeCategory(idx, v === "__none" ? undefined : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[320px]">
|
||||||
|
<SelectItem value="__none">Unmatched</SelectItem>
|
||||||
|
{categoryOptions.map((c) => (
|
||||||
|
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{cat?.avgTotalSold?.toFixed?.(2) ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">{r.moq ?? "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Input
|
||||||
|
className="w-24 text-right"
|
||||||
|
value={Number.isFinite(r.finalQty) ? r.finalQty : 0}
|
||||||
|
onChange={(e) => changeQty(idx, e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => removeRow(idx)} aria-label="Remove row">
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}), [categoryByKey, categoryOptions, changeCategory, changeQty, removeRow]);
|
||||||
|
|
||||||
|
const exportJson = useMemo(() => {
|
||||||
|
const items = orderRows
|
||||||
|
.filter((r) => (r.finalQty || 0) > 0 && !!(r.upc && r.upc.trim()))
|
||||||
|
.map((r) => ({ upc: r.upc!, quantity: r.finalQty }));
|
||||||
|
return {
|
||||||
|
supplierId: selectedSupplierId ?? null,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
itemCount: items.length,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}, [orderRows, selectedSupplierId]);
|
||||||
|
|
||||||
|
const canProcess = headers.length > 0 && rawRows.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick Order Builder</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Supplier + Clear */}
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="max-w-sm">
|
||||||
|
<div className="text-sm font-medium mb-1">Supplier</div>
|
||||||
|
<Select
|
||||||
|
value={selectedSupplierId ?? "__none"}
|
||||||
|
onValueChange={(v) => setSelectedSupplierId(v === "__none" ? undefined : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select supplier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[320px]">
|
||||||
|
<SelectItem value="__none">Select supplier…</SelectItem>
|
||||||
|
{supplierOptions.map((s) => (
|
||||||
|
<SelectItem key={String(s.value)} value={String(s.value)}>
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setPasted("");
|
||||||
|
setHeaders([]);
|
||||||
|
setRawRows([]);
|
||||||
|
setHeaderMap({});
|
||||||
|
setOrderRows([]);
|
||||||
|
setShowJson(false);
|
||||||
|
setSelectedSupplierId(undefined);
|
||||||
|
setScalePct(100);
|
||||||
|
setScaleInput("100");
|
||||||
|
setParsed(false);
|
||||||
|
setShowMapping(false);
|
||||||
|
try { localStorage.removeItem(DRAFT_KEY); } catch {}
|
||||||
|
toast.message("Draft cleared");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear Draft
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!parsed && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv,.tsv,.txt"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-sm">or paste below</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
placeholder="Paste rows (with a header): Product, SKU, Category, MOQ..."
|
||||||
|
value={pasted}
|
||||||
|
onChange={(e) => setPasted(e.target.value)}
|
||||||
|
className="min-h-[120px]"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handlePasteParse} disabled={!pasted.trim()}>
|
||||||
|
Parse Pasted Data
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{headers.length > 0 && showMapping && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm font-medium">Map Columns</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Product (recommended)</div>
|
||||||
|
<Select
|
||||||
|
value={headerMap.product}
|
||||||
|
onValueChange={(v) => setHeaderMap((m) => ({ ...m, product: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{headerOptions.map((o) => {
|
||||||
|
const ci = idToIndex.get(o.id)!;
|
||||||
|
const samples = rawRows
|
||||||
|
.map((r) => String((r && r[ci]) ?? "").trim())
|
||||||
|
.filter((v) => v.length > 0)
|
||||||
|
.slice(0, 3);
|
||||||
|
return (
|
||||||
|
<SelectItem key={o.id} value={o.id}>
|
||||||
|
<div className="flex flex-col text-left">
|
||||||
|
<span>{o.label}</span>
|
||||||
|
{samples.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">UPC / Barcode (recommended)</div>
|
||||||
|
<Select
|
||||||
|
value={headerMap.upc ?? "__none"}
|
||||||
|
onValueChange={(v) => setHeaderMap((m) => ({ ...m, upc: v === "__none" ? undefined : v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none">None</SelectItem>
|
||||||
|
{headerOptions.map((o) => {
|
||||||
|
const ci = idToIndex.get(o.id)!;
|
||||||
|
const samples = rawRows
|
||||||
|
.map((r) => String((r && r[ci]) ?? "").trim())
|
||||||
|
.filter((v) => v.length > 0)
|
||||||
|
.slice(0, 3);
|
||||||
|
return (
|
||||||
|
<SelectItem key={o.id} value={o.id}>
|
||||||
|
<div className="flex flex-col text-left">
|
||||||
|
<span>{o.label}</span>
|
||||||
|
{samples.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">SKU (optional)</div>
|
||||||
|
<Select
|
||||||
|
value={headerMap.sku ?? "__none"}
|
||||||
|
onValueChange={(v) => setHeaderMap((m) => ({ ...m, sku: v === "__none" ? undefined : v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none">None</SelectItem>
|
||||||
|
{headerOptions.map((o) => {
|
||||||
|
const ci = idToIndex.get(o.id)!;
|
||||||
|
const samples = rawRows
|
||||||
|
.map((r) => String((r && r[ci]) ?? "").trim())
|
||||||
|
.filter((v) => v.length > 0)
|
||||||
|
.slice(0, 3);
|
||||||
|
return (
|
||||||
|
<SelectItem key={o.id} value={o.id}>
|
||||||
|
<div className="flex flex-col text-left">
|
||||||
|
<span>{o.label}</span>
|
||||||
|
{samples.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Category Hint (optional)</div>
|
||||||
|
<Select
|
||||||
|
value={headerMap.categoryHint ?? "__none"}
|
||||||
|
onValueChange={(v) => setHeaderMap((m) => ({ ...m, categoryHint: v === "__none" ? undefined : v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none">None</SelectItem>
|
||||||
|
{headerOptions.map((o) => {
|
||||||
|
const ci = idToIndex.get(o.id)!;
|
||||||
|
const samples = rawRows
|
||||||
|
.map((r) => String((r && r[ci]) ?? "").trim())
|
||||||
|
.filter((v) => v.length > 0)
|
||||||
|
.slice(0, 3);
|
||||||
|
return (
|
||||||
|
<SelectItem key={o.id} value={o.id}>
|
||||||
|
<div className="flex flex-col text-left">
|
||||||
|
<span>{o.label}</span>
|
||||||
|
{samples.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">MOQ (optional)</div>
|
||||||
|
<Select
|
||||||
|
value={headerMap.moq ?? "__none"}
|
||||||
|
onValueChange={(v) => setHeaderMap((m) => ({ ...m, moq: v === "__none" ? undefined : v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select column" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none">None</SelectItem>
|
||||||
|
{headerOptions.map((o) => {
|
||||||
|
const ci = idToIndex.get(o.id)!;
|
||||||
|
const samples = rawRows
|
||||||
|
.map((r) => String((r && r[ci]) ?? "").trim())
|
||||||
|
.filter((v) => v.length > 0)
|
||||||
|
.slice(0, 3);
|
||||||
|
return (
|
||||||
|
<SelectItem key={o.id} value={o.id}>
|
||||||
|
<div className="flex flex-col text-left">
|
||||||
|
<span>{o.label}</span>
|
||||||
|
{samples.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{samples.join(" • ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<Button onClick={buildOrderRows} disabled={!canProcess || (!headerMap.product && !headerMap.upc)}>
|
||||||
|
Build Suggestions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orderRows.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Controls for existing suggestions */}
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Scale suggestions (%)</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="w-28"
|
||||||
|
value={scaleInput}
|
||||||
|
onChange={(e) => setScaleInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pb-2">
|
||||||
|
<Checkbox id="excludedOnly" checked={showExcludedOnly} onCheckedChange={(v) => setShowExcludedOnly(!!v)} />
|
||||||
|
<label htmlFor="excludedOnly" className="text-sm">Show excluded only</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pb-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowMapping((v) => !v)}>
|
||||||
|
{showMapping ? 'Hide Mapping' : 'Edit Mapping'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<OrderRowsTable rows={visibleRows} />
|
||||||
|
|
||||||
|
{/* Exclusion alert if some rows won't be exported */}
|
||||||
|
{(() => {
|
||||||
|
const excluded = orderRows.filter((r) => !(r.finalQty > 0 && r.upc && r.upc.trim()));
|
||||||
|
if (excluded.length === 0) return null;
|
||||||
|
const missingUpc = excluded.filter((r) => !r.upc || !r.upc.trim()).length;
|
||||||
|
const zeroQty = excluded.filter((r) => !(r.finalQty > 0)).length;
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Some rows will not be included</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<div className="text-sm">
|
||||||
|
{excluded.length} row{excluded.length !== 1 ? "s" : ""} excluded from JSON
|
||||||
|
<ul className="list-disc ml-5">
|
||||||
|
{missingUpc > 0 && <li>{missingUpc} missing UPC</li>}
|
||||||
|
{zeroQty > 0 && <li>{zeroQty} with zero quantity</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowJson((s) => !s)}>
|
||||||
|
{showJson ? "Hide" : "Preview"} JSON
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowJson(true);
|
||||||
|
navigator.clipboard?.writeText(JSON.stringify(exportJson, null, 2)).then(
|
||||||
|
() => toast.success("JSON copied"),
|
||||||
|
() => toast.message("JSON ready (copy failed)")
|
||||||
|
).finally(() => {
|
||||||
|
try { localStorage.removeItem(DRAFT_KEY); } catch {}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showJson && (
|
||||||
|
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(exportJson, null, 2)}
|
||||||
|
</Code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,21 +8,17 @@ interface Product {
|
|||||||
pid: string;
|
pid: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
title: string;
|
title: string;
|
||||||
stock_quantity: number;
|
total_sold: number;
|
||||||
daily_sales_avg: number;
|
|
||||||
forecast_units: number;
|
|
||||||
forecast_revenue: number;
|
|
||||||
confidence_level: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForecastItem {
|
export interface ForecastItem {
|
||||||
category: string;
|
category: string;
|
||||||
categoryPath: string;
|
categoryPath: string;
|
||||||
avgDailySales: number;
|
|
||||||
totalSold: number;
|
totalSold: number;
|
||||||
numProducts: number;
|
numProducts: number;
|
||||||
avgPrice: number;
|
|
||||||
avgTotalSold: number;
|
avgTotalSold: number;
|
||||||
|
minSold: number;
|
||||||
|
maxSold: number;
|
||||||
products?: Product[];
|
products?: Product[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +53,7 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "avgDailySales",
|
accessorKey: "avgTotalSold",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -65,16 +61,54 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
|||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Avg Daily Sales
|
Avg Total Sold
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const value = row.getValue("avgDailySales") as number;
|
const value = row.getValue("avgTotalSold") as number;
|
||||||
return value?.toFixed(2) || "0.00";
|
return value?.toFixed(2) || "0.00";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "minSold",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Min Sold
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const value = row.getValue("minSold") as number;
|
||||||
|
return value?.toLocaleString() || "0";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "maxSold",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Max Sold
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const value = row.getValue("maxSold") as number;
|
||||||
|
return value?.toLocaleString() || "0";
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "totalSold",
|
accessorKey: "totalSold",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
@@ -112,44 +146,6 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
|||||||
return value?.toLocaleString() || "0";
|
return value?.toLocaleString() || "0";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "avgTotalSold",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Avg Total Sold
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = row.getValue("avgTotalSold") as number;
|
|
||||||
return value?.toFixed(2) || "0.00";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "avgPrice",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Avg Price
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = row.getValue("avgPrice") as number;
|
|
||||||
return `$${value?.toFixed(2) || "0.00"}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const renderSubComponent = ({ row }: { row: any }) => {
|
export const renderSubComponent = ({ row }: { row: any }) => {
|
||||||
@@ -161,11 +157,7 @@ export const renderSubComponent = ({ row }: { row: any }) => {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Product</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead className="text-right">Stock</TableHead>
|
<TableHead className="text-right">Sold</TableHead>
|
||||||
<TableHead className="text-right">Daily Sales</TableHead>
|
|
||||||
<TableHead className="text-right">Forecast Units</TableHead>
|
|
||||||
<TableHead className="text-right">Forecast Revenue</TableHead>
|
|
||||||
<TableHead className="text-right">Confidence</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -182,11 +174,7 @@ export const renderSubComponent = ({ row }: { row: any }) => {
|
|||||||
</a>
|
</a>
|
||||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
<TableCell className="text-right">{product.total_sold?.toLocaleString?.() ?? product.total_sold}</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>
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useLocation, useNavigate, Link } from "react-router-dom";
|
import { useLocation, useNavigate, Link } from "react-router-dom";
|
||||||
import { Protected } from "@/components/auth/Protected";
|
import { Protected } from "@/components/auth/Protected";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
const dashboardItems = [
|
const dashboardItems = [
|
||||||
{
|
{
|
||||||
@@ -112,6 +114,7 @@ export function AppSidebar() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
useSidebar();
|
useSidebar();
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
@@ -119,6 +122,12 @@ export function AppSidebar() {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if user has access to any items in a section
|
||||||
|
const hasAccessToSection = (items: typeof inventoryItems): boolean => {
|
||||||
|
if (user?.is_admin) return true;
|
||||||
|
return items.some(item => user?.permissions?.includes(item.permission));
|
||||||
|
};
|
||||||
|
|
||||||
const renderMenuItems = (items: typeof inventoryItems) => {
|
const renderMenuItems = (items: typeof inventoryItems) => {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
@@ -180,6 +189,7 @@ export function AppSidebar() {
|
|||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{/* Dashboard Section */}
|
{/* Dashboard Section */}
|
||||||
|
{hasAccessToSection(dashboardItems) && (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Dashboard</SidebarGroupLabel>
|
<SidebarGroupLabel>Dashboard</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -188,9 +198,10 @@ export function AppSidebar() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Inventory Section */}
|
{/* Inventory Section */}
|
||||||
|
{hasAccessToSection(inventoryItems) && (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Inventory</SidebarGroupLabel>
|
<SidebarGroupLabel>Inventory</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -199,9 +210,10 @@ export function AppSidebar() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Product Setup Section */}
|
{/* Product Setup Section */}
|
||||||
|
{hasAccessToSection(productSetupItems) && (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Product Setup</SidebarGroupLabel>
|
<SidebarGroupLabel>Product Setup</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -210,9 +222,10 @@ export function AppSidebar() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Chat Section */}
|
{/* Chat Section */}
|
||||||
|
{hasAccessToSection(chatItems) && (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Chat</SidebarGroupLabel>
|
<SidebarGroupLabel>Chat</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -221,17 +234,13 @@ export function AppSidebar() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
<SidebarSeparator />
|
)}
|
||||||
|
|
||||||
{/* Settings Section */}
|
{/* Settings Section */}
|
||||||
|
<Protected permission="access:settings" fallback={null}>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
|
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<Protected
|
|
||||||
permission="access:settings"
|
|
||||||
fallback={null}
|
|
||||||
>
|
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
@@ -246,10 +255,10 @@ export function AppSidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</Protected>
|
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
</Protected>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ const SupplierSelector = React.memo(({
|
|||||||
{suppliers?.map((supplier: any) => (
|
{suppliers?.map((supplier: any) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={supplier.value}
|
key={supplier.value}
|
||||||
value={supplier.label}
|
value={`${supplier.label} ${supplier.value}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(supplier.value);
|
onChange(supplier.value);
|
||||||
setOpen(false); // Close popover after selection
|
setOpen(false); // Close popover after selection
|
||||||
@@ -347,11 +347,25 @@ const CompanySelector = React.memo(({
|
|||||||
companies: any[]
|
companies: any[]
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
const handleCommandListWheel = (e: React.WheelEvent) => {
|
const handleCommandListWheel = (e: React.WheelEvent) => {
|
||||||
e.currentTarget.scrollTop += e.deltaY;
|
e.currentTarget.scrollTop += e.deltaY;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filtered and sliced list to prevent UI freezes with very large lists
|
||||||
|
const filteredCompanies = React.useMemo(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
// When no search, show a capped subset for performance
|
||||||
|
return (companies || []).slice(0, 200);
|
||||||
|
}
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return (companies || []).filter((c: any) => (
|
||||||
|
String(c.label || '').toLowerCase().includes(q) ||
|
||||||
|
String(c.value || '').toLowerCase().includes(q)
|
||||||
|
));
|
||||||
|
}, [companies, query]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -369,14 +383,14 @@ const CompanySelector = React.memo(({
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search companies..." />
|
<CommandInput placeholder="Search companies..." value={query} onValueChange={setQuery} />
|
||||||
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleCommandListWheel}>
|
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleCommandListWheel}>
|
||||||
<CommandEmpty>No companies found.</CommandEmpty>
|
<CommandEmpty>No companies found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{companies?.map((company: any) => (
|
{filteredCompanies.map((company: any) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={company.value}
|
key={company.value}
|
||||||
value={company.label}
|
value={`${company.label} ${company.value}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(company.value);
|
onChange(company.value);
|
||||||
setOpen(false); // Close popover after selection
|
setOpen(false); // Close popover after selection
|
||||||
@@ -443,7 +457,7 @@ const LineSelector = React.memo(({
|
|||||||
{lines?.map((line: any) => (
|
{lines?.map((line: any) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={line.value}
|
key={line.value}
|
||||||
value={line.label}
|
value={`${line.label} ${line.value}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(line.value);
|
onChange(line.value);
|
||||||
setOpen(false); // Close popover after selection
|
setOpen(false); // Close popover after selection
|
||||||
@@ -510,7 +524,7 @@ const SubLineSelector = React.memo(({
|
|||||||
{sublines?.map((subline: any) => (
|
{sublines?.map((subline: any) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={subline.value}
|
key={subline.value}
|
||||||
value={subline.label}
|
value={`${subline.label} ${subline.value}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(subline.value);
|
onChange(subline.value);
|
||||||
setOpen(false); // Close popover after selection
|
setOpen(false); // Close popover after selection
|
||||||
|
|||||||
@@ -186,9 +186,11 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
// Apply global selections to each row of data if they exist
|
// Apply global selections to each row of data if they exist
|
||||||
const dataWithGlobalSelections = globalSelections
|
const dataWithGlobalSelections = globalSelections
|
||||||
? dataWithMeta.map((row: Data<string> & { __index?: string }) => {
|
? dataWithMeta.map((row: Data<string> & { __index?: string }) => {
|
||||||
const newRow = { ...row };
|
const newRow = { ...row } as any;
|
||||||
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
|
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
|
||||||
if (globalSelections.company) newRow.company = globalSelections.company;
|
if (globalSelections.company) newRow.company = globalSelections.company;
|
||||||
|
if (globalSelections.line) newRow.line = globalSelections.line;
|
||||||
|
if (globalSelections.subline) newRow.subline = globalSelections.subline;
|
||||||
return newRow;
|
return newRow;
|
||||||
})
|
})
|
||||||
: dataWithMeta;
|
: dataWithMeta;
|
||||||
|
|||||||
@@ -542,7 +542,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
<div className="text-xs font-semibold text-blue-700 mb-2">
|
<div className="text-xs font-semibold text-blue-700 mb-2">
|
||||||
Company-Specific Instructions
|
Company-Specific Instructions
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap">
|
<pre className="whitespace-pre-wrap break-words break-all">
|
||||||
{content.substring(
|
{content.substring(
|
||||||
companySpecificStartIndex,
|
companySpecificStartIndex,
|
||||||
companySpecificEndIndex +
|
companySpecificEndIndex +
|
||||||
@@ -566,7 +566,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
<div className="text-xs font-semibold text-amber-700 mb-2">
|
<div className="text-xs font-semibold text-amber-700 mb-2">
|
||||||
Taxonomy Data
|
Taxonomy Data
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap">
|
<pre className="whitespace-pre-wrap break-words break-all">
|
||||||
{content.substring(
|
{content.substring(
|
||||||
actualTaxonomyStartIndex,
|
actualTaxonomyStartIndex,
|
||||||
taxEnd
|
taxEnd
|
||||||
@@ -587,7 +587,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
<div className="text-xs font-semibold text-pink-700 mb-2">
|
<div className="text-xs font-semibold text-pink-700 mb-2">
|
||||||
Product Data
|
Product Data
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap">
|
<pre className="whitespace-pre-wrap break-words break-all">
|
||||||
{content.substring(
|
{content.substring(
|
||||||
productDataStartIndex
|
productDataStartIndex
|
||||||
)}
|
)}
|
||||||
@@ -600,7 +600,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="whitespace-pre-wrap">
|
<pre className="whitespace-pre-wrap break-words break-all">
|
||||||
{message.content}
|
{message.content}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -612,7 +612,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-full w-full">
|
<ScrollArea className="h-full w-full">
|
||||||
<Code className="whitespace-pre-wrap p-4 break-normal max-w-full">
|
<Code className="whitespace-pre-wrap break-words break-all p-4 max-w-full overflow-x-hidden">
|
||||||
{currentPrompt.prompt}
|
{currentPrompt.prompt}
|
||||||
</Code>
|
</Code>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface UpcValidationTableAdapterProps<T extends string> {
|
|||||||
copyDown: (rowIndex: number, fieldKey: string, endRowIndex?: number) => void
|
copyDown: (rowIndex: number, fieldKey: string, endRowIndex?: number) => void
|
||||||
validatingCells: Set<string>
|
validatingCells: Set<string>
|
||||||
isLoadingTemplates: boolean
|
isLoadingTemplates: boolean
|
||||||
|
editingCells: Set<string>
|
||||||
|
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||||
rowProductLines: Record<string, any[]>
|
rowProductLines: Record<string, any[]>
|
||||||
rowSublines: Record<string, any[]>
|
rowSublines: Record<string, any[]>
|
||||||
isLoadingLines: Record<string, boolean>
|
isLoadingLines: Record<string, boolean>
|
||||||
@@ -53,6 +55,8 @@ function UpcValidationTableAdapter<T extends string>({
|
|||||||
copyDown,
|
copyDown,
|
||||||
validatingCells: externalValidatingCells,
|
validatingCells: externalValidatingCells,
|
||||||
isLoadingTemplates,
|
isLoadingTemplates,
|
||||||
|
editingCells,
|
||||||
|
setEditingCells,
|
||||||
rowProductLines,
|
rowProductLines,
|
||||||
rowSublines,
|
rowSublines,
|
||||||
isLoadingLines,
|
isLoadingLines,
|
||||||
@@ -86,11 +90,7 @@ function UpcValidationTableAdapter<T extends string>({
|
|||||||
|
|
||||||
// First add from itemNumbers directly - this is the source of truth for template applications
|
// First add from itemNumbers directly - this is the source of truth for template applications
|
||||||
if (itemNumbers) {
|
if (itemNumbers) {
|
||||||
// Log all numbers for debugging
|
|
||||||
console.log(`[ADAPTER-DEBUG] Received itemNumbers map with ${itemNumbers.size} entries`);
|
|
||||||
|
|
||||||
itemNumbers.forEach((itemNumber, rowIndex) => {
|
itemNumbers.forEach((itemNumber, rowIndex) => {
|
||||||
console.log(`[ADAPTER-DEBUG] Adding item number for row ${rowIndex} from itemNumbers: ${itemNumber}`);
|
|
||||||
result.set(rowIndex, itemNumber);
|
result.set(rowIndex, itemNumber);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -100,14 +100,12 @@ function UpcValidationTableAdapter<T extends string>({
|
|||||||
// Check if upcValidation has an item number for this row
|
// Check if upcValidation has an item number for this row
|
||||||
const itemNumber = upcValidation.getItemNumber(index);
|
const itemNumber = upcValidation.getItemNumber(index);
|
||||||
if (itemNumber) {
|
if (itemNumber) {
|
||||||
console.log(`[ADAPTER-DEBUG] Adding item number for row ${index} from upcValidation: ${itemNumber}`);
|
|
||||||
result.set(index, itemNumber);
|
result.set(index, itemNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check if it's directly in the data
|
// Also check if it's directly in the data
|
||||||
const dataItemNumber = data[index].item_number;
|
const dataItemNumber = data[index].item_number;
|
||||||
if (dataItemNumber && !result.has(index)) {
|
if (dataItemNumber && !result.has(index)) {
|
||||||
console.log(`[ADAPTER-DEBUG] Adding item number for row ${index} from data: ${dataItemNumber}`);
|
|
||||||
result.set(index, dataItemNumber);
|
result.set(index, dataItemNumber);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -151,6 +149,8 @@ function UpcValidationTableAdapter<T extends string>({
|
|||||||
rowSublines={rowSublines}
|
rowSublines={rowSublines}
|
||||||
isLoadingLines={isLoadingLines}
|
isLoadingLines={isLoadingLines}
|
||||||
isLoadingSublines={isLoadingSublines}
|
isLoadingSublines={isLoadingSublines}
|
||||||
|
editingCells={editingCells}
|
||||||
|
setEditingCells={setEditingCells}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ const BaseCellContent = React.memo(({
|
|||||||
hasErrors,
|
hasErrors,
|
||||||
options = [],
|
options = [],
|
||||||
className = '',
|
className = '',
|
||||||
fieldKey = ''
|
fieldKey = '',
|
||||||
|
onStartEdit,
|
||||||
|
onEndEdit
|
||||||
}: {
|
}: {
|
||||||
field: Field<string>;
|
field: Field<string>;
|
||||||
value: any;
|
value: any;
|
||||||
@@ -87,6 +89,8 @@ const BaseCellContent = React.memo(({
|
|||||||
options?: readonly any[];
|
options?: readonly any[];
|
||||||
className?: string;
|
className?: string;
|
||||||
fieldKey?: string;
|
fieldKey?: string;
|
||||||
|
onStartEdit?: () => void;
|
||||||
|
onEndEdit?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
// Get field type information
|
// Get field type information
|
||||||
const fieldType = fieldKey === 'line' || fieldKey === 'subline'
|
const fieldType = fieldKey === 'line' || fieldKey === 'subline'
|
||||||
@@ -113,6 +117,8 @@ const BaseCellContent = React.memo(({
|
|||||||
field={{...field, fieldType: { type: 'select', options }}}
|
field={{...field, fieldType: { type: 'select', options }}}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onStartEdit={onStartEdit}
|
||||||
|
onEndEdit={onEndEdit}
|
||||||
options={options}
|
options={options}
|
||||||
hasErrors={hasErrors}
|
hasErrors={hasErrors}
|
||||||
className={className}
|
className={className}
|
||||||
@@ -127,6 +133,8 @@ const BaseCellContent = React.memo(({
|
|||||||
field={field}
|
field={field}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onStartEdit={onStartEdit}
|
||||||
|
onEndEdit={onEndEdit}
|
||||||
options={options}
|
options={options}
|
||||||
hasErrors={hasErrors}
|
hasErrors={hasErrors}
|
||||||
className={className}
|
className={className}
|
||||||
@@ -141,6 +149,8 @@ const BaseCellContent = React.memo(({
|
|||||||
field={field}
|
field={field}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onStartEdit={onStartEdit}
|
||||||
|
onEndEdit={onEndEdit}
|
||||||
options={options}
|
options={options}
|
||||||
hasErrors={hasErrors}
|
hasErrors={hasErrors}
|
||||||
className={className}
|
className={className}
|
||||||
@@ -154,6 +164,8 @@ const BaseCellContent = React.memo(({
|
|||||||
field={field}
|
field={field}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onStartEdit={onStartEdit}
|
||||||
|
onEndEdit={onEndEdit}
|
||||||
hasErrors={hasErrors}
|
hasErrors={hasErrors}
|
||||||
isMultiline={isMultiline}
|
isMultiline={isMultiline}
|
||||||
isPrice={isPrice}
|
isPrice={isPrice}
|
||||||
@@ -191,6 +203,8 @@ export interface ValidationCellProps {
|
|||||||
rowIndex: number
|
rowIndex: number
|
||||||
copyDown?: (endRowIndex?: number) => void
|
copyDown?: (endRowIndex?: number) => void
|
||||||
totalRows?: number
|
totalRows?: number
|
||||||
|
editingCells: Set<string>
|
||||||
|
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add efficient error message extraction function
|
// Add efficient error message extraction function
|
||||||
@@ -288,7 +302,9 @@ const ValidationCell = React.memo(({
|
|||||||
width,
|
width,
|
||||||
copyDown,
|
copyDown,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
totalRows = 0
|
totalRows = 0,
|
||||||
|
// editingCells not used; keep setEditingCells for API compatibility
|
||||||
|
setEditingCells
|
||||||
}: ValidationCellProps) => {
|
}: ValidationCellProps) => {
|
||||||
// Use the CopyDown context
|
// Use the CopyDown context
|
||||||
const copyDownContext = React.useContext(CopyDownContext);
|
const copyDownContext = React.useContext(CopyDownContext);
|
||||||
@@ -297,9 +313,6 @@ const ValidationCell = React.memo(({
|
|||||||
// This ensures that when the itemNumber changes, the display value changes
|
// This ensures that when the itemNumber changes, the display value changes
|
||||||
let displayValue;
|
let displayValue;
|
||||||
if (fieldKey === 'item_number' && itemNumber) {
|
if (fieldKey === 'item_number' && itemNumber) {
|
||||||
// Always log when an item_number field is rendered to help debug
|
|
||||||
console.log(`[VC-DEBUG] ValidationCell rendering item_number for row=${rowIndex} with itemNumber=${itemNumber}, value=${value}`);
|
|
||||||
|
|
||||||
// Prioritize itemNumber prop for item_number fields
|
// Prioritize itemNumber prop for item_number fields
|
||||||
displayValue = itemNumber;
|
displayValue = itemNumber;
|
||||||
} else {
|
} else {
|
||||||
@@ -324,6 +337,22 @@ const ValidationCell = React.memo(({
|
|||||||
// Add state for hover on target row
|
// Add state for hover on target row
|
||||||
const [isTargetRowHovered, setIsTargetRowHovered] = React.useState(false);
|
const [isTargetRowHovered, setIsTargetRowHovered] = React.useState(false);
|
||||||
|
|
||||||
|
// PERFORMANCE FIX: Create cell key for editing state management
|
||||||
|
const cellKey = `${rowIndex}-${fieldKey}`;
|
||||||
|
|
||||||
|
// SINGLE-CLICK EDITING FIX: Create editing state management functions
|
||||||
|
const handleStartEdit = React.useCallback(() => {
|
||||||
|
setEditingCells(prev => new Set([...prev, cellKey]));
|
||||||
|
}, [setEditingCells, cellKey]);
|
||||||
|
|
||||||
|
const handleEndEdit = React.useCallback(() => {
|
||||||
|
setEditingCells(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(cellKey);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, [setEditingCells, cellKey]);
|
||||||
|
|
||||||
// Force isValidating to be a boolean
|
// Force isValidating to be a boolean
|
||||||
const isLoading = isValidating === true;
|
const isLoading = isValidating === true;
|
||||||
|
|
||||||
@@ -461,6 +490,8 @@ const ValidationCell = React.memo(({
|
|||||||
options={options}
|
options={options}
|
||||||
className={cellClassName}
|
className={cellClassName}
|
||||||
fieldKey={fieldKey}
|
fieldKey={fieldKey}
|
||||||
|
onStartEdit={handleStartEdit}
|
||||||
|
onEndEdit={handleEndEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ const ValidationContainer = <T extends string>({
|
|||||||
fields,
|
fields,
|
||||||
isLoadingTemplates,
|
isLoadingTemplates,
|
||||||
validatingCells,
|
validatingCells,
|
||||||
setValidatingCells
|
setValidatingCells,
|
||||||
|
editingCells,
|
||||||
|
setEditingCells
|
||||||
} = validationState
|
} = validationState
|
||||||
|
|
||||||
// Use product lines fetching hook
|
// Use product lines fetching hook
|
||||||
@@ -121,9 +123,23 @@ const ValidationContainer = <T extends string>({
|
|||||||
|
|
||||||
// Function to mark a row for revalidation
|
// Function to mark a row for revalidation
|
||||||
const markRowForRevalidation = useCallback((rowIndex: number, fieldKey?: string) => {
|
const markRowForRevalidation = useCallback((rowIndex: number, fieldKey?: string) => {
|
||||||
|
// Map filtered rowIndex to original data index via __index
|
||||||
|
const originalIndex = (() => {
|
||||||
|
try {
|
||||||
|
const row = filteredData[rowIndex];
|
||||||
|
if (!row) return rowIndex;
|
||||||
|
const id = row.__index;
|
||||||
|
if (!id) return rowIndex;
|
||||||
|
const idx = data.findIndex(r => r.__index === id);
|
||||||
|
return idx >= 0 ? idx : rowIndex;
|
||||||
|
} catch {
|
||||||
|
return rowIndex;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
setFieldsToRevalidate(prev => {
|
setFieldsToRevalidate(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.add(rowIndex);
|
newSet.add(originalIndex);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,16 +147,16 @@ const ValidationContainer = <T extends string>({
|
|||||||
if (fieldKey) {
|
if (fieldKey) {
|
||||||
setFieldsToRevalidateMap(prev => {
|
setFieldsToRevalidateMap(prev => {
|
||||||
const newMap = { ...prev };
|
const newMap = { ...prev };
|
||||||
if (!newMap[rowIndex]) {
|
if (!newMap[originalIndex]) {
|
||||||
newMap[rowIndex] = [];
|
newMap[originalIndex] = [];
|
||||||
}
|
}
|
||||||
if (!newMap[rowIndex].includes(fieldKey)) {
|
if (!newMap[originalIndex].includes(fieldKey)) {
|
||||||
newMap[rowIndex] = [...newMap[rowIndex], fieldKey];
|
newMap[originalIndex] = [...newMap[originalIndex], fieldKey];
|
||||||
}
|
}
|
||||||
return newMap;
|
return newMap;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, [data, filteredData]);
|
||||||
|
|
||||||
// Add a ref to track the last validation time
|
// Add a ref to track the last validation time
|
||||||
|
|
||||||
@@ -160,8 +176,6 @@ const ValidationContainer = <T extends string>({
|
|||||||
// Clear the fields map
|
// Clear the fields map
|
||||||
setFieldsToRevalidateMap({});
|
setFieldsToRevalidateMap({});
|
||||||
|
|
||||||
console.log(`Validating ${rowsToRevalidate.length} rows with specific fields`);
|
|
||||||
|
|
||||||
// Revalidate each row with specific fields information
|
// Revalidate each row with specific fields information
|
||||||
validationState.revalidateRows(rowsToRevalidate, fieldsMap);
|
validationState.revalidateRows(rowsToRevalidate, fieldsMap);
|
||||||
}, [fieldsToRevalidate, validationState, fieldsToRevalidateMap]);
|
}, [fieldsToRevalidate, validationState, fieldsToRevalidateMap]);
|
||||||
@@ -488,81 +502,39 @@ const ValidationContainer = <T extends string>({
|
|||||||
// Detect if this is a direct item_number edit
|
// Detect if this is a direct item_number edit
|
||||||
const isItemNumberEdit = key === 'item_number' as T;
|
const isItemNumberEdit = key === 'item_number' as T;
|
||||||
|
|
||||||
// For item_number edits, we need special handling to ensure they persist
|
// For item_number edits, use core updateRow to atomically update + validate
|
||||||
if (isItemNumberEdit) {
|
if (isItemNumberEdit) {
|
||||||
console.log(`Manual edit to item_number: ${value}`);
|
const idx = originalIndex >= 0 ? originalIndex : rowIndex;
|
||||||
|
validationState.updateRow(idx, key as unknown as any, processedValue);
|
||||||
// First, update data immediately to ensure edit takes effect
|
|
||||||
setData(prevData => {
|
|
||||||
const newData = [...prevData];
|
|
||||||
if (originalIndex >= 0 && originalIndex < newData.length) {
|
|
||||||
newData[originalIndex] = {
|
|
||||||
...newData[originalIndex],
|
|
||||||
[key]: processedValue
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return newData;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark for revalidation after a delay to ensure data update completes first
|
|
||||||
setTimeout(() => {
|
|
||||||
markRowForRevalidation(rowIndex, key as string);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// Return early to prevent double-updating
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For all other fields, use standard approach
|
// For all other fields, use core updateRow for atomic update + validation
|
||||||
// Always use setData for updating - immediate update for better UX
|
const idx = originalIndex >= 0 ? originalIndex : rowIndex;
|
||||||
const updatedRow = { ...rowData, [key]: processedValue };
|
validationState.updateRow(idx, key as unknown as any, processedValue);
|
||||||
|
|
||||||
// Mark this row for revalidation to clear any existing errors
|
// Secondary effects - using requestAnimationFrame for better performance
|
||||||
markRowForRevalidation(rowIndex, key as string);
|
requestAnimationFrame(() => {
|
||||||
|
|
||||||
// Update the data immediately to show the change
|
|
||||||
setData(prevData => {
|
|
||||||
const newData = [...prevData];
|
|
||||||
if (originalIndex >= 0 && originalIndex < newData.length) {
|
|
||||||
// Create a new row object with the updated field
|
|
||||||
newData[originalIndex] = {
|
|
||||||
...newData[originalIndex],
|
|
||||||
[key]: processedValue
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return newData;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Secondary effects - using a timeout to ensure UI updates first
|
|
||||||
setTimeout(() => {
|
|
||||||
// Handle company change - clear line/subline and fetch product lines
|
// Handle company change - clear line/subline and fetch product lines
|
||||||
if (key === 'company' && value) {
|
if (key === 'company' && value) {
|
||||||
console.log(`Company changed to ${value} for row ${rowIndex}, updating lines and sublines`);
|
|
||||||
|
|
||||||
// Clear any existing line/subline values immediately
|
// Clear any existing line/subline values immediately
|
||||||
setData(prevData => {
|
setData(prevData => {
|
||||||
const newData = [...prevData];
|
const newData = [...prevData];
|
||||||
const idx = newData.findIndex(item => item.__index === rowId);
|
const idx = newData.findIndex(item => item.__index === rowId);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
console.log(`Clearing line and subline values for row with ID ${rowId}`);
|
|
||||||
newData[idx] = {
|
newData[idx] = {
|
||||||
...newData[idx],
|
...newData[idx],
|
||||||
line: undefined,
|
line: undefined,
|
||||||
subline: undefined
|
subline: undefined
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
console.warn(`Could not find row with ID ${rowId} to clear line/subline values`);
|
|
||||||
}
|
}
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch product lines for the new company
|
// Fetch product lines for the new company with debouncing
|
||||||
if (rowId && value !== undefined) {
|
if (rowId && value !== undefined) {
|
||||||
const companyId = value.toString();
|
const companyId = value.toString();
|
||||||
|
|
||||||
// Force immediate fetch for better UX
|
|
||||||
console.log(`Immediately fetching product lines for company ${companyId} for row ${rowId}`);
|
|
||||||
|
|
||||||
// Set loading state first
|
// Set loading state first
|
||||||
setValidatingCells(prev => {
|
setValidatingCells(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@@ -570,10 +542,9 @@ const ValidationContainer = <T extends string>({
|
|||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Debounce the API call to prevent excessive requests
|
||||||
|
setTimeout(() => {
|
||||||
fetchProductLines(rowId, companyId)
|
fetchProductLines(rowId, companyId)
|
||||||
.then(lines => {
|
|
||||||
console.log(`Successfully loaded ${lines.length} product lines for company ${companyId}`);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error(`Error fetching product lines for company ${companyId}:`, err);
|
console.error(`Error fetching product lines for company ${companyId}:`, err);
|
||||||
toast.error("Failed to load product lines");
|
toast.error("Failed to load product lines");
|
||||||
@@ -586,13 +557,14 @@ const ValidationContainer = <T extends string>({
|
|||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}, 100); // 100ms debounce
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle supplier + UPC validation - using the most recent values
|
// Handle supplier + UPC validation - using the most recent values
|
||||||
if (key === 'supplier' && value) {
|
if (key === 'supplier' && value) {
|
||||||
// Get the latest UPC value from the updated row
|
// Get the latest UPC value from the updated row
|
||||||
const upcValue = updatedRow.upc || updatedRow.barcode;
|
const upcValue = (data[rowIndex] as any)?.upc || (data[rowIndex] as any)?.barcode;
|
||||||
|
|
||||||
if (upcValue) {
|
if (upcValue) {
|
||||||
console.log(`Validating UPC: rowIndex=${rowIndex}, supplier=${value}, upc=${upcValue}`);
|
console.log(`Validating UPC: rowIndex=${rowIndex}, supplier=${value}, upc=${upcValue}`);
|
||||||
@@ -689,7 +661,7 @@ const ValidationContainer = <T extends string>({
|
|||||||
// Handle UPC/barcode + supplier validation
|
// Handle UPC/barcode + supplier validation
|
||||||
if ((key === 'upc' || key === 'barcode') && value) {
|
if ((key === 'upc' || key === 'barcode') && value) {
|
||||||
// Get latest supplier from the updated row
|
// Get latest supplier from the updated row
|
||||||
const supplier = updatedRow.supplier;
|
const supplier = (data[rowIndex] as any)?.supplier;
|
||||||
|
|
||||||
if (supplier) {
|
if (supplier) {
|
||||||
console.log(`Validating UPC from UPC change: rowIndex=${rowIndex}, supplier=${supplier}, upc=${value}`);
|
console.log(`Validating UPC from UPC change: rowIndex=${rowIndex}, supplier=${supplier}, upc=${value}`);
|
||||||
@@ -728,7 +700,7 @@ const ValidationContainer = <T extends string>({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 0); // Using 0ms timeout to defer execution until after the UI update
|
}); // Using requestAnimationFrame to defer execution until after the UI update
|
||||||
}, [data, filteredData, setData, fetchProductLines, fetchSublines, upcValidation, markRowForRevalidation]);
|
}, [data, filteredData, setData, fetchProductLines, fetchSublines, upcValidation, markRowForRevalidation]);
|
||||||
|
|
||||||
// Fix the missing loading indicator clear code
|
// Fix the missing loading indicator clear code
|
||||||
@@ -800,15 +772,15 @@ const ValidationContainer = <T extends string>({
|
|||||||
markRowForRevalidation(targetRowIndex, fieldKey);
|
markRowForRevalidation(targetRowIndex, fieldKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the loading state for all cells after a short delay
|
// Clear the loading state for all cells efficiently
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
setValidatingCells(prev => {
|
setValidatingCells(prev => {
|
||||||
if (prev.size === 0) return prev;
|
if (prev.size === 0 || updatingCells.size === 0) return prev;
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
updatingCells.forEach(cell => newSet.delete(cell));
|
updatingCells.forEach(cell => newSet.delete(cell));
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
}, 100);
|
});
|
||||||
|
|
||||||
// If copying UPC or supplier fields, validate UPC for all rows
|
// If copying UPC or supplier fields, validate UPC for all rows
|
||||||
if (fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier') {
|
if (fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier') {
|
||||||
@@ -949,6 +921,8 @@ const ValidationContainer = <T extends string>({
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
templates={templates}
|
templates={templates}
|
||||||
applyTemplate={applyTemplateWrapper}
|
applyTemplate={applyTemplateWrapper}
|
||||||
|
editingCells={editingCells}
|
||||||
|
setEditingCells={setEditingCells}
|
||||||
getTemplateDisplayText={getTemplateDisplayText}
|
getTemplateDisplayText={getTemplateDisplayText}
|
||||||
isValidatingUpc={isRowValidatingUpc}
|
isValidatingUpc={isRowValidatingUpc}
|
||||||
validatingUpcRows={Array.from(upcValidation.validatingRows)}
|
validatingUpcRows={Array.from(upcValidation.validatingRows)}
|
||||||
@@ -987,7 +961,18 @@ const ValidationContainer = <T extends string>({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden">
|
<div
|
||||||
|
className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden"
|
||||||
|
onMouseUp={() => {
|
||||||
|
// Prevent stray text selection when clicking away from cells
|
||||||
|
try {
|
||||||
|
const sel = window.getSelection?.();
|
||||||
|
if (sel && sel.type === 'Range') {
|
||||||
|
sel.removeAllRanges();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<div className="flex h-[calc(100vh-9.5rem)] flex-col">
|
<div className="flex h-[calc(100vh-9.5rem)] flex-col">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ type ErrorType = {
|
|||||||
source?: string;
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable empty errors array to prevent unnecessary re-renders
|
||||||
|
// Use a mutable empty array to satisfy the ErrorType[] type
|
||||||
|
const EMPTY_ERRORS: ErrorType[] = [];
|
||||||
|
|
||||||
interface ValidationTableProps<T extends string> {
|
interface ValidationTableProps<T extends string> {
|
||||||
data: RowData<T>[]
|
data: RowData<T>[]
|
||||||
fields: Fields<T>
|
fields: Fields<T>
|
||||||
@@ -46,6 +50,8 @@ interface ValidationTableProps<T extends string> {
|
|||||||
itemNumbers: Map<number, string>
|
itemNumbers: Map<number, string>
|
||||||
isLoadingTemplates?: boolean
|
isLoadingTemplates?: boolean
|
||||||
copyDown: (rowIndex: number, key: string, endRowIndex?: number) => void
|
copyDown: (rowIndex: number, key: string, endRowIndex?: number) => void
|
||||||
|
editingCells: Set<string>
|
||||||
|
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +112,9 @@ const MemoizedCell = React.memo(({
|
|||||||
width,
|
width,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
copyDown,
|
copyDown,
|
||||||
totalRows
|
totalRows,
|
||||||
|
editingCells,
|
||||||
|
setEditingCells
|
||||||
}: {
|
}: {
|
||||||
field: Field<string>,
|
field: Field<string>,
|
||||||
value: any,
|
value: any,
|
||||||
@@ -119,7 +127,9 @@ const MemoizedCell = React.memo(({
|
|||||||
width: number,
|
width: number,
|
||||||
rowIndex: number,
|
rowIndex: number,
|
||||||
copyDown?: (endRowIndex?: number) => void,
|
copyDown?: (endRowIndex?: number) => void,
|
||||||
totalRows: number
|
totalRows: number,
|
||||||
|
editingCells: Set<string>,
|
||||||
|
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ValidationCell
|
<ValidationCell
|
||||||
@@ -135,37 +145,24 @@ const MemoizedCell = React.memo(({
|
|||||||
rowIndex={rowIndex}
|
rowIndex={rowIndex}
|
||||||
copyDown={copyDown}
|
copyDown={copyDown}
|
||||||
totalRows={totalRows}
|
totalRows={totalRows}
|
||||||
|
editingCells={editingCells}
|
||||||
|
setEditingCells={setEditingCells}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, (prev, next) => {
|
}, (prev, next) => {
|
||||||
// CRITICAL FIX: Never memoize item_number cells - always re-render them
|
// For item_number cells, only re-render when itemNumber actually changes
|
||||||
if (prev.fieldKey === 'item_number') {
|
if (prev.fieldKey === 'item_number') {
|
||||||
return false; // Never skip re-renders for item_number cells
|
return prev.itemNumber === next.itemNumber &&
|
||||||
|
prev.value === next.value &&
|
||||||
|
prev.isValidating === next.isValidating;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimize the memo comparison function for better performance
|
// Simplified memo comparison - most expensive checks removed
|
||||||
// Only re-render if these essential props change
|
// Note: editingCells changes are not checked here as they need immediate re-renders
|
||||||
const valueEqual = prev.value === next.value;
|
return prev.value === next.value &&
|
||||||
const isValidatingEqual = prev.isValidating === next.isValidating;
|
prev.isValidating === next.isValidating &&
|
||||||
|
prev.errors === next.errors &&
|
||||||
// Shallow equality check for errors array
|
prev.options === next.options;
|
||||||
const errorsEqual = prev.errors === next.errors || (
|
|
||||||
Array.isArray(prev.errors) &&
|
|
||||||
Array.isArray(next.errors) &&
|
|
||||||
prev.errors.length === next.errors.length &&
|
|
||||||
prev.errors.every((err, idx) => err === next.errors[idx])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Shallow equality check for options array
|
|
||||||
const optionsEqual = prev.options === next.options || (
|
|
||||||
Array.isArray(prev.options) &&
|
|
||||||
Array.isArray(next.options) &&
|
|
||||||
prev.options.length === next.options.length &&
|
|
||||||
prev.options.every((opt, idx) => opt === next.options?.[idx])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Skip checking for props that rarely change
|
|
||||||
return valueEqual && isValidatingEqual && errorsEqual && optionsEqual;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
MemoizedCell.displayName = 'MemoizedCell';
|
MemoizedCell.displayName = 'MemoizedCell';
|
||||||
@@ -185,6 +182,8 @@ const ValidationTable = <T extends string>({
|
|||||||
itemNumbers,
|
itemNumbers,
|
||||||
isLoadingTemplates = false,
|
isLoadingTemplates = false,
|
||||||
copyDown,
|
copyDown,
|
||||||
|
editingCells,
|
||||||
|
setEditingCells,
|
||||||
rowProductLines = {},
|
rowProductLines = {},
|
||||||
rowSublines = {},
|
rowSublines = {},
|
||||||
isLoadingLines = {},
|
isLoadingLines = {},
|
||||||
@@ -394,9 +393,19 @@ const ValidationTable = <T extends string>({
|
|||||||
options = rowSublines[rowId];
|
options = rowSublines[rowId];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if this cell is in loading state - use a clear consistent approach
|
// Get the current cell value first
|
||||||
|
const currentValue = fieldKey === 'item_number' && row.original[field.key]
|
||||||
|
? row.original[field.key]
|
||||||
|
: row.original[field.key as keyof typeof row.original];
|
||||||
|
|
||||||
|
// Determine if this cell is in loading state - only show loading for empty fields
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
|
|
||||||
|
// Only show loading if the field is currently empty
|
||||||
|
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '' ||
|
||||||
|
(Array.isArray(currentValue) && currentValue.length === 0);
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
// Check the validatingCells Set first (for item_number and other fields)
|
// Check the validatingCells Set first (for item_number and other fields)
|
||||||
const cellLoadingKey = `${row.index}-${fieldKey}`;
|
const cellLoadingKey = `${row.index}-${fieldKey}`;
|
||||||
if (validatingCells.has(cellLoadingKey)) {
|
if (validatingCells.has(cellLoadingKey)) {
|
||||||
@@ -413,9 +422,11 @@ const ValidationTable = <T extends string>({
|
|||||||
else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) {
|
else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get validation errors for this cell
|
// Get validation errors for this cell
|
||||||
const cellErrors = validationErrors.get(row.index)?.[fieldKey] || [];
|
// Use stable EMPTY_ERRORS to avoid new array creation on every render
|
||||||
|
const cellErrors = validationErrors.get(row.index)?.[fieldKey] || EMPTY_ERRORS;
|
||||||
|
|
||||||
// Create a copy of the field with guaranteed field type for line and subline fields
|
// Create a copy of the field with guaranteed field type for line and subline fields
|
||||||
let fieldWithType = field;
|
let fieldWithType = field;
|
||||||
@@ -448,19 +459,16 @@ const ValidationTable = <T extends string>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL: For item_number fields, create a unique key that includes the itemNumber value
|
// Create stable keys that only change when actual content changes
|
||||||
// This forces a complete re-render when the itemNumber changes
|
|
||||||
const cellKey = fieldKey === 'item_number'
|
const cellKey = fieldKey === 'item_number'
|
||||||
? `cell-${row.index}-${fieldKey}-${itemNumber || 'empty'}-${Date.now()}` // Force re-render on every render cycle for item_number
|
? `cell-${row.index}-${fieldKey}-${itemNumber || 'empty'}` // Only change when itemNumber actually changes
|
||||||
: `cell-${row.index}-${fieldKey}`;
|
: `cell-${row.index}-${fieldKey}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MemoizedCell
|
<MemoizedCell
|
||||||
key={cellKey} // CRITICAL: Add key to force re-render when itemNumber changes
|
key={cellKey} // CRITICAL: Add key to force re-render when itemNumber changes
|
||||||
field={fieldWithType as Field<string>}
|
field={fieldWithType as Field<string>}
|
||||||
value={fieldKey === 'item_number' && row.original[field.key]
|
value={currentValue}
|
||||||
? row.original[field.key] // Use direct value from row data
|
|
||||||
: row.original[field.key as keyof typeof row.original]}
|
|
||||||
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
|
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
|
||||||
errors={cellErrors}
|
errors={cellErrors}
|
||||||
isValidating={isLoading}
|
isValidating={isLoading}
|
||||||
@@ -471,6 +479,8 @@ const ValidationTable = <T extends string>({
|
|||||||
rowIndex={row.index}
|
rowIndex={row.index}
|
||||||
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
|
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
|
||||||
totalRows={data.length}
|
totalRows={data.length}
|
||||||
|
editingCells={editingCells}
|
||||||
|
setEditingCells={setEditingCells}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -678,6 +688,10 @@ const areEqual = (prev: ValidationTableProps<any>, next: ValidationTableProps<an
|
|||||||
// Fast path: data length change always means re-render
|
// Fast path: data length change always means re-render
|
||||||
if (prev.data.length !== next.data.length) return false;
|
if (prev.data.length !== next.data.length) return false;
|
||||||
|
|
||||||
|
// CRITICAL: Check if data content has actually changed
|
||||||
|
// Simple reference equality check - if data array reference changed, re-render
|
||||||
|
if (prev.data !== next.data) return false;
|
||||||
|
|
||||||
// Efficiently check row selection changes
|
// Efficiently check row selection changes
|
||||||
const prevSelectionKeys = Object.keys(prev.rowSelection);
|
const prevSelectionKeys = Object.keys(prev.rowSelection);
|
||||||
const nextSelectionKeys = Object.keys(next.rowSelection);
|
const nextSelectionKeys = Object.keys(next.rowSelection);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback, useTransition, useRef, useEffect, useMemo } from 'react'
|
import React, { useState, useCallback, useMemo } from 'react'
|
||||||
import { Field } from '../../../../types'
|
import { Field } from '../../../../types'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -17,19 +17,7 @@ interface InputCellProps<T extends string> {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add efficient price formatting utility
|
// (removed unused formatPrice helper)
|
||||||
const formatPrice = (value: string): string => {
|
|
||||||
// Remove any non-numeric characters except decimal point
|
|
||||||
const numericValue = value.replace(/[^\d.]/g, '');
|
|
||||||
|
|
||||||
// Parse as float and format to 2 decimal places
|
|
||||||
const numValue = parseFloat(numericValue);
|
|
||||||
if (!isNaN(numValue)) {
|
|
||||||
return numValue.toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
return numericValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const InputCell = <T extends string>({
|
const InputCell = <T extends string>({
|
||||||
field,
|
field,
|
||||||
@@ -45,53 +33,25 @@ const InputCell = <T extends string>({
|
|||||||
}: InputCellProps<T>) => {
|
}: InputCellProps<T>) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
// Use a ref to track if we need to process the value
|
|
||||||
const needsProcessingRef = useRef(false);
|
|
||||||
|
|
||||||
// Track local display value to avoid waiting for validation
|
|
||||||
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Add state for hover
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
// Remove optimistic updates and rely on parent state
|
||||||
|
|
||||||
// Helper function to check if a class is present in the className string
|
// Helper function to check if a class is present in the className string
|
||||||
const hasClass = (cls: string): boolean => {
|
const hasClass = (cls: string): boolean => {
|
||||||
const classNames = className.split(' ');
|
const classNames = className.split(' ');
|
||||||
return classNames.includes(cls);
|
return classNames.includes(cls);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize localDisplayValue on mount and when value changes externally
|
// No complex initialization needed
|
||||||
useEffect(() => {
|
|
||||||
if (localDisplayValue === null ||
|
|
||||||
(typeof value === 'string' && typeof localDisplayValue === 'string' &&
|
|
||||||
value.trim() !== localDisplayValue.trim())) {
|
|
||||||
setLocalDisplayValue(value);
|
|
||||||
}
|
|
||||||
}, [value, localDisplayValue]);
|
|
||||||
|
|
||||||
// Efficiently handle price formatting without multiple rerenders
|
// Handle focus event
|
||||||
useEffect(() => {
|
|
||||||
if (isPrice && needsProcessingRef.current && !isEditing) {
|
|
||||||
needsProcessingRef.current = false;
|
|
||||||
|
|
||||||
// Do price processing only when needed
|
|
||||||
const formattedValue = formatPrice(value);
|
|
||||||
if (formattedValue !== value) {
|
|
||||||
onChange(formattedValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [value, isPrice, isEditing, onChange]);
|
|
||||||
|
|
||||||
// Handle focus event - optimized to be synchronous
|
|
||||||
const handleFocus = useCallback(() => {
|
const handleFocus = useCallback(() => {
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
|
|
||||||
// For price fields, strip formatting when focusing
|
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null) {
|
||||||
if (isPrice) {
|
if (isPrice) {
|
||||||
// Remove any non-numeric characters except decimal point
|
// Remove any non-numeric characters except decimal point for editing
|
||||||
const numericValue = String(value).replace(/[^\d.]/g, '');
|
const numericValue = String(value).replace(/[^\d.]/g, '');
|
||||||
setEditValue(numericValue);
|
setEditValue(numericValue);
|
||||||
} else {
|
} else {
|
||||||
@@ -104,30 +64,17 @@ const InputCell = <T extends string>({
|
|||||||
onStartEdit?.();
|
onStartEdit?.();
|
||||||
}, [value, onStartEdit, isPrice]);
|
}, [value, onStartEdit, isPrice]);
|
||||||
|
|
||||||
// Handle blur event - use transition for non-critical updates
|
// Handle blur event - save to parent only
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
// First - lock in the current edit value to prevent it from being lost
|
|
||||||
const finalValue = editValue.trim();
|
const finalValue = editValue.trim();
|
||||||
|
|
||||||
// Then transition to non-editing state
|
// Save to parent - parent must update immediately for this to work
|
||||||
startTransition(() => {
|
onChange(finalValue);
|
||||||
|
|
||||||
|
// Exit editing mode
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
||||||
// Format the value for storage (remove formatting like $ for price)
|
|
||||||
let processedValue = finalValue;
|
|
||||||
|
|
||||||
if (isPrice && processedValue) {
|
|
||||||
needsProcessingRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local display value immediately to prevent UI flicker
|
|
||||||
setLocalDisplayValue(processedValue);
|
|
||||||
|
|
||||||
// Commit the change to parent component
|
|
||||||
onChange(processedValue);
|
|
||||||
onEndEdit?.();
|
onEndEdit?.();
|
||||||
});
|
}, [editValue, onChange, onEndEdit]);
|
||||||
}, [editValue, onChange, onEndEdit, isPrice]);
|
|
||||||
|
|
||||||
// Handle direct input change - optimized to be synchronous for typing
|
// Handle direct input change - optimized to be synchronous for typing
|
||||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
@@ -135,30 +82,22 @@ const InputCell = <T extends string>({
|
|||||||
setEditValue(newValue);
|
setEditValue(newValue);
|
||||||
}, [isPrice]);
|
}, [isPrice]);
|
||||||
|
|
||||||
// Get the display value - prioritize local display value
|
// Get the display value - use parent value directly
|
||||||
const displayValue = useMemo(() => {
|
const displayValue = useMemo(() => {
|
||||||
// First priority: local display value (for immediate updates)
|
const currentValue = value ?? '';
|
||||||
if (localDisplayValue !== null) {
|
|
||||||
if (isPrice) {
|
|
||||||
// Format price value
|
|
||||||
const numValue = parseFloat(localDisplayValue);
|
|
||||||
return !isNaN(numValue) ? numValue.toFixed(2) : localDisplayValue;
|
|
||||||
}
|
|
||||||
return localDisplayValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second priority: handle price formatting for the actual value
|
// Handle price formatting for display
|
||||||
if (isPrice && value) {
|
if (isPrice && currentValue !== '' && currentValue !== undefined && currentValue !== null) {
|
||||||
if (typeof value === 'number') {
|
if (typeof currentValue === 'number') {
|
||||||
return value.toFixed(2);
|
return currentValue.toFixed(2);
|
||||||
} else if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) {
|
} else if (typeof currentValue === 'string' && /^-?\d+(\.\d+)?$/.test(currentValue)) {
|
||||||
return parseFloat(value).toFixed(2);
|
return parseFloat(currentValue).toFixed(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: use the actual value or empty string
|
// For non-price or invalid price values, return as-is
|
||||||
return value ?? '';
|
return String(currentValue);
|
||||||
}, [isPrice, value, localDisplayValue]);
|
}, [isPrice, value]);
|
||||||
|
|
||||||
// Add outline even when not in focus
|
// Add outline even when not in focus
|
||||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
|
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
|
||||||
@@ -221,7 +160,6 @@ const InputCell = <T extends string>({
|
|||||||
className={cn(
|
className={cn(
|
||||||
outlineClass,
|
outlineClass,
|
||||||
hasErrors ? "border-destructive" : "",
|
hasErrors ? "border-destructive" : "",
|
||||||
isPending ? "opacity-50" : "",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -267,33 +205,11 @@ const InputCell = <T extends string>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimize memo comparison to focus on essential props
|
// Simplified memo comparison
|
||||||
export default React.memo(InputCell, (prev, next) => {
|
export default React.memo(InputCell, (prev, next) => {
|
||||||
if (prev.hasErrors !== next.hasErrors) return false;
|
// Only re-render if essential props change
|
||||||
if (prev.isMultiline !== next.isMultiline) return false;
|
return prev.value === next.value &&
|
||||||
if (prev.isPrice !== next.isPrice) return false;
|
prev.hasErrors === next.hasErrors &&
|
||||||
if (prev.disabled !== next.disabled) return false;
|
prev.disabled === next.disabled &&
|
||||||
if (prev.field !== next.field) return false;
|
prev.field === next.field;
|
||||||
|
|
||||||
// Only check value if not editing (to avoid expensive rerender during editing)
|
|
||||||
if (prev.value !== next.value) {
|
|
||||||
// For price values, do a more intelligent comparison
|
|
||||||
if (prev.isPrice) {
|
|
||||||
// Convert both to numeric values for comparison
|
|
||||||
const prevNum = typeof prev.value === 'number' ? prev.value :
|
|
||||||
typeof prev.value === 'string' ? parseFloat(prev.value) : 0;
|
|
||||||
const nextNum = typeof next.value === 'number' ? next.value :
|
|
||||||
typeof next.value === 'string' ? parseFloat(next.value) : 0;
|
|
||||||
|
|
||||||
// Only update if the actual numeric values differ
|
|
||||||
if (!isNaN(prevNum) && !isNaN(nextNum) &&
|
|
||||||
Math.abs(prevNum - nextNum) > 0.001) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
@@ -11,6 +11,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
interface FieldOption {
|
interface FieldOption {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
hex?: string; // optional hex color for colors field
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MultiSelectCellProps<T extends string> {
|
interface MultiSelectCellProps<T extends string> {
|
||||||
@@ -237,24 +238,43 @@ const MultiSelectCell = <T extends string>({
|
|||||||
if (providedOptions && providedOptions.length > 0) {
|
if (providedOptions && providedOptions.length > 0) {
|
||||||
// Check if options are already in the right format
|
// Check if options are already in the right format
|
||||||
if (typeof providedOptions[0] === 'object' && 'label' in providedOptions[0] && 'value' in providedOptions[0]) {
|
if (typeof providedOptions[0] === 'object' && 'label' in providedOptions[0] && 'value' in providedOptions[0]) {
|
||||||
return providedOptions as FieldOption[];
|
// Preserve optional hex if present (hex or hex_color without #)
|
||||||
|
return (providedOptions as any[]).map(opt => ({
|
||||||
|
label: opt.label,
|
||||||
|
value: String(opt.value),
|
||||||
|
hex: opt.hex
|
||||||
|
|| (opt.hexColor ? `#${String(opt.hexColor).replace(/^#/, '')}` : undefined)
|
||||||
|
|| (opt.hex_color ? `#${String(opt.hex_color).replace(/^#/, '')}` : undefined)
|
||||||
|
})) as FieldOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
return providedOptions.map(option => ({
|
return (providedOptions as any[]).map(option => ({
|
||||||
label: option.label || String(option.value),
|
label: option.label || String(option.value),
|
||||||
value: String(option.value)
|
value: String(option.value),
|
||||||
|
hex: option.hex
|
||||||
|
|| (option.hexColor ? `#${String(option.hexColor).replace(/^#/, '')}` : undefined)
|
||||||
|
|| (option.hex_color ? `#${String(option.hex_color).replace(/^#/, '')}` : undefined)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check field options format
|
// Check field options format
|
||||||
if (fieldOptions.length > 0) {
|
if (fieldOptions.length > 0) {
|
||||||
if (typeof fieldOptions[0] === 'object' && 'label' in fieldOptions[0] && 'value' in fieldOptions[0]) {
|
if (typeof fieldOptions[0] === 'object' && 'label' in fieldOptions[0] && 'value' in fieldOptions[0]) {
|
||||||
return fieldOptions as FieldOption[];
|
return (fieldOptions as any[]).map(opt => ({
|
||||||
|
label: opt.label,
|
||||||
|
value: String(opt.value),
|
||||||
|
hex: opt.hex
|
||||||
|
|| (opt.hexColor ? `#${String(opt.hexColor).replace(/^#/, '')}` : undefined)
|
||||||
|
|| (opt.hex_color ? `#${String(opt.hex_color).replace(/^#/, '')}` : undefined)
|
||||||
|
})) as FieldOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
return fieldOptions.map(option => ({
|
return (fieldOptions as any[]).map(option => ({
|
||||||
label: option.label || String(option.value),
|
label: option.label || String(option.value),
|
||||||
value: String(option.value)
|
value: String(option.value),
|
||||||
|
hex: option.hex
|
||||||
|
|| (option.hexColor ? `#${String(option.hexColor).replace(/^#/, '')}` : undefined)
|
||||||
|
|| (option.hex_color ? `#${String(option.hex_color).replace(/^#/, '')}` : undefined)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +511,18 @@ const MultiSelectCell = <T extends string>({
|
|||||||
onSelect={() => handleSelect(option.value)}
|
onSelect={() => handleSelect(option.value)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
{option.label}
|
<div className="flex items-center gap-2">
|
||||||
|
{field.key === 'colors' && option.hex && (
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 rounded-full ${option.hex.toLowerCase() === '#ffffff' || option.hex.toLowerCase() === '#fff' ? 'border' : ''}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: option.hex,
|
||||||
|
...(option.hex.toLowerCase() === '#ffffff' || option.hex.toLowerCase() === '#fff' ? { borderColor: '#000' } : {})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</div>
|
||||||
{selectedValueSet.has(option.value) && (
|
{selectedValueSet.has(option.value) && (
|
||||||
<Check className="ml-auto h-4 w-4" />
|
<Check className="ml-auto h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -142,10 +142,10 @@ const SelectCell = <T extends string>({
|
|||||||
// 5. Call onChange synchronously to avoid race conditions with other cells
|
// 5. Call onChange synchronously to avoid race conditions with other cells
|
||||||
onChange(valueToCommit);
|
onChange(valueToCommit);
|
||||||
|
|
||||||
// 6. Clear processing state after a short delay
|
// 6. Clear processing state after a short delay - reduced for responsiveness
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}, 200);
|
}, 50);
|
||||||
}, [onChange, onEndEdit]);
|
}, [onChange, onEndEdit]);
|
||||||
|
|
||||||
// If disabled, render a static view
|
// If disabled, render a static view
|
||||||
|
|||||||
@@ -296,10 +296,24 @@ export const useAiValidation = <T extends string>(
|
|||||||
lastProduct: data[data.length - 1]
|
lastProduct: data[data.length - 1]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean the data to ensure we only send what's needed
|
// Build a complete row object including empty cells so API receives all fields
|
||||||
const cleanedData = data.map(item => {
|
const cleanedData = data.map(item => {
|
||||||
const { __index, ...rest } = item;
|
const { __index, ...rest } = item as any;
|
||||||
return rest;
|
// Ensure all known field keys are present, even if empty
|
||||||
|
const withAllKeys: Record<string, any> = {};
|
||||||
|
(fields as any[]).forEach((f) => {
|
||||||
|
const k = String(f.key);
|
||||||
|
// Preserve arrays (e.g., multi-select) as empty array if undefined
|
||||||
|
if (Array.isArray(rest[k])) {
|
||||||
|
withAllKeys[k] = rest[k];
|
||||||
|
} else if (rest[k] === undefined) {
|
||||||
|
// Use empty string to represent an empty cell
|
||||||
|
withAllKeys[k] = "";
|
||||||
|
} else {
|
||||||
|
withAllKeys[k] = rest[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return withAllKeys;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Cleaned data sample:', {
|
console.log('Cleaned data sample:', {
|
||||||
@@ -421,10 +435,21 @@ export const useAiValidation = <T extends string>(
|
|||||||
});
|
});
|
||||||
}, 1000) as unknown as NodeJS.Timeout;
|
}, 1000) as unknown as NodeJS.Timeout;
|
||||||
|
|
||||||
// Clean the data to ensure we only send what's needed
|
// Build a complete row object including empty cells so API receives all fields
|
||||||
const cleanedData = data.map(item => {
|
const cleanedData = data.map(item => {
|
||||||
const { __index, ...rest } = item;
|
const { __index, ...rest } = item as any;
|
||||||
return rest;
|
const withAllKeys: Record<string, any> = {};
|
||||||
|
(fields as any[]).forEach((f) => {
|
||||||
|
const k = String(f.key);
|
||||||
|
if (Array.isArray(rest[k])) {
|
||||||
|
withAllKeys[k] = rest[k];
|
||||||
|
} else if (rest[k] === undefined) {
|
||||||
|
withAllKeys[k] = "";
|
||||||
|
} else {
|
||||||
|
withAllKeys[k] = rest[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return withAllKeys;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Cleaned data for validation:', cleanedData);
|
console.log('Cleaned data for validation:', cleanedData);
|
||||||
|
|||||||
@@ -7,14 +7,24 @@ import { RowData, isEmpty } from './validationTypes';
|
|||||||
// Create a cache for validation results to avoid repeated validation of the same data
|
// Create a cache for validation results to avoid repeated validation of the same data
|
||||||
const validationResultCache = new Map();
|
const validationResultCache = new Map();
|
||||||
|
|
||||||
// Add a function to clear cache for a specific field value
|
// Optimize cache clearing - only clear when necessary
|
||||||
export const clearValidationCacheForField = (fieldKey: string) => {
|
export const clearValidationCacheForField = (fieldKey: string, specificValue?: any) => {
|
||||||
// Look for entries that match this field key
|
if (specificValue !== undefined) {
|
||||||
|
// Only clear specific field-value combinations
|
||||||
|
const specificKey = `${fieldKey}-${String(specificValue)}`;
|
||||||
|
validationResultCache.forEach((_, key) => {
|
||||||
|
if (key.startsWith(specificKey)) {
|
||||||
|
validationResultCache.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Clear all entries for the field
|
||||||
validationResultCache.forEach((_, key) => {
|
validationResultCache.forEach((_, key) => {
|
||||||
if (key.startsWith(`${fieldKey}-`)) {
|
if (key.startsWith(`${fieldKey}-`)) {
|
||||||
validationResultCache.delete(key);
|
validationResultCache.delete(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add a special function to clear all uniqueness validation caches
|
// Add a special function to clear all uniqueness validation caches
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ export const useFilterManagement = <T extends string>(
|
|||||||
|
|
||||||
// Filter data based on current filter state
|
// Filter data based on current filter state
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
|
// Fast path: no filters active, return original data reference to avoid re-renders
|
||||||
|
const noSearch = !filters.searchText || filters.searchText.trim() === '';
|
||||||
|
const noErrorsOnly = !filters.showErrorsOnly;
|
||||||
|
const noFieldFilter = !filters.filterField || !filters.filterValue || filters.filterValue.trim() === '';
|
||||||
|
|
||||||
|
if (noSearch && noErrorsOnly && noFieldFilter) {
|
||||||
|
return data; // preserve reference; prevents full table rerender on error map changes
|
||||||
|
}
|
||||||
|
|
||||||
return data.filter((row, index) => {
|
return data.filter((row, index) => {
|
||||||
// Filter by search text
|
// Filter by search text
|
||||||
if (filters.searchText) {
|
if (filters.searchText) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { RowData } from './validationTypes';
|
import { RowData } from './validationTypes';
|
||||||
import type { Field, Fields } from '../../../types';
|
import type { Field, Fields } from '../../../types';
|
||||||
import { ErrorType, ValidationError } from '../../../types';
|
import { ErrorSources, ErrorType, ValidationError } from '../../../types';
|
||||||
|
import { useUniqueValidation } from './useUniqueValidation';
|
||||||
|
import { isEmpty } from './validationTypes';
|
||||||
|
|
||||||
export const useRowOperations = <T extends string>(
|
export const useRowOperations = <T extends string>(
|
||||||
data: RowData<T>[],
|
data: RowData<T>[],
|
||||||
@@ -10,6 +12,93 @@ export const useRowOperations = <T extends string>(
|
|||||||
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
|
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
|
||||||
validateFieldFromHook: (value: any, field: Field<T>) => ValidationError[]
|
validateFieldFromHook: (value: any, field: Field<T>) => ValidationError[]
|
||||||
) => {
|
) => {
|
||||||
|
// Uniqueness validation utilities
|
||||||
|
const { validateUniqueField } = useUniqueValidation<T>(fields);
|
||||||
|
|
||||||
|
// Determine which field keys are considered uniqueness-constrained
|
||||||
|
const uniquenessFieldKeys = useMemo(() => {
|
||||||
|
const keys = new Set<string>([
|
||||||
|
'item_number',
|
||||||
|
'upc',
|
||||||
|
'barcode',
|
||||||
|
'supplier_no',
|
||||||
|
'notions_no',
|
||||||
|
'name'
|
||||||
|
]);
|
||||||
|
fields.forEach((f) => {
|
||||||
|
if (f.validations?.some((v) => v.rule === 'unique')) {
|
||||||
|
keys.add(String(f.key));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return keys;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// Merge per-field uniqueness errors into the validation error map
|
||||||
|
const mergeUniqueErrorsForFields = useCallback(
|
||||||
|
(
|
||||||
|
baseErrors: Map<number, Record<string, ValidationError[]>>,
|
||||||
|
dataForCalc: RowData<T>[],
|
||||||
|
fieldKeysToCheck: string[]
|
||||||
|
) => {
|
||||||
|
if (!fieldKeysToCheck.length) return baseErrors;
|
||||||
|
|
||||||
|
const newErrors = new Map(baseErrors);
|
||||||
|
|
||||||
|
// For each field, compute duplicates and merge
|
||||||
|
fieldKeysToCheck.forEach((fieldKey) => {
|
||||||
|
if (!uniquenessFieldKeys.has(fieldKey)) return;
|
||||||
|
|
||||||
|
// Compute unique errors for this single field
|
||||||
|
const uniqueMap = validateUniqueField(dataForCalc, fieldKey);
|
||||||
|
|
||||||
|
// Rows that currently have uniqueness errors for this field
|
||||||
|
const rowsWithUniqueErrors = new Set<number>();
|
||||||
|
uniqueMap.forEach((_, rowIdx) => rowsWithUniqueErrors.add(rowIdx));
|
||||||
|
|
||||||
|
// First, apply/overwrite unique errors for rows that have duplicates
|
||||||
|
uniqueMap.forEach((errorsForRow, rowIdx) => {
|
||||||
|
const existing = { ...(newErrors.get(rowIdx) || {}) };
|
||||||
|
|
||||||
|
// Convert InfoWithSource to ValidationError[] for this field
|
||||||
|
const info = errorsForRow[fieldKey];
|
||||||
|
// Only apply uniqueness error when the value is non-empty
|
||||||
|
const currentValue = (dataForCalc[rowIdx] as any)?.[fieldKey];
|
||||||
|
if (info && !isEmpty(currentValue)) {
|
||||||
|
existing[fieldKey] = [
|
||||||
|
{
|
||||||
|
message: info.message,
|
||||||
|
level: info.level,
|
||||||
|
source: info.source ?? ErrorSources.Table,
|
||||||
|
type: info.type ?? ErrorType.Unique
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(existing).length > 0) newErrors.set(rowIdx, existing);
|
||||||
|
else newErrors.delete(rowIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then, remove any stale unique errors for this field where duplicates are resolved
|
||||||
|
newErrors.forEach((rowErrs, rowIdx) => {
|
||||||
|
// Skip rows that still have unique errors for this field
|
||||||
|
if (rowsWithUniqueErrors.has(rowIdx)) return;
|
||||||
|
|
||||||
|
if ((rowErrs as any)[fieldKey]) {
|
||||||
|
const filtered = (rowErrs as any)[fieldKey].filter((e: ValidationError) => e.type !== ErrorType.Unique);
|
||||||
|
if (filtered.length > 0) (rowErrs as any)[fieldKey] = filtered;
|
||||||
|
else delete (rowErrs as any)[fieldKey];
|
||||||
|
|
||||||
|
if (Object.keys(rowErrs).length > 0) newErrors.set(rowIdx, rowErrs);
|
||||||
|
else newErrors.delete(rowIdx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return newErrors;
|
||||||
|
},
|
||||||
|
[uniquenessFieldKeys, validateUniqueField]
|
||||||
|
);
|
||||||
|
|
||||||
// Helper function to validate a field value
|
// Helper function to validate a field value
|
||||||
const fieldValidationHelper = useCallback(
|
const fieldValidationHelper = useCallback(
|
||||||
(rowIndex: number, specificField?: string) => {
|
(rowIndex: number, specificField?: string) => {
|
||||||
@@ -27,7 +116,7 @@ export const useRowOperations = <T extends string>(
|
|||||||
|
|
||||||
// Use state setter instead of direct mutation
|
// Use state setter instead of direct mutation
|
||||||
setValidationErrors((prev) => {
|
setValidationErrors((prev) => {
|
||||||
const newErrors = new Map(prev);
|
let newErrors = new Map(prev);
|
||||||
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
|
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
|
||||||
|
|
||||||
// Quick check for required fields - this prevents flashing errors
|
// Quick check for required fields - this prevents flashing errors
|
||||||
@@ -73,6 +162,12 @@ export const useRowOperations = <T extends string>(
|
|||||||
newErrors.delete(rowIndex);
|
newErrors.delete(rowIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If field is uniqueness-constrained, also re-validate uniqueness for the column
|
||||||
|
if (uniquenessFieldKeys.has(specificField)) {
|
||||||
|
const dataForCalc = data; // latest data
|
||||||
|
newErrors = mergeUniqueErrorsForFields(newErrors, dataForCalc, [specificField]);
|
||||||
|
}
|
||||||
|
|
||||||
return newErrors;
|
return newErrors;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,7 +198,7 @@ export const useRowOperations = <T extends string>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[data, fields, validateFieldFromHook, setValidationErrors]
|
[data, fields, validateFieldFromHook, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use validateRow as an alias for fieldValidationHelper for compatibility
|
// Use validateRow as an alias for fieldValidationHelper for compatibility
|
||||||
@@ -155,7 +250,8 @@ export const useRowOperations = <T extends string>(
|
|||||||
// CRITICAL FIX: Combine both validation operations into a single state update
|
// CRITICAL FIX: Combine both validation operations into a single state update
|
||||||
// to prevent intermediate rendering that causes error icon flashing
|
// to prevent intermediate rendering that causes error icon flashing
|
||||||
setValidationErrors((prev) => {
|
setValidationErrors((prev) => {
|
||||||
const newMap = new Map(prev);
|
// Start with previous errors
|
||||||
|
let newMap = new Map(prev);
|
||||||
const existingErrors = newMap.get(rowIndex) || {};
|
const existingErrors = newMap.get(rowIndex) || {};
|
||||||
const newRowErrors = { ...existingErrors };
|
const newRowErrors = { ...existingErrors };
|
||||||
|
|
||||||
@@ -203,8 +299,8 @@ export const useRowOperations = <T extends string>(
|
|||||||
// Update with new validation results
|
// Update with new validation results
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
newRowErrors[key as string] = errors;
|
newRowErrors[key as string] = errors;
|
||||||
} else if (!newRowErrors[key as string]) {
|
} else {
|
||||||
// If no errors found and no existing errors, ensure field is removed from errors
|
// Clear any existing errors for this field
|
||||||
delete newRowErrors[key as string];
|
delete newRowErrors[key as string];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +311,24 @@ export const useRowOperations = <T extends string>(
|
|||||||
newMap.delete(rowIndex);
|
newMap.delete(rowIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If uniqueness applies, validate affected columns
|
||||||
|
const fieldsToCheck: string[] = [];
|
||||||
|
if (uniquenessFieldKeys.has(String(key))) fieldsToCheck.push(String(key));
|
||||||
|
if (key === ('upc' as T) || key === ('barcode' as T) || key === ('supplier' as T)) {
|
||||||
|
if (uniquenessFieldKeys.has('item_number')) fieldsToCheck.push('item_number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldsToCheck.length > 0) {
|
||||||
|
const dataForCalc = (() => {
|
||||||
|
const copy = [...data];
|
||||||
|
if (rowIndex >= 0 && rowIndex < copy.length) {
|
||||||
|
copy[rowIndex] = { ...(copy[rowIndex] || {}), [key]: processedValue } as RowData<T>;
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
})();
|
||||||
|
newMap = mergeUniqueErrorsForFields(newMap, dataForCalc, fieldsToCheck);
|
||||||
|
}
|
||||||
|
|
||||||
return newMap;
|
return newMap;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,9 +369,9 @@ export const useRowOperations = <T extends string>(
|
|||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 5); // Reduced delay for faster secondary effects
|
||||||
},
|
},
|
||||||
[data, fields, validateFieldFromHook, setData, setValidationErrors]
|
[data, fields, validateFieldFromHook, setData, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Improved revalidateRows function
|
// Improved revalidateRows function
|
||||||
@@ -268,7 +382,10 @@ export const useRowOperations = <T extends string>(
|
|||||||
) => {
|
) => {
|
||||||
// Process all specified rows using a single state update to avoid race conditions
|
// Process all specified rows using a single state update to avoid race conditions
|
||||||
setValidationErrors((prev) => {
|
setValidationErrors((prev) => {
|
||||||
const newErrors = new Map(prev);
|
let newErrors = new Map(prev);
|
||||||
|
|
||||||
|
// Track which uniqueness fields need to be revalidated across the dataset
|
||||||
|
const uniqueFieldsToCheck = new Set<string>();
|
||||||
|
|
||||||
// Process each row
|
// Process each row
|
||||||
for (const rowIndex of rowIndexes) {
|
for (const rowIndex of rowIndexes) {
|
||||||
@@ -300,6 +417,11 @@ export const useRowOperations = <T extends string>(
|
|||||||
} else {
|
} else {
|
||||||
delete existingRowErrors[fieldKey];
|
delete existingRowErrors[fieldKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If field is uniqueness-constrained, mark for uniqueness pass
|
||||||
|
if (uniquenessFieldKeys.has(fieldKey)) {
|
||||||
|
uniqueFieldsToCheck.add(fieldKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the row's errors
|
// Update the row's errors
|
||||||
@@ -324,6 +446,11 @@ export const useRowOperations = <T extends string>(
|
|||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
rowErrors[fieldKey] = errors;
|
rowErrors[fieldKey] = errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If field is uniqueness-constrained and we validated it, include for uniqueness pass
|
||||||
|
if (uniquenessFieldKeys.has(fieldKey)) {
|
||||||
|
uniqueFieldsToCheck.add(fieldKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the row's errors
|
// Update the row's errors
|
||||||
@@ -335,10 +462,15 @@ export const useRowOperations = <T extends string>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run per-field uniqueness checks and merge results
|
||||||
|
if (uniqueFieldsToCheck.size > 0) {
|
||||||
|
newErrors = mergeUniqueErrorsForFields(newErrors, data, Array.from(uniqueFieldsToCheck));
|
||||||
|
}
|
||||||
|
|
||||||
return newErrors;
|
return newErrors;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[data, fields, validateFieldFromHook]
|
[data, fields, validateFieldFromHook, mergeUniqueErrorsForFields, uniquenessFieldKeys]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Copy a cell value to all cells below it in the same column
|
// Copy a cell value to all cells below it in the same column
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ export const useUniqueItemNumbersValidation = <T extends string>(
|
|||||||
) => {
|
) => {
|
||||||
// Update validateUniqueItemNumbers to also check for uniqueness of UPC/barcode
|
// Update validateUniqueItemNumbers to also check for uniqueness of UPC/barcode
|
||||||
const validateUniqueItemNumbers = useCallback(async () => {
|
const validateUniqueItemNumbers = useCallback(async () => {
|
||||||
console.log("Validating unique fields");
|
|
||||||
|
|
||||||
// Skip if no data
|
// Skip if no data
|
||||||
if (!data.length) return;
|
if (!data.length) return;
|
||||||
|
|
||||||
@@ -23,11 +21,6 @@ export const useUniqueItemNumbersValidation = <T extends string>(
|
|||||||
.filter((field) => field.validations?.some((v) => v.rule === "unique"))
|
.filter((field) => field.validations?.some((v) => v.rule === "unique"))
|
||||||
.map((field) => String(field.key));
|
.map((field) => String(field.key));
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Found ${uniqueFields.length} fields requiring uniqueness validation:`,
|
|
||||||
uniqueFields
|
|
||||||
);
|
|
||||||
|
|
||||||
// Always check item_number uniqueness even if not explicitly defined
|
// Always check item_number uniqueness even if not explicitly defined
|
||||||
if (!uniqueFields.includes("item_number")) {
|
if (!uniqueFields.includes("item_number")) {
|
||||||
uniqueFields.push("item_number");
|
uniqueFields.push("item_number");
|
||||||
@@ -41,8 +34,13 @@ export const useUniqueItemNumbersValidation = <T extends string>(
|
|||||||
// Initialize batch updates
|
// Initialize batch updates
|
||||||
const errors = new Map<number, Record<string, ValidationError[]>>();
|
const errors = new Map<number, Record<string, ValidationError[]>>();
|
||||||
|
|
||||||
// Single pass through data to identify all unique values
|
// ASYNC: Single pass through data to identify all unique values in batches
|
||||||
data.forEach((row, index) => {
|
const BATCH_SIZE = 20;
|
||||||
|
for (let batchStart = 0; batchStart < data.length; batchStart += BATCH_SIZE) {
|
||||||
|
const batchEnd = Math.min(batchStart + BATCH_SIZE, data.length);
|
||||||
|
|
||||||
|
for (let index = batchStart; index < batchEnd; index++) {
|
||||||
|
const row = data[index];
|
||||||
uniqueFields.forEach((fieldKey) => {
|
uniqueFields.forEach((fieldKey) => {
|
||||||
const value = row[fieldKey as keyof typeof row];
|
const value = row[fieldKey as keyof typeof row];
|
||||||
|
|
||||||
@@ -61,12 +59,19 @@ export const useUniqueItemNumbersValidation = <T extends string>(
|
|||||||
fieldMap.set(valueStr, indices);
|
fieldMap.set(valueStr, indices);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
// Process duplicates
|
// Yield control back to UI thread after each batch
|
||||||
uniqueFields.forEach((fieldKey) => {
|
if (batchEnd < data.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASYNC: Process duplicates in batches to prevent UI blocking
|
||||||
|
let processedFields = 0;
|
||||||
|
for (const fieldKey of uniqueFields) {
|
||||||
const fieldMap = uniqueFieldsMap.get(fieldKey);
|
const fieldMap = uniqueFieldsMap.get(fieldKey);
|
||||||
if (!fieldMap) return;
|
if (!fieldMap) continue;
|
||||||
|
|
||||||
fieldMap.forEach((indices, value) => {
|
fieldMap.forEach((indices, value) => {
|
||||||
// Only process if there are duplicates
|
// Only process if there are duplicates
|
||||||
@@ -93,54 +98,56 @@ export const useUniqueItemNumbersValidation = <T extends string>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Apply batch updates only if we have errors to report
|
processedFields++;
|
||||||
if (errors.size > 0) {
|
// Yield control after every few fields to prevent UI blocking
|
||||||
// OPTIMIZATION: Check if we actually have new errors before updating state
|
if (processedFields % 2 === 0) {
|
||||||
let hasChanges = false;
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// We'll update errors with a single batch operation
|
// Merge uniqueness errors with existing validation errors
|
||||||
setValidationErrors((prev) => {
|
setValidationErrors((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
|
|
||||||
// Check each row for changes
|
// Add uniqueness errors
|
||||||
errors.forEach((rowErrors, rowIndex) => {
|
errors.forEach((rowErrors, rowIndex) => {
|
||||||
const existingErrors = newMap.get(rowIndex) || {};
|
const existingErrors = newMap.get(rowIndex) || {};
|
||||||
const updatedErrors = { ...existingErrors };
|
const updatedErrors = { ...existingErrors };
|
||||||
let rowHasChanges = false;
|
|
||||||
|
|
||||||
// Check each field for changes
|
// Add uniqueness errors to existing errors
|
||||||
Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => {
|
Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => {
|
||||||
// Compare with existing errors
|
|
||||||
const existingFieldErrors = existingErrors[fieldKey];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!existingFieldErrors ||
|
|
||||||
existingFieldErrors.length !== fieldErrors.length ||
|
|
||||||
!existingFieldErrors.every(
|
|
||||||
(err, idx) =>
|
|
||||||
err.message === fieldErrors[idx].message &&
|
|
||||||
err.type === fieldErrors[idx].type
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// We have a change
|
|
||||||
updatedErrors[fieldKey] = fieldErrors;
|
updatedErrors[fieldKey] = fieldErrors;
|
||||||
rowHasChanges = true;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only update if we have changes
|
|
||||||
if (rowHasChanges) {
|
|
||||||
newMap.set(rowIndex, updatedErrors);
|
newMap.set(rowIndex, updatedErrors);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up rows that have no uniqueness errors anymore
|
||||||
|
// by removing only uniqueness error types from rows not in the errors map
|
||||||
|
newMap.forEach((rowErrors, rowIndex) => {
|
||||||
|
if (!errors.has(rowIndex)) {
|
||||||
|
// Remove uniqueness errors from this row
|
||||||
|
const cleanedErrors: Record<string, ValidationError[]> = {};
|
||||||
|
Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => {
|
||||||
|
// Keep non-uniqueness errors
|
||||||
|
const nonUniqueErrors = fieldErrors.filter(error => error.type !== ErrorType.Unique);
|
||||||
|
if (nonUniqueErrors.length > 0) {
|
||||||
|
cleanedErrors[fieldKey] = nonUniqueErrors;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only return a new map if we have changes
|
// Update the row or remove it if no errors remain
|
||||||
return hasChanges ? newMap : prev;
|
if (Object.keys(cleanedErrors).length > 0) {
|
||||||
});
|
newMap.set(rowIndex, cleanedErrors);
|
||||||
|
} else {
|
||||||
|
newMap.delete(rowIndex);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
|
||||||
console.log("Uniqueness validation complete");
|
console.log("Uniqueness validation complete");
|
||||||
}, [data, fields, setValidationErrors]);
|
}, [data, fields, setValidationErrors]);
|
||||||
|
|||||||
@@ -13,6 +13,40 @@ import { useUniqueItemNumbersValidation } from "./useUniqueItemNumbersValidation
|
|||||||
import { useUpcValidation } from "./useUpcValidation";
|
import { useUpcValidation } from "./useUpcValidation";
|
||||||
import { Props, RowData } from "./validationTypes";
|
import { Props, RowData } from "./validationTypes";
|
||||||
|
|
||||||
|
// Country normalization helper (common mappings) - function declaration for hoisting
|
||||||
|
function normalizeCountryCode(input: string): string | null {
|
||||||
|
if (!input) return null;
|
||||||
|
const s = input.trim();
|
||||||
|
const upper = s.toUpperCase();
|
||||||
|
if (/^[A-Z]{2}$/.test(upper)) return upper; // already 2-letter
|
||||||
|
const iso3to2: Record<string, string> = {
|
||||||
|
USA: "US", GBR: "GB", UK: "GB", CHN: "CN", DEU: "DE", FRA: "FR", ITA: "IT", ESP: "ES",
|
||||||
|
CAN: "CA", MEX: "MX", AUS: "AU", NZL: "NZ", JPN: "JP", KOR: "KR", PRK: "KP", TWN: "TW",
|
||||||
|
VNM: "VN", THA: "TH", IDN: "ID", IND: "IN", BRA: "BR", ARG: "AR", CHL: "CL", PER: "PE",
|
||||||
|
ZAF: "ZA", RUS: "RU", UKR: "UA", NLD: "NL", BEL: "BE", CHE: "CH", SWE: "SE", NOR: "NO",
|
||||||
|
DNK: "DK", POL: "PL", AUT: "AT", PRT: "PT", GRC: "GR", CZE: "CZ", HUN: "HU", IRL: "IE",
|
||||||
|
ISR: "IL", PAK: "PK", BGD: "BD", PHL: "PH", MYS: "MY", SGP: "SG", HKG: "HK", MAC: "MO"
|
||||||
|
};
|
||||||
|
if (iso3to2[upper]) return iso3to2[upper];
|
||||||
|
const nameMap: Record<string, string> = {
|
||||||
|
"UNITED STATES": "US", "UNITED STATES OF AMERICA": "US", "AMERICA": "US", "U.S.": "US", "U.S.A": "US", "USA": "US",
|
||||||
|
"UNITED KINGDOM": "GB", "UK": "GB", "GREAT BRITAIN": "GB", "ENGLAND": "GB",
|
||||||
|
"CHINA": "CN", "PEOPLE'S REPUBLIC OF CHINA": "CN", "PRC": "CN",
|
||||||
|
"CANADA": "CA", "MEXICO": "MX", "JAPAN": "JP", "SOUTH KOREA": "KR", "KOREA, REPUBLIC OF": "KR",
|
||||||
|
"TAIWAN": "TW", "VIETNAM": "VN", "THAILAND": "TH", "INDONESIA": "ID", "INDIA": "IN",
|
||||||
|
"GERMANY": "DE", "FRANCE": "FR", "ITALY": "IT", "SPAIN": "ES", "NETHERLANDS": "NL", "BELGIUM": "BE",
|
||||||
|
"SWITZERLAND": "CH", "SWEDEN": "SE", "NORWAY": "NO", "DENMARK": "DK", "POLAND": "PL", "AUSTRIA": "AT",
|
||||||
|
"PORTUGAL": "PT", "GREECE": "GR", "CZECH REPUBLIC": "CZ", "CZECHIA": "CZ", "HUNGARY": "HU", "IRELAND": "IE",
|
||||||
|
"RUSSIA": "RU", "UKRAINE": "UA", "AUSTRALIA": "AU", "NEW ZEALAND": "NZ",
|
||||||
|
"BRAZIL": "BR", "ARGENTINA": "AR", "CHILE": "CL", "PERU": "PE", "SOUTH AFRICA": "ZA",
|
||||||
|
"ISRAEL": "IL", "PAKISTAN": "PK", "BANGLADESH": "BD", "PHILIPPINES": "PH", "MALAYSIA": "MY", "SINGAPORE": "SG",
|
||||||
|
"HONG KONG": "HK", "MACAU": "MO"
|
||||||
|
};
|
||||||
|
const normalizedName = s.replace(/\./g, "").trim().toUpperCase();
|
||||||
|
if (nameMap[normalizedName]) return nameMap[normalizedName];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const useValidationState = <T extends string>({
|
export const useValidationState = <T extends string>({
|
||||||
initialData,
|
initialData,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -20,8 +54,8 @@ export const useValidationState = <T extends string>({
|
|||||||
}: Props<T>) => {
|
}: Props<T>) => {
|
||||||
const { fields, rowHook, tableHook } = useRsi<T>();
|
const { fields, rowHook, tableHook } = useRsi<T>();
|
||||||
|
|
||||||
// Import validateField from useValidation
|
// Import validateField and validateUniqueField from useValidation
|
||||||
const { validateField: validateFieldFromHook } = useValidation<T>(
|
const { validateField: validateFieldFromHook, validateUniqueField } = useValidation<T>(
|
||||||
fields,
|
fields,
|
||||||
rowHook
|
rowHook
|
||||||
);
|
);
|
||||||
@@ -71,10 +105,23 @@ export const useValidationState = <T extends string>({
|
|||||||
updatedRow.ship_restrictions = "0";
|
updatedRow.ship_restrictions = "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize country code (COO) to 2-letter ISO if possible
|
||||||
|
if (typeof updatedRow.coo === "string") {
|
||||||
|
const raw = updatedRow.coo.trim();
|
||||||
|
const normalized = normalizeCountryCode(raw);
|
||||||
|
if (normalized) {
|
||||||
|
updatedRow.coo = normalized;
|
||||||
|
} else {
|
||||||
|
// Uppercase 2-letter values as fallback
|
||||||
|
if (raw.length === 2) updatedRow.coo = raw.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return updatedRow as RowData<T>;
|
return updatedRow as RowData<T>;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Row selection state
|
// Row selection state
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
|
||||||
@@ -90,8 +137,14 @@ export const useValidationState = <T extends string>({
|
|||||||
// Add state for tracking cells in loading state
|
// Add state for tracking cells in loading state
|
||||||
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set());
|
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Add global editing state to prevent validation during editing
|
||||||
|
const [editingCells, setEditingCells] = useState<Set<string>>(new Set());
|
||||||
|
const hasEditingCells = editingCells.size > 0;
|
||||||
|
|
||||||
const initialValidationDoneRef = useRef(false);
|
const initialValidationDoneRef = useRef(false);
|
||||||
const isValidatingRef = useRef(false);
|
// isValidatingRef unused; remove to satisfy TS
|
||||||
|
// Track last seen item_number signature to drive targeted uniqueness checks
|
||||||
|
const lastItemNumberSigRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Use row operations hook
|
// Use row operations hook
|
||||||
const { validateRow, updateRow, revalidateRows, copyDown } = useRowOperations<T>(
|
const { validateRow, updateRow, revalidateRows, copyDown } = useRowOperations<T>(
|
||||||
@@ -128,111 +181,13 @@ export const useValidationState = <T extends string>({
|
|||||||
// Use filter management hook
|
// Use filter management hook
|
||||||
const filterManagement = useFilterManagement<T>(data, fields, validationErrors);
|
const filterManagement = useFilterManagement<T>(data, fields, validationErrors);
|
||||||
|
|
||||||
// Run validation when data changes - FIXED to prevent recursive validation
|
// Disable global full-table revalidation on any data change.
|
||||||
|
// Field-level validation now runs inside updateRow/validateRow, and per-column
|
||||||
|
// uniqueness is handled surgically where needed.
|
||||||
|
// Intentionally left blank to avoid UI lock-ups on small edits.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip initial load - we have a separate initialization process
|
return; // no-op
|
||||||
if (!initialValidationDoneRef.current) return;
|
}, [data, fields, hasEditingCells]);
|
||||||
|
|
||||||
// Don't run validation during template application
|
|
||||||
if (isApplyingTemplateRef.current) return;
|
|
||||||
|
|
||||||
// CRITICAL FIX: Skip validation if we're already validating to prevent infinite loops
|
|
||||||
if (isValidatingRef.current) return;
|
|
||||||
|
|
||||||
console.log("Running validation on data change");
|
|
||||||
isValidatingRef.current = true;
|
|
||||||
|
|
||||||
// For faster validation, run synchronously instead of in an async function
|
|
||||||
const validateFields = () => {
|
|
||||||
try {
|
|
||||||
// Run regex validations on all rows
|
|
||||||
const regexFields = fields.filter((field) =>
|
|
||||||
field.validations?.some((v) => v.rule === "regex")
|
|
||||||
);
|
|
||||||
if (regexFields.length > 0) {
|
|
||||||
// Create a map to collect validation errors
|
|
||||||
const regexErrors = new Map<
|
|
||||||
number,
|
|
||||||
Record<string, any[]>
|
|
||||||
>();
|
|
||||||
|
|
||||||
// Check each row for regex errors
|
|
||||||
data.forEach((row, rowIndex) => {
|
|
||||||
const rowErrors: Record<string, any[]> = {};
|
|
||||||
let hasErrors = false;
|
|
||||||
|
|
||||||
// Check each regex field
|
|
||||||
regexFields.forEach((field) => {
|
|
||||||
const key = String(field.key);
|
|
||||||
const value = row[key as keyof typeof row];
|
|
||||||
|
|
||||||
// Skip empty values
|
|
||||||
if (value === undefined || value === null || value === "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find regex validation
|
|
||||||
const regexValidation = field.validations?.find(
|
|
||||||
(v) => v.rule === "regex"
|
|
||||||
);
|
|
||||||
if (regexValidation) {
|
|
||||||
try {
|
|
||||||
// Check if value matches regex
|
|
||||||
const regex = new RegExp(
|
|
||||||
regexValidation.value,
|
|
||||||
regexValidation.flags
|
|
||||||
);
|
|
||||||
if (!regex.test(String(value))) {
|
|
||||||
// Add regex validation error
|
|
||||||
rowErrors[key] = [
|
|
||||||
{
|
|
||||||
message: regexValidation.errorMessage,
|
|
||||||
level: regexValidation.level || "error",
|
|
||||||
source: "row",
|
|
||||||
type: "regex",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
hasErrors = true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Invalid regex in validation:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add errors if any found
|
|
||||||
if (hasErrors) {
|
|
||||||
regexErrors.set(rowIndex, rowErrors);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update validation errors
|
|
||||||
if (regexErrors.size > 0) {
|
|
||||||
setValidationErrors((prev) => {
|
|
||||||
const newErrors = new Map(prev);
|
|
||||||
// Merge in regex errors
|
|
||||||
for (const [rowIndex, errors] of regexErrors.entries()) {
|
|
||||||
const existingErrors = newErrors.get(rowIndex) || {};
|
|
||||||
newErrors.set(rowIndex, { ...existingErrors, ...errors });
|
|
||||||
}
|
|
||||||
return newErrors;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run uniqueness validations immediately
|
|
||||||
validateUniqueItemNumbers();
|
|
||||||
} finally {
|
|
||||||
// Always ensure the ref is reset, even if an error occurs
|
|
||||||
setTimeout(() => {
|
|
||||||
isValidatingRef.current = false;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run validation immediately
|
|
||||||
validateFields();
|
|
||||||
}, [data, fields, validateUniqueItemNumbers]);
|
|
||||||
|
|
||||||
// Add field options query
|
// Add field options query
|
||||||
const { data: fieldOptionsData } = useQuery({
|
const { data: fieldOptionsData } = useQuery({
|
||||||
@@ -348,11 +303,12 @@ export const useValidationState = <T extends string>({
|
|||||||
[data, onBack, onNext, validationErrors]
|
[data, onBack, onNext, validationErrors]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize validation on mount
|
// Initialize validation once, after initial UPC-based item number generation completes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialValidationDoneRef.current) return;
|
if (initialValidationDoneRef.current) return;
|
||||||
|
// Wait for initial UPC validation to finish to avoid double work and ensure
|
||||||
console.log("Running initial validation");
|
// item_number values are in place before uniqueness checks
|
||||||
|
if (!upcValidation.initialValidationDone) return;
|
||||||
|
|
||||||
const runCompleteValidation = async () => {
|
const runCompleteValidation = async () => {
|
||||||
if (!data || data.length === 0) return;
|
if (!data || data.length === 0) return;
|
||||||
@@ -379,8 +335,8 @@ export const useValidationState = <T extends string>({
|
|||||||
`Found ${uniqueFields.length} fields requiring uniqueness validation`
|
`Found ${uniqueFields.length} fields requiring uniqueness validation`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Limit batch size to avoid UI freezing
|
// Dynamic batch size based on dataset size
|
||||||
const BATCH_SIZE = 100;
|
const BATCH_SIZE = data.length <= 50 ? data.length : 25; // Process all at once for small datasets
|
||||||
const totalRows = data.length;
|
const totalRows = data.length;
|
||||||
|
|
||||||
// Initialize new data for any modifications
|
// Initialize new data for any modifications
|
||||||
@@ -559,9 +515,9 @@ export const useValidationState = <T extends string>({
|
|||||||
currentBatch = batch;
|
currentBatch = batch;
|
||||||
await processBatch();
|
await processBatch();
|
||||||
|
|
||||||
// Yield to UI thread periodically
|
// Yield to UI thread more frequently for large datasets
|
||||||
if (batch % 2 === 1) {
|
if (batch % 2 === 1 || totalRows > 500) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, totalRows > 1000 ? 10 : 5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +547,73 @@ export const useValidationState = <T extends string>({
|
|||||||
|
|
||||||
// Run the complete validation
|
// Run the complete validation
|
||||||
runCompleteValidation();
|
runCompleteValidation();
|
||||||
}, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers]);
|
}, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers, upcValidation.initialValidationDone]);
|
||||||
|
|
||||||
|
// Targeted uniqueness revalidation: run only when item_number values change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
// Build a simple signature of the item_number column
|
||||||
|
const sig = data.map((r) => String((r as Record<string, any>).item_number ?? '')).join('|');
|
||||||
|
if (lastItemNumberSigRef.current === sig) return;
|
||||||
|
lastItemNumberSigRef.current = sig;
|
||||||
|
|
||||||
|
// Compute unique errors for item_number only and merge
|
||||||
|
const uniqueMap = validateUniqueField(data, 'item_number');
|
||||||
|
const rowsWithUnique = new Set<number>();
|
||||||
|
uniqueMap.forEach((_, idx) => rowsWithUnique.add(idx));
|
||||||
|
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
|
||||||
|
// Apply unique errors
|
||||||
|
uniqueMap.forEach((errorsForRow, rowIdx) => {
|
||||||
|
const existing = { ...(newMap.get(rowIdx) || {}) } as Record<string, any[]>;
|
||||||
|
const info = (errorsForRow as any)['item_number'];
|
||||||
|
const currentValue = (data[rowIdx] as any)?.['item_number'];
|
||||||
|
// Only apply uniqueness error when the value is non-empty
|
||||||
|
if (info && currentValue !== undefined && currentValue !== null && String(currentValue) !== '') {
|
||||||
|
existing['item_number'] = [
|
||||||
|
{
|
||||||
|
message: info.message,
|
||||||
|
level: info.level,
|
||||||
|
source: info.source,
|
||||||
|
type: info.type,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// If value is now present, make sure to clear any lingering Required error
|
||||||
|
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && existing['item_number']) {
|
||||||
|
existing['item_number'] = (existing['item_number'] as any[]).filter((e) => e.type !== ErrorType.Required);
|
||||||
|
if ((existing['item_number'] as any[]).length === 0) delete existing['item_number'];
|
||||||
|
}
|
||||||
|
if (Object.keys(existing).length > 0) newMap.set(rowIdx, existing);
|
||||||
|
else newMap.delete(rowIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove stale unique errors for rows no longer duplicated
|
||||||
|
newMap.forEach((rowErrs, rowIdx) => {
|
||||||
|
const currentValue = (data[rowIdx] as any)?.['item_number'];
|
||||||
|
const shouldRemoveUnique = !rowsWithUnique.has(rowIdx) || currentValue === undefined || currentValue === null || String(currentValue) === '';
|
||||||
|
if (shouldRemoveUnique && (rowErrs as any)['item_number']) {
|
||||||
|
const filtered = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Unique);
|
||||||
|
if (filtered.length > 0) (rowErrs as any)['item_number'] = filtered;
|
||||||
|
else delete (rowErrs as any)['item_number'];
|
||||||
|
}
|
||||||
|
// If value now present, also clear any lingering Required error for this field
|
||||||
|
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && (rowErrs as any)['item_number']) {
|
||||||
|
const nonRequired = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Required);
|
||||||
|
if (nonRequired.length > 0) (rowErrs as any)['item_number'] = nonRequired;
|
||||||
|
else delete (rowErrs as any)['item_number'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(rowErrs).length > 0) newMap.set(rowIdx, rowErrs);
|
||||||
|
else newMap.delete(rowIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
}, [data, validateUniqueField, setValidationErrors]);
|
||||||
|
|
||||||
// Update fields with latest options
|
// Update fields with latest options
|
||||||
const fieldsWithOptions = useMemo(() => {
|
const fieldsWithOptions = useMemo(() => {
|
||||||
@@ -680,6 +702,10 @@ export const useValidationState = <T extends string>({
|
|||||||
validatingCells,
|
validatingCells,
|
||||||
setValidatingCells,
|
setValidatingCells,
|
||||||
|
|
||||||
|
// PERFORMANCE FIX: Export editing state management
|
||||||
|
editingCells,
|
||||||
|
setEditingCells,
|
||||||
|
|
||||||
// Row selection
|
// Row selection
|
||||||
rowSelection,
|
rowSelection,
|
||||||
setRowSelection,
|
setRowSelection,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Info } from "lucide-react";
|
import { Info } from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
|
||||||
interface Permission {
|
interface Permission {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -21,13 +22,15 @@ interface PermissionSelectorProps {
|
|||||||
selectedPermissions: number[];
|
selectedPermissions: number[];
|
||||||
onChange: (selectedPermissions: number[]) => void;
|
onChange: (selectedPermissions: number[]) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PermissionSelector({
|
export function PermissionSelector({
|
||||||
permissionsByCategory,
|
permissionsByCategory,
|
||||||
selectedPermissions,
|
selectedPermissions,
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false
|
disabled = false,
|
||||||
|
isAdmin = false
|
||||||
}: PermissionSelectorProps) {
|
}: PermissionSelectorProps) {
|
||||||
// Handle permission checkbox change
|
// Handle permission checkbox change
|
||||||
const handlePermissionChange = (permissionId: number) => {
|
const handlePermissionChange = (permissionId: number) => {
|
||||||
@@ -68,13 +71,17 @@ export function PermissionSelector({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium">Permissions</h3>
|
<h3 className="text-lg font-medium">Permissions</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
{isAdmin && (
|
||||||
Select the permissions you want to grant to this user
|
<Alert variant="destructive">
|
||||||
</p>
|
<AlertDescription>
|
||||||
|
Administrators have access to all permissions by default. Individual permissions cannot be edited for admin users.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{permissionsByCategory.map(category => (
|
{permissionsByCategory.map(category => (
|
||||||
<Card key={category.category} className="mb-4">
|
<Card key={category.category} className="mb-4">
|
||||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
<CardHeader className="pb-2 flex flex-row items-center justify-between pt-2">
|
||||||
<CardTitle className="text-md">{category.category}</CardTitle>
|
<CardTitle className="text-md">{category.category}</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { PermissionSelector } from "./PermissionSelector";
|
import { PermissionSelector } from "./PermissionSelector";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import config from "@/config";
|
||||||
|
|
||||||
interface Permission {
|
interface Permission {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -25,12 +28,22 @@ interface Permission {
|
|||||||
category?: string;
|
category?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RocketChatUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
active: boolean;
|
||||||
|
mongo_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
rocket_chat_user_id?: string;
|
||||||
permissions?: Permission[];
|
permissions?: Permission[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +66,7 @@ const userFormSchema = z.object({
|
|||||||
password: z.string().min(6, { message: "Password must be at least 6 characters" }).optional().or(z.literal("")),
|
password: z.string().min(6, { message: "Password must be at least 6 characters" }).optional().or(z.literal("")),
|
||||||
is_admin: z.boolean().default(false),
|
is_admin: z.boolean().default(false),
|
||||||
is_active: z.boolean().default(true),
|
is_active: z.boolean().default(true),
|
||||||
|
rocket_chat_user_id: z.string().default("none"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormValues = z.infer<typeof userFormSchema>;
|
type FormValues = z.infer<typeof userFormSchema>;
|
||||||
@@ -80,12 +94,15 @@ interface UserSaveData {
|
|||||||
password?: string;
|
password?: string;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
rocket_chat_user_id?: string;
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps) {
|
export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps) {
|
||||||
const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]);
|
const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [rocketChatUsers, setRocketChatUsers] = useState<RocketChatUser[]>([]);
|
||||||
|
const [loadingRocketChatUsers, setLoadingRocketChatUsers] = useState(true);
|
||||||
|
|
||||||
// Initialize the form with React Hook Form
|
// Initialize the form with React Hook Form
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
@@ -96,29 +113,78 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
|||||||
password: "", // Don't pre-fill password
|
password: "", // Don't pre-fill password
|
||||||
is_admin: user?.is_admin || false,
|
is_admin: user?.is_admin || false,
|
||||||
is_active: user?.is_active !== false,
|
is_active: user?.is_active !== false,
|
||||||
|
rocket_chat_user_id: user?.rocket_chat_user_id || "none",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize selected permissions
|
// Fetch Rocket Chat users
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRocketChatUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${config.chatUrl}/users`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
setRocketChatUsers(data.users);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch Rocket Chat users:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Rocket Chat users:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingRocketChatUsers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure rocket_chat_user_id is set to "none" initially if not set
|
||||||
|
if (!form.getValues().rocket_chat_user_id) {
|
||||||
|
form.setValue('rocket_chat_user_id', 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRocketChatUsers();
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
// Initialize selected permissions and form values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("User permissions:", user?.permissions);
|
|
||||||
|
|
||||||
if (user?.permissions && Array.isArray(user.permissions) && user.permissions.length > 0) {
|
if (user?.permissions && Array.isArray(user.permissions) && user.permissions.length > 0) {
|
||||||
// Extract IDs from the permissions
|
// Extract IDs from the permissions
|
||||||
const permissionIds = user.permissions.map(p => p.id);
|
const permissionIds = user.permissions.map(p => p.id);
|
||||||
console.log("Setting selected permissions:", permissionIds);
|
|
||||||
setSelectedPermissions(permissionIds);
|
setSelectedPermissions(permissionIds);
|
||||||
} else {
|
} else {
|
||||||
console.log("No permissions found or empty permissions array");
|
|
||||||
setSelectedPermissions([]);
|
setSelectedPermissions([]);
|
||||||
}
|
}
|
||||||
}, [user]);
|
|
||||||
|
// Update form values when user data changes
|
||||||
|
if (user) {
|
||||||
|
form.reset({
|
||||||
|
username: user.username || "",
|
||||||
|
email: user.email || "",
|
||||||
|
password: "", // Don't pre-fill password
|
||||||
|
is_admin: user.is_admin || false,
|
||||||
|
is_active: user.is_active !== false,
|
||||||
|
rocket_chat_user_id: user.rocket_chat_user_id || "none",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For new users, ensure rocket_chat_user_id defaults to "none"
|
||||||
|
form.reset({
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
is_admin: false,
|
||||||
|
is_active: true,
|
||||||
|
rocket_chat_user_id: "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user, form]);
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const onSubmit = (data: FormValues) => {
|
const onSubmit = (data: FormValues) => {
|
||||||
try {
|
try {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
console.log("Form submitted with permissions:", selectedPermissions);
|
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
if (!user && !data.password) {
|
if (!user && !data.password) {
|
||||||
@@ -130,6 +196,7 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
|||||||
const userData: UserSaveData = {
|
const userData: UserSaveData = {
|
||||||
...data,
|
...data,
|
||||||
id: user?.id, // Include ID if editing existing user
|
id: user?.id, // Include ID if editing existing user
|
||||||
|
rocket_chat_user_id: data.is_admin ? undefined : (data.rocket_chat_user_id === "none" ? undefined : data.rocket_chat_user_id),
|
||||||
permissions: [] // Initialize with empty array
|
permissions: [] // Initialize with empty array
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,7 +228,7 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
|||||||
userData.permissions = [];
|
userData.permissions = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Saving user data:", userData);
|
|
||||||
onSave(userData);
|
onSave(userData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "An error occurred";
|
const errorMessage = error instanceof Error ? error.message : "An error occurred";
|
||||||
@@ -169,19 +236,10 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// For debugging
|
|
||||||
console.log("Current form state:", form.getValues());
|
|
||||||
console.log("Available permissions categories:", permissions);
|
|
||||||
console.log("Selected permissions:", selectedPermissions);
|
|
||||||
console.log("Is admin:", form.watch("is_admin"));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold mb-2">{user ? "Edit User" : "Add New User"}</h2>
|
<h2 className="text-2xl font-bold mb-2">{user ? "Edit User" : "Add New User"}</h2>
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{user ? "Update the user's information and permissions" : "Create a new user account"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formError && (
|
{formError && (
|
||||||
@@ -192,6 +250,8 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{/* Basic Information Section */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="username"
|
name="username"
|
||||||
@@ -233,16 +293,66 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
|||||||
placeholder={user ? "Leave blank to keep current password" : ""}
|
placeholder={user ? "Leave blank to keep current password" : ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{user && (
|
|
||||||
<FormDescription>
|
|
||||||
Leave blank to keep the current password
|
|
||||||
</FormDescription>
|
|
||||||
)}
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="rocket_chat_user_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Rocket Chat User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{form.watch("is_admin") ? (
|
||||||
|
<div className="flex items-center gap-2 p-2 border text-sm rounded-md bg-muted">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Admin users have access to all chat rooms by default
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select value={field.value || "none"} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={loadingRocketChatUsers ? "Loading..." : "Select Rocket Chat user..."} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{!loadingRocketChatUsers && (
|
||||||
|
<SelectItem value="none">
|
||||||
|
<span className="text-muted-foreground">None</span>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
{rocketChatUsers.map((rcUser) => (
|
||||||
|
<SelectItem key={rcUser.id} value={rcUser.id.toString()}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage
|
||||||
|
src={rcUser.mongo_id ? `${config.chatUrl}/avatar/${rcUser.mongo_id}` : undefined}
|
||||||
|
alt={rcUser.name || rcUser.username}
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{(rcUser.name || rcUser.username).charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className={rcUser.active ? '' : 'text-muted-foreground'}>
|
||||||
|
{rcUser.name || rcUser.username}
|
||||||
|
{!rcUser.active && <span className="text-xs ml-1">(inactive)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Switches - Two Columns */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -287,32 +397,18 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions Section */}
|
||||||
{permissions && permissions.length > 0 && (
|
{permissions && permissions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{form.watch("is_admin") ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-medium">Permissions</h3>
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
Administrators have access to all permissions by default. Individual permissions cannot be edited for admin users.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<PermissionSelector
|
<PermissionSelector
|
||||||
permissionsByCategory={permissions}
|
permissionsByCategory={permissions}
|
||||||
selectedPermissions={getAllPermissionIds(permissions)}
|
selectedPermissions={form.watch("is_admin") ? getAllPermissionIds(permissions) : selectedPermissions}
|
||||||
onChange={() => {}}
|
|
||||||
disabled={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<PermissionSelector
|
|
||||||
permissionsByCategory={permissions}
|
|
||||||
selectedPermissions={selectedPermissions}
|
|
||||||
onChange={setSelectedPermissions}
|
onChange={setSelectedPermissions}
|
||||||
|
disabled={form.watch("is_admin")}
|
||||||
|
isAdmin={form.watch("is_admin")}
|
||||||
/>
|
/>
|
||||||
{selectedPermissions.length === 0 && (
|
{!form.watch("is_admin") && selectedPermissions.length === 0 && (
|
||||||
<Alert>
|
<Alert variant="destructive">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Warning: This user has no permissions selected. They won't be able to access anything.
|
Warning: This user has no permissions selected. They won't be able to access anything.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
@@ -320,8 +416,6 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-4">
|
<div className="flex justify-end space-x-4">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
const isDev = import.meta.env.DEV;
|
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
apiUrl: isDev ? '/api' : 'https://inventory.kent.pw/api',
|
apiUrl: '/api',
|
||||||
baseUrl: isDev ? '' : 'https://inventory.kent.pw',
|
baseUrl: '',
|
||||||
authUrl: isDev ? '/auth-inv' : 'https://inventory.kent.pw/auth-inv',
|
authUrl: '/auth-inv',
|
||||||
chatUrl: isDev ? '/chat-api' : 'https://inventory.kent.pw/chat-api'
|
chatUrl: '/chat-api'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
const isDev = import.meta.env.DEV;
|
const isDev = import.meta.env.DEV;
|
||||||
|
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
|
||||||
|
// Use proxy paths when on inventory domains to avoid CORS
|
||||||
|
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.acot.site');
|
||||||
|
|
||||||
const liveDashboardConfig = {
|
const liveDashboardConfig = {
|
||||||
auth: isDev ? '/dashboard-auth' : 'https://dashboard.kent.pw/auth',
|
auth: isDev || useProxy ? '/dashboard-auth' : 'https://dashboard.kent.pw/auth',
|
||||||
aircall: isDev ? '/api/aircall' : 'https://dashboard.kent.pw/api/aircall',
|
aircall: isDev || useProxy ? '/api/aircall' : 'https://dashboard.kent.pw/api/aircall',
|
||||||
klaviyo: isDev ? '/api/klaviyo' : 'https://dashboard.kent.pw/api/klaviyo',
|
klaviyo: isDev || useProxy ? '/api/klaviyo' : 'https://dashboard.kent.pw/api/klaviyo',
|
||||||
meta: isDev ? '/api/meta' : 'https://dashboard.kent.pw/api/meta',
|
meta: isDev || useProxy ? '/api/meta' : 'https://dashboard.kent.pw/api/meta',
|
||||||
gorgias: isDev ? '/api/gorgias' : 'https://dashboard.kent.pw/api/gorgias',
|
gorgias: isDev || useProxy ? '/api/gorgias' : 'https://dashboard.kent.pw/api/gorgias',
|
||||||
analytics: isDev ? '/api/dashboard-analytics' : 'https://dashboard.kent.pw/api/analytics',
|
analytics: isDev || useProxy ? '/api/dashboard-analytics' : 'https://dashboard.kent.pw/api/analytics',
|
||||||
typeform: isDev ? '/api/typeform' : 'https://dashboard.kent.pw/api/typeform',
|
typeform: isDev || useProxy ? '/api/typeform' : 'https://dashboard.kent.pw/api/typeform',
|
||||||
acot: isDev ? '/api/acot' : 'https://dashboard.kent.pw/api/acot',
|
acot: isDev || useProxy ? '/api/acot' : 'https://dashboard.kent.pw/api/acot',
|
||||||
clarity: isDev ? '/api/clarity' : 'https://dashboard.kent.pw/api/clarity'
|
clarity: isDev || useProxy ? '/api/clarity' : 'https://dashboard.kent.pw/api/clarity'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default liveDashboardConfig;
|
export default liveDashboardConfig;
|
||||||
@@ -14,6 +14,7 @@ export interface User {
|
|||||||
username: string;
|
username: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
|
rocket_chat_user_id?: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const userData = await response.json();
|
const userData = await response.json();
|
||||||
console.log("Fetched current user data:", userData);
|
console.log("Fetched current user data:", userData);
|
||||||
console.log("User permissions:", userData.permissions);
|
console.log("User permissions:", userData.permissions);
|
||||||
|
console.log("User rocket_chat_user_id:", userData.rocket_chat_user_id);
|
||||||
|
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
// Ensure we have the sessionStorage isLoggedIn flag set
|
// Ensure we have the sessionStorage isLoggedIn flag set
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -8,6 +8,7 @@ import { Loader2, Search } from 'lucide-react';
|
|||||||
import { RoomList } from '@/components/chat/RoomList';
|
import { RoomList } from '@/components/chat/RoomList';
|
||||||
import { ChatRoom } from '@/components/chat/ChatRoom';
|
import { ChatRoom } from '@/components/chat/ChatRoom';
|
||||||
import { SearchResults } from '@/components/chat/SearchResults';
|
import { SearchResults } from '@/components/chat/SearchResults';
|
||||||
|
import { AuthContext } from '@/contexts/AuthContext';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -39,11 +40,13 @@ interface SearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Chat() {
|
export function Chat() {
|
||||||
|
const { user: currentUser } = useContext(AuthContext);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||||
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [userRocketChatId, setUserRocketChatId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Global search state
|
// Global search state
|
||||||
const [globalSearchQuery, setGlobalSearchQuery] = useState('');
|
const [globalSearchQuery, setGlobalSearchQuery] = useState('');
|
||||||
@@ -51,6 +54,12 @@ export function Chat() {
|
|||||||
const [showSearchResults, setShowSearchResults] = useState(false);
|
const [showSearchResults, setShowSearchResults] = useState(false);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentUser) {
|
||||||
|
setUserRocketChatId(currentUser.rocket_chat_user_id || null);
|
||||||
|
}
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -59,6 +68,25 @@ export function Chat() {
|
|||||||
|
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
setUsers(data.users);
|
setUsers(data.users);
|
||||||
|
|
||||||
|
// Auto-select user based on permissions
|
||||||
|
if (currentUser && !currentUser.is_admin && userRocketChatId) {
|
||||||
|
console.log('Attempting to auto-select user:', {
|
||||||
|
currentUser: currentUser.username,
|
||||||
|
userRocketChatId,
|
||||||
|
userRocketChatIdType: typeof userRocketChatId,
|
||||||
|
availableUsers: data.users.map((u: User) => ({ id: u.id, idType: typeof u.id }))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-admin users should only see their connected rocket chat user
|
||||||
|
const userRocketChatUser = data.users.find((user: User) => user.id.toString() === userRocketChatId?.toString());
|
||||||
|
console.log('Found matching user:', userRocketChatUser);
|
||||||
|
|
||||||
|
if (userRocketChatUser) {
|
||||||
|
setSelectedUserId(userRocketChatUser.id.toString());
|
||||||
|
console.log('Auto-selected user ID:', userRocketChatUser.id.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error || 'Failed to fetch users');
|
throw new Error(data.error || 'Failed to fetch users');
|
||||||
}
|
}
|
||||||
@@ -70,14 +98,19 @@ export function Chat() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, []);
|
}
|
||||||
|
}, [currentUser, userRocketChatId]);
|
||||||
|
|
||||||
const handleUserChange = (userId: string) => {
|
const handleUserChange = (userId: string) => {
|
||||||
|
// Only allow admins to change users, or if the user is selecting their own connected account
|
||||||
|
if (currentUser?.is_admin || userId === userRocketChatId) {
|
||||||
setSelectedUserId(userId);
|
setSelectedUserId(userId);
|
||||||
setSelectedRoomId(null); // Reset room selection when user changes
|
setSelectedRoomId(null); // Reset room selection when user changes
|
||||||
setGlobalSearchQuery(''); // Clear search when user changes
|
setGlobalSearchQuery(''); // Clear search when user changes
|
||||||
setShowSearchResults(false);
|
setShowSearchResults(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRoomSelect = (roomId: string) => {
|
const handleRoomSelect = (roomId: string) => {
|
||||||
@@ -181,6 +214,7 @@ export function Chat() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentUser?.is_admin ? (
|
||||||
<Select value={selectedUserId} onValueChange={handleUserChange}>
|
<Select value={selectedUserId} onValueChange={handleUserChange}>
|
||||||
<SelectTrigger className="w-64">
|
<SelectTrigger className="w-64">
|
||||||
<SelectValue placeholder="View as user..." />
|
<SelectValue placeholder="View as user..." />
|
||||||
@@ -207,6 +241,39 @@ export function Chat() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 p-2 border rounded-md bg-muted w-64">
|
||||||
|
{selectedUserId && users.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
const selectedUser = users.find(u => u.id.toString() === selectedUserId);
|
||||||
|
return selectedUser ? (
|
||||||
|
<>
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage
|
||||||
|
src={selectedUser.mongo_id ? `${config.chatUrl}/avatar/${selectedUser.mongo_id}` : undefined}
|
||||||
|
alt={selectedUser.name || selectedUser.username}
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{(selectedUser.name || selectedUser.username).charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span>
|
||||||
|
Viewing as: {selectedUser.name || selectedUser.username}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">No connected Rocket Chat user</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{userRocketChatId ? 'Loading...' : 'No connected Rocket Chat user'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -233,9 +300,20 @@ export function Chat() {
|
|||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-center h-64">
|
<CardContent className="flex items-center justify-center h-64">
|
||||||
|
{currentUser?.is_admin ? (
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Select a user to view their chat rooms and messages.
|
Select a user to view their chat rooms and messages.
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No Rocket Chat user connected to your account.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Please contact your administrator to connect your account with a Rocket Chat user.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, Fragment } from "react";
|
import { useEffect, useState, useMemo, Fragment } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -16,16 +16,55 @@ import {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { columns, ForecastItem, renderSubComponent } from "@/components/forecasting/columns";
|
import { columns, ForecastItem, renderSubComponent } from "@/components/forecasting/columns";
|
||||||
import { DateRange } from "react-day-picker";
|
import { DateRange } from "react-day-picker";
|
||||||
import { addDays } from "date-fns";
|
import { addDays, addMonths } from "date-fns";
|
||||||
import { DateRangePicker } from "@/components/ui/date-range-picker";
|
import { DateRangePickerQuick } from "@/components/forecasting/DateRangePickerQuick";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { QuickOrderBuilder } from "@/components/forecasting/QuickOrderBuilder";
|
||||||
|
|
||||||
|
|
||||||
export default function Forecasting() {
|
export default function Forecasting() {
|
||||||
const [selectedBrand, setSelectedBrand] = useState<string>("");
|
const [selectedBrand, setSelectedBrand] = useState<string>("");
|
||||||
const [dateRange, setDateRange] = useState<DateRange>({
|
const [dateRange, setDateRange] = useState<DateRange>({
|
||||||
from: addDays(new Date(), -30),
|
from: addDays(addMonths(new Date(), -1), 1),
|
||||||
to: new Date(),
|
to: new Date(),
|
||||||
});
|
});
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const FILTERS_KEY = "forecastingFilters";
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Restore saved brand and date range on first mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FILTERS_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const saved = JSON.parse(raw);
|
||||||
|
if (typeof saved.brand === 'string') setSelectedBrand(saved.brand);
|
||||||
|
if (saved.from && saved.to) {
|
||||||
|
const from = new Date(saved.from);
|
||||||
|
const to = new Date(saved.to);
|
||||||
|
if (!isNaN(from.getTime()) && !isNaN(to.getTime())) {
|
||||||
|
setDateRange({ from, to });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Force a refetch once state settles
|
||||||
|
setTimeout(() => {
|
||||||
|
try { queryClient.invalidateQueries({ queryKey: ["forecast"] }); } catch {}
|
||||||
|
}, 0);
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Persist brand and date range
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
FILTERS_KEY,
|
||||||
|
JSON.stringify({ brand: selectedBrand, from: dateRange.from?.toISOString(), to: dateRange.to?.toISOString() })
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}, [selectedBrand, dateRange]);
|
||||||
|
|
||||||
|
|
||||||
const handleDateRangeChange = (range: DateRange | undefined) => {
|
const handleDateRangeChange = (range: DateRange | undefined) => {
|
||||||
if (range) {
|
if (range) {
|
||||||
@@ -61,21 +100,16 @@ export default function Forecasting() {
|
|||||||
return data.map((item: any) => ({
|
return data.map((item: any) => ({
|
||||||
category: item.category_name,
|
category: item.category_name,
|
||||||
categoryPath: item.path,
|
categoryPath: item.path,
|
||||||
avgDailySales: Number(item.avg_daily_sales) || 0,
|
|
||||||
totalSold: Number(item.total_sold) || 0,
|
totalSold: Number(item.total_sold) || 0,
|
||||||
numProducts: Number(item.num_products) || 0,
|
numProducts: Number(item.num_products) || 0,
|
||||||
avgPrice: Number(item.avg_price) || 0,
|
|
||||||
avgTotalSold: Number(item.avgTotalSold) || 0,
|
avgTotalSold: Number(item.avgTotalSold) || 0,
|
||||||
|
minSold: Number(item.minSold) || 0,
|
||||||
|
maxSold: Number(item.maxSold) || 0,
|
||||||
products: item.products?.map((p: any) => ({
|
products: item.products?.map((p: any) => ({
|
||||||
pid: p.pid,
|
pid: p.pid,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
sku: p.sku,
|
sku: p.sku,
|
||||||
stock_quantity: Number(p.stock_quantity) || 0,
|
|
||||||
total_sold: Number(p.total_sold) || 0,
|
total_sold: Number(p.total_sold) || 0,
|
||||||
daily_sales_avg: Number(p.daily_sales_avg) || 0,
|
|
||||||
forecast_units: Number(p.forecast_units) || 0,
|
|
||||||
forecast_revenue: Number(p.forecast_revenue) || 0,
|
|
||||||
confidence_level: Number(p.confidence_level) || 0,
|
|
||||||
categoryPath: item.path
|
categoryPath: item.path
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
@@ -83,8 +117,60 @@ export default function Forecasting() {
|
|||||||
enabled: !!selectedBrand && !!dateRange.from && !!dateRange.to,
|
enabled: !!selectedBrand && !!dateRange.from && !!dateRange.to,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Local, instant filter + summary for title substring matches within category groups
|
||||||
|
|
||||||
|
type ProductLite = { pid: string; title: string; sku: string; total_sold: number; categoryPath: string };
|
||||||
|
|
||||||
|
const displayData = useMemo(() => {
|
||||||
|
if (!forecastData) return [] as ForecastItem[];
|
||||||
|
const term = search.trim().toLowerCase();
|
||||||
|
if (!term) return forecastData;
|
||||||
|
|
||||||
|
const filteredGroups: ForecastItem[] = [];
|
||||||
|
const allMatchedProducts: ProductLite[] = [];
|
||||||
|
for (const g of forecastData) {
|
||||||
|
const matched: ProductLite[] = (g.products || []).filter((p: ProductLite) => p.title?.toLowerCase().includes(term));
|
||||||
|
if (matched.length === 0) continue;
|
||||||
|
const totalSold = matched.reduce((sum: number, p: ProductLite) => sum + (p.total_sold || 0), 0);
|
||||||
|
const numProducts = matched.length;
|
||||||
|
const avgTotalSold = numProducts > 0 ? totalSold / numProducts : 0;
|
||||||
|
const minSold = matched.reduce((min: number, p: ProductLite) => Math.min(min, p.total_sold || 0), Number.POSITIVE_INFINITY);
|
||||||
|
const maxSold = matched.reduce((max: number, p: ProductLite) => Math.max(max, p.total_sold || 0), 0);
|
||||||
|
filteredGroups.push({
|
||||||
|
category: g.category,
|
||||||
|
categoryPath: g.categoryPath,
|
||||||
|
totalSold,
|
||||||
|
numProducts,
|
||||||
|
avgTotalSold,
|
||||||
|
minSold: Number.isFinite(minSold) ? minSold : 0,
|
||||||
|
maxSold,
|
||||||
|
products: matched,
|
||||||
|
});
|
||||||
|
allMatchedProducts.push(...matched);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allMatchedProducts.length > 0) {
|
||||||
|
const totalSoldAll = allMatchedProducts.reduce((sum: number, p: ProductLite) => sum + (p.total_sold || 0), 0);
|
||||||
|
const avgTotalSoldAll = totalSoldAll / allMatchedProducts.length;
|
||||||
|
const minSoldAll = allMatchedProducts.reduce((min: number, p: ProductLite) => Math.min(min, p.total_sold || 0), Number.POSITIVE_INFINITY);
|
||||||
|
const maxSoldAll = allMatchedProducts.reduce((max: number, p: ProductLite) => Math.max(max, p.total_sold || 0), 0);
|
||||||
|
filteredGroups.unshift({
|
||||||
|
category: `Matches: "${search}"`,
|
||||||
|
categoryPath: "",
|
||||||
|
totalSold: totalSoldAll,
|
||||||
|
numProducts: allMatchedProducts.length,
|
||||||
|
avgTotalSold: avgTotalSoldAll,
|
||||||
|
minSold: Number.isFinite(minSoldAll) ? minSoldAll : 0,
|
||||||
|
maxSold: maxSoldAll,
|
||||||
|
products: allMatchedProducts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredGroups;
|
||||||
|
}, [forecastData, search]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: forecastData || [],
|
data: displayData || [],
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
@@ -97,13 +183,13 @@ export default function Forecasting() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-10">
|
<div className="container mx-auto py-10 space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sales Forecasting</CardTitle>
|
<CardTitle>Historical Sales</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex gap-4 mb-6">
|
<div className="flex gap-4 mb-6 items-center">
|
||||||
<div className="w-[200px]">
|
<div className="w-[200px]">
|
||||||
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
|
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
|
||||||
<SelectTrigger disabled={brandsLoading}>
|
<SelectTrigger disabled={brandsLoading}>
|
||||||
@@ -118,15 +204,36 @@ export default function Forecasting() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<DateRangePicker
|
<DateRangePickerQuick
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={handleDateRangeChange}
|
onChange={handleDateRangeChange}
|
||||||
/>
|
/>
|
||||||
|
{(Array.isArray(displayData) && displayData.length > 0) || search.trim().length > 0 ? (
|
||||||
|
<div className="w-[400px] relative">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter by product title"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pr-8"
|
||||||
|
/>
|
||||||
|
{search.trim().length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{forecastLoading ? (
|
{forecastLoading ? (
|
||||||
<div className="h-24 flex items-center justify-center">
|
<div className="h-24 flex items-center justify-center">
|
||||||
Loading forecast data...
|
Loading sales data...
|
||||||
</div>
|
</div>
|
||||||
) : forecastData && (
|
) : forecastData && (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
@@ -153,6 +260,7 @@ export default function Forecasting() {
|
|||||||
<Fragment key={row.id}>
|
<Fragment key={row.id}>
|
||||||
<TableRow
|
<TableRow
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className={String(row.original.category || '').startsWith('Matches:') ? 'bg-muted font-medium' : ''}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
@@ -185,6 +293,17 @@ export default function Forecasting() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{/* Quick Order Builder */}
|
||||||
|
<QuickOrderBuilder
|
||||||
|
brand={selectedBrand}
|
||||||
|
categories={(displayData || []).map((c: any) => ({
|
||||||
|
category: c.category,
|
||||||
|
categoryPath: c.categoryPath,
|
||||||
|
avgTotalSold: c.avgTotalSold,
|
||||||
|
minSold: c.minSold,
|
||||||
|
maxSold: c.maxSold,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,7 @@ const BASE_IMPORT_FIELDS = [
|
|||||||
label: "Cost Each",
|
label: "Cost Each",
|
||||||
key: "cost_each",
|
key: "cost_each",
|
||||||
description: "Wholesale cost per unit",
|
description: "Wholesale cost per unit",
|
||||||
alternateMatches: ["wholesale", "wholesale price", "supplier cost each"],
|
alternateMatches: ["wholesale", "wholesale price", "supplier cost each", "cost each"],
|
||||||
fieldType: {
|
fieldType: {
|
||||||
type: "input",
|
type: "input",
|
||||||
price: true
|
price: true
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user