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:
|
||||
|
||||
- Page access: `access:{page_name}`
|
||||
- Actions: `{action}:{resource}`
|
||||
- Settings sections: `settings:{section_name}`
|
||||
- Admin features: `admin:{feature}`
|
||||
|
||||
Examples:
|
||||
- `access:products` - Can access the Products page
|
||||
- `create:products` - Can create new products
|
||||
- `edit:users` - Can edit user accounts
|
||||
- `settings:user_management` - Can access User Management settings
|
||||
- `admin:debug` - Can see debug information
|
||||
|
||||
## Permission Components
|
||||
|
||||
@@ -22,10 +23,10 @@ The core component that conditionally renders content based on permissions.
|
||||
|
||||
```tsx
|
||||
<PermissionGuard
|
||||
permission="create:products"
|
||||
permission="settings:user_management"
|
||||
fallback={<p>No permission</p>}
|
||||
>
|
||||
<button>Create Product</button>
|
||||
<button>Manage Users</button>
|
||||
</PermissionGuard>
|
||||
```
|
||||
|
||||
@@ -81,7 +82,7 @@ Specific component for settings with built-in permission checks.
|
||||
<SettingsSection
|
||||
title="System Settings"
|
||||
description="Configure global settings"
|
||||
permission="edit:system_settings"
|
||||
permission="settings:global"
|
||||
>
|
||||
{/* Settings content */}
|
||||
</SettingsSection>
|
||||
@@ -95,8 +96,8 @@ Core hook for checking any permission.
|
||||
|
||||
```tsx
|
||||
const { hasPermission, hasPageAccess, isAdmin } = usePermissions();
|
||||
if (hasPermission('delete:products')) {
|
||||
// Can delete products
|
||||
if (hasPermission('settings:user_management')) {
|
||||
// Can access user management
|
||||
}
|
||||
```
|
||||
|
||||
@@ -106,8 +107,8 @@ Specialized hook for page-level permissions.
|
||||
|
||||
```tsx
|
||||
const { canView, canCreate, canEdit, canDelete } = usePagePermission('products');
|
||||
if (canEdit()) {
|
||||
// Can edit products
|
||||
if (canView()) {
|
||||
// Can view products
|
||||
}
|
||||
```
|
||||
|
||||
@@ -119,18 +120,43 @@ Permissions are stored in the database:
|
||||
|
||||
Admin users automatically have all permissions.
|
||||
|
||||
## Common Permission Codes
|
||||
## Implemented Permission Codes
|
||||
|
||||
### Page Access Permissions
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `access:dashboard` | Access to Dashboard page |
|
||||
| `access:overview` | Access to Overview page |
|
||||
| `access:products` | Access to Products page |
|
||||
| `create:products` | Create new products |
|
||||
| `edit:products` | Edit existing products |
|
||||
| `delete:products` | Delete products |
|
||||
| `view:users` | View user accounts |
|
||||
| `edit:users` | Edit user accounts |
|
||||
| `manage:permissions` | Assign permissions to users |
|
||||
| `access:categories` | Access to Categories page |
|
||||
| `access:brands` | Access to Brands page |
|
||||
| `access:vendors` | Access to Vendors page |
|
||||
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||
| `access:analytics` | Access to Analytics page |
|
||||
| `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
|
||||
|
||||
@@ -148,25 +174,31 @@ In `App.tsx`:
|
||||
### Component Level Protection
|
||||
|
||||
```tsx
|
||||
const { canEdit } = usePagePermission('products');
|
||||
const { hasPermission } = usePermissions();
|
||||
|
||||
function handleEdit() {
|
||||
if (!canEdit()) {
|
||||
function handleAction() {
|
||||
if (!hasPermission('settings:user_management')) {
|
||||
toast.error("You don't have permission");
|
||||
return;
|
||||
}
|
||||
// Edit logic
|
||||
// Action logic
|
||||
}
|
||||
```
|
||||
|
||||
### UI Element Protection
|
||||
|
||||
```tsx
|
||||
<PermissionButton
|
||||
page="products"
|
||||
action="delete"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</PermissionButton>
|
||||
<PermissionGuard permission="settings:user_management">
|
||||
<button onClick={handleManageUsers}>
|
||||
Manage Users
|
||||
</button>
|
||||
</PermissionGuard>
|
||||
```
|
||||
|
||||
## 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
|
||||
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]
|
||||
);
|
||||
|
||||
console.log('Database query result for user', decoded.userId, ':', result.rows[0]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
@@ -58,7 +60,7 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
// Get user from database
|
||||
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]
|
||||
);
|
||||
|
||||
@@ -101,6 +103,7 @@ router.post('/login', async (req, res) => {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin,
|
||||
rocket_chat_user_id: user.rocket_chat_user_id,
|
||||
permissions
|
||||
}
|
||||
});
|
||||
@@ -119,8 +122,13 @@ router.get('/me', authenticate, async (req, res) => {
|
||||
res.json({
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
email: req.user.email,
|
||||
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) {
|
||||
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) => {
|
||||
try {
|
||||
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
|
||||
ORDER BY username
|
||||
`);
|
||||
@@ -151,7 +159,7 @@ router.get('/users/:id', authenticate, requirePermission('view:users'), async (r
|
||||
|
||||
// Get user details
|
||||
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
|
||||
WHERE id = $1
|
||||
`, [userId]);
|
||||
@@ -187,13 +195,14 @@ router.post('/users', authenticate, requirePermission('create:users'), async (re
|
||||
const client = await pool.connect();
|
||||
|
||||
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:", {
|
||||
username,
|
||||
email,
|
||||
is_admin,
|
||||
is_active,
|
||||
rocket_chat_user_id,
|
||||
permissions: permissions || []
|
||||
});
|
||||
|
||||
@@ -221,10 +230,10 @@ router.post('/users', authenticate, requirePermission('create:users'), async (re
|
||||
|
||||
// Insert new user
|
||||
const userResult = await client.query(`
|
||||
INSERT INTO users (username, email, password, is_admin, is_active, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
INSERT INTO users (username, email, password, is_admin, is_active, rocket_chat_user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP)
|
||||
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;
|
||||
|
||||
@@ -299,7 +308,7 @@ router.put('/users/:id', authenticate, requirePermission('edit:users'), async (r
|
||||
|
||||
try {
|
||||
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:", {
|
||||
userId,
|
||||
@@ -307,6 +316,7 @@ router.put('/users/:id', authenticate, requirePermission('edit:users'), async (r
|
||||
email,
|
||||
is_admin,
|
||||
is_active,
|
||||
rocket_chat_user_id,
|
||||
permissions: permissions || []
|
||||
});
|
||||
|
||||
@@ -348,6 +358,11 @@ router.put('/users/:id', authenticate, requirePermission('edit:users'), async (r
|
||||
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
|
||||
if (password) {
|
||||
const saltRounds = 10;
|
||||
|
||||
@@ -108,7 +108,7 @@ app.get('/me', async (req, res) => {
|
||||
|
||||
// Get user details from database
|
||||
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]
|
||||
);
|
||||
|
||||
@@ -135,6 +135,7 @@ app.get('/me', async (req, res) => {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
rocket_chat_user_id: user.rocket_chat_user_id,
|
||||
is_admin: user.is_admin,
|
||||
permissions: permissions
|
||||
});
|
||||
|
||||
@@ -1,6 +1,169 @@
|
||||
const express = require('express');
|
||||
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
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -107,10 +107,10 @@ router.get('/stats', async (req, res) => {
|
||||
// Get overall cost metrics from purchase orders
|
||||
const { rows: [overallCostMetrics] } = await pool.query(`
|
||||
SELECT
|
||||
ROUND((SUM(ordered * 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 / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
|
||||
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_spend
|
||||
FROM purchase_orders
|
||||
WHERE cost_price IS NOT NULL
|
||||
WHERE po_cost_price IS NOT NULL
|
||||
AND ordered > 0
|
||||
AND vendor IS NOT NULL AND vendor != ''
|
||||
`);
|
||||
@@ -261,10 +261,10 @@ router.get('/', async (req, res) => {
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
vendor,
|
||||
ROUND((SUM(ordered * 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 / NULLIF(SUM(ordered), 0)), 2) as avg_unit_cost,
|
||||
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_spend
|
||||
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
|
||||
) po ON vm.vendor_name = po.vendor
|
||||
${whereClause}
|
||||
|
||||
6
inventory/package-lock.json
generated
6
inventory/package-lock.json
generated
@@ -3763,9 +3763,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001700",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
|
||||
"integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
|
||||
"version": "1.0.30001739",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz",
|
||||
"integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"mount": "../mountremote.command"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -103,14 +103,7 @@ function App() {
|
||||
}>
|
||||
{/* Core inventory app routes - will be lazy loaded */}
|
||||
<Route index element={
|
||||
<Protected page="dashboard" fallback={<FirstAccessiblePage />}>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Overview />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<Protected page="dashboard">
|
||||
<Protected page="overview" fallback={<FirstAccessiblePage />}>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Overview />
|
||||
</Suspense>
|
||||
|
||||
@@ -1,67 +1,162 @@
|
||||
# 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
|
||||
|
||||
Permissions follow this naming convention:
|
||||
|
||||
- Page access: `access:{page_name}`
|
||||
- Actions: `{action}:{resource}`
|
||||
- Settings sections: `settings:{section_name}`
|
||||
- Admin features: `admin:{feature}`
|
||||
|
||||
Examples:
|
||||
- `access:products` - Can access the Products page
|
||||
- `create:products` - Can create new products
|
||||
- `edit:users` - Can edit user accounts
|
||||
- `settings:user_management` - Can access User Management settings
|
||||
- `admin:debug` - Can see debug information
|
||||
|
||||
## Permission Component
|
||||
## Permission Components
|
||||
|
||||
### Protected
|
||||
### PermissionGuard
|
||||
|
||||
The core component that conditionally renders content based on permissions.
|
||||
|
||||
```tsx
|
||||
<Protected
|
||||
permission="create:products"
|
||||
<PermissionGuard
|
||||
permission="settings:user_management"
|
||||
fallback={<p>No permission</p>}
|
||||
>
|
||||
<button>Create Product</button>
|
||||
</Protected>
|
||||
<button>Manage Users</button>
|
||||
</PermissionGuard>
|
||||
```
|
||||
|
||||
Options:
|
||||
- `permission`: Single permission code (e.g., "create:products")
|
||||
- `page`: Page name (checks `access:{page}` permission)
|
||||
- `resource` + `action`: Resource and action (checks `{action}:{resource}` permission)
|
||||
- `permission`: Single permission code
|
||||
- `anyPermissions`: Array of permissions (ANY match grants access)
|
||||
- `allPermissions`: Array of permissions (ALL required)
|
||||
- `adminOnly`: For admin-only sections
|
||||
- `page`: Page name (checks `access:{page}` permission)
|
||||
- `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
|
||||
<Route element={
|
||||
<RequireAuth>
|
||||
<MainLayout />
|
||||
</RequireAuth>
|
||||
}>
|
||||
{/* Protected routes */}
|
||||
</Route>
|
||||
<Route path="/products" element={
|
||||
<PermissionProtectedRoute page="products">
|
||||
<Products />
|
||||
</PermissionProtectedRoute>
|
||||
} />
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|------|-------------|
|
||||
| `access:dashboard` | Access to Dashboard page |
|
||||
| `access:overview` | Access to Overview page |
|
||||
| `access:products` | Access to Products page |
|
||||
| `create:products` | Create new products |
|
||||
| `edit:products` | Edit existing products |
|
||||
| `delete:products` | Delete products |
|
||||
| `view:users` | View user accounts |
|
||||
| `edit:users` | Edit user accounts |
|
||||
| `manage:permissions` | Assign permissions to users |
|
||||
| `access:categories` | Access to Categories page |
|
||||
| `access:brands` | Access to Brands page |
|
||||
| `access:vendors` | Access to Vendors page |
|
||||
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||
| `access:analytics` | Access to Analytics page |
|
||||
| `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
|
||||
|
||||
@@ -70,35 +165,40 @@ Used for basic authentication checks (is user logged in?).
|
||||
In `App.tsx`:
|
||||
```tsx
|
||||
<Route path="/products" element={
|
||||
<Protected page="products" fallback={<Navigate to="/" />}>
|
||||
<PermissionProtectedRoute page="products">
|
||||
<Products />
|
||||
</Protected>
|
||||
</PermissionProtectedRoute>
|
||||
} />
|
||||
```
|
||||
|
||||
### Component Level Protection
|
||||
|
||||
```tsx
|
||||
<Protected permission="edit:products">
|
||||
<form>
|
||||
{/* Form fields */}
|
||||
<button type="submit">Save Changes</button>
|
||||
</form>
|
||||
</Protected>
|
||||
const { hasPermission } = usePermissions();
|
||||
|
||||
function handleAction() {
|
||||
if (!hasPermission('settings:user_management')) {
|
||||
toast.error("You don't have permission");
|
||||
return;
|
||||
}
|
||||
// Action logic
|
||||
}
|
||||
```
|
||||
|
||||
### Button Protection
|
||||
### UI Element Protection
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={!hasPermission('delete:products')}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
// With Protected component
|
||||
<Protected permission="delete:products" fallback={null}>
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
</Protected>
|
||||
<PermissionGuard permission="settings:user_management">
|
||||
<button onClick={handleManageUsers}>
|
||||
Manage Users
|
||||
</button>
|
||||
</PermissionGuard>
|
||||
```
|
||||
|
||||
## 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;
|
||||
sku: string;
|
||||
title: string;
|
||||
stock_quantity: number;
|
||||
daily_sales_avg: number;
|
||||
forecast_units: number;
|
||||
forecast_revenue: number;
|
||||
confidence_level: number;
|
||||
total_sold: number;
|
||||
}
|
||||
|
||||
export interface ForecastItem {
|
||||
category: string;
|
||||
categoryPath: string;
|
||||
avgDailySales: number;
|
||||
totalSold: number;
|
||||
numProducts: number;
|
||||
avgPrice: number;
|
||||
avgTotalSold: number;
|
||||
minSold: number;
|
||||
maxSold: number;
|
||||
products?: Product[];
|
||||
}
|
||||
|
||||
@@ -57,7 +53,7 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "avgDailySales",
|
||||
accessorKey: "avgTotalSold",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -65,16 +61,54 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Avg Daily Sales
|
||||
Avg Total Sold
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("avgDailySales") as number;
|
||||
const value = row.getValue("avgTotalSold") as number;
|
||||
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",
|
||||
header: ({ column }) => {
|
||||
@@ -112,44 +146,6 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
||||
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 }) => {
|
||||
@@ -161,11 +157,7 @@ export const renderSubComponent = ({ row }: { row: any }) => {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</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>
|
||||
<TableHead className="text-right">Sold</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -182,11 +174,7 @@ export const renderSubComponent = ({ row }: { row: any }) => {
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||
<TableCell className="text-right">{product.daily_sales_avg.toFixed(1)}</TableCell>
|
||||
<TableCell className="text-right">{product.forecast_units.toFixed(1)}</TableCell>
|
||||
<TableCell className="text-right">{product.forecast_revenue.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{product.confidence_level.toFixed(1)}%</TableCell>
|
||||
<TableCell className="text-right">{product.total_sold?.toLocaleString?.() ?? product.total_sold}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom";
|
||||
import { Protected } from "@/components/auth/Protected";
|
||||
import { useContext } from "react";
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
|
||||
const dashboardItems = [
|
||||
{
|
||||
@@ -112,6 +114,7 @@ export function AppSidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
useSidebar();
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
@@ -119,6 +122,12 @@ export function AppSidebar() {
|
||||
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) => {
|
||||
return items.map((item) => {
|
||||
const isActive =
|
||||
@@ -180,58 +189,58 @@ export function AppSidebar() {
|
||||
<SidebarSeparator />
|
||||
<SidebarContent>
|
||||
{/* Dashboard Section */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Dashboard</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{renderMenuItems(dashboardItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{hasAccessToSection(dashboardItems) && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Dashboard</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{renderMenuItems(dashboardItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{/* Inventory Section */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Inventory</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{renderMenuItems(inventoryItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{hasAccessToSection(inventoryItems) && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Inventory</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{renderMenuItems(inventoryItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{/* Product Setup Section */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Product Setup</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{renderMenuItems(productSetupItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{hasAccessToSection(productSetupItems) && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Product Setup</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{renderMenuItems(productSetupItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{/* Chat Section */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Chat</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{renderMenuItems(chatItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarSeparator />
|
||||
{hasAccessToSection(chatItems) && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Chat</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{renderMenuItems(chatItems)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{/* Settings Section */}
|
||||
<SidebarGroup>
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<Protected
|
||||
permission="access:settings"
|
||||
fallback={null}
|
||||
>
|
||||
<Protected permission="access:settings" fallback={null}>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
@@ -246,10 +255,10 @@ export function AppSidebar() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</Protected>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</Protected>
|
||||
</SidebarContent>
|
||||
<SidebarSeparator />
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -312,7 +312,7 @@ const SupplierSelector = React.memo(({
|
||||
{suppliers?.map((supplier: any) => (
|
||||
<CommandItem
|
||||
key={supplier.value}
|
||||
value={supplier.label}
|
||||
value={`${supplier.label} ${supplier.value}`}
|
||||
onSelect={() => {
|
||||
onChange(supplier.value);
|
||||
setOpen(false); // Close popover after selection
|
||||
@@ -347,11 +347,25 @@ const CompanySelector = React.memo(({
|
||||
companies: any[]
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const handleCommandListWheel = (e: React.WheelEvent) => {
|
||||
e.currentTarget.scrollTop += e.deltaY;
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -369,14 +383,14 @@ const CompanySelector = React.memo(({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search companies..." />
|
||||
<CommandInput placeholder="Search companies..." value={query} onValueChange={setQuery} />
|
||||
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleCommandListWheel}>
|
||||
<CommandEmpty>No companies found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{companies?.map((company: any) => (
|
||||
{filteredCompanies.map((company: any) => (
|
||||
<CommandItem
|
||||
key={company.value}
|
||||
value={company.label}
|
||||
value={`${company.label} ${company.value}`}
|
||||
onSelect={() => {
|
||||
onChange(company.value);
|
||||
setOpen(false); // Close popover after selection
|
||||
@@ -443,7 +457,7 @@ const LineSelector = React.memo(({
|
||||
{lines?.map((line: any) => (
|
||||
<CommandItem
|
||||
key={line.value}
|
||||
value={line.label}
|
||||
value={`${line.label} ${line.value}`}
|
||||
onSelect={() => {
|
||||
onChange(line.value);
|
||||
setOpen(false); // Close popover after selection
|
||||
@@ -510,7 +524,7 @@ const SubLineSelector = React.memo(({
|
||||
{sublines?.map((subline: any) => (
|
||||
<CommandItem
|
||||
key={subline.value}
|
||||
value={subline.label}
|
||||
value={`${subline.label} ${subline.value}`}
|
||||
onSelect={() => {
|
||||
onChange(subline.value);
|
||||
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
|
||||
const dataWithGlobalSelections = globalSelections
|
||||
? dataWithMeta.map((row: Data<string> & { __index?: string }) => {
|
||||
const newRow = { ...row };
|
||||
const newRow = { ...row } as any;
|
||||
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
|
||||
if (globalSelections.company) newRow.company = globalSelections.company;
|
||||
if (globalSelections.line) newRow.line = globalSelections.line;
|
||||
if (globalSelections.subline) newRow.subline = globalSelections.subline;
|
||||
return newRow;
|
||||
})
|
||||
: dataWithMeta;
|
||||
|
||||
@@ -542,7 +542,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
||||
<div className="text-xs font-semibold text-blue-700 mb-2">
|
||||
Company-Specific Instructions
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
<pre className="whitespace-pre-wrap break-words break-all">
|
||||
{content.substring(
|
||||
companySpecificStartIndex,
|
||||
companySpecificEndIndex +
|
||||
@@ -566,7 +566,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
||||
<div className="text-xs font-semibold text-amber-700 mb-2">
|
||||
Taxonomy Data
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
<pre className="whitespace-pre-wrap break-words break-all">
|
||||
{content.substring(
|
||||
actualTaxonomyStartIndex,
|
||||
taxEnd
|
||||
@@ -587,7 +587,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
||||
<div className="text-xs font-semibold text-pink-700 mb-2">
|
||||
Product Data
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap">
|
||||
<pre className="whitespace-pre-wrap break-words break-all">
|
||||
{content.substring(
|
||||
productDataStartIndex
|
||||
)}
|
||||
@@ -600,7 +600,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap">
|
||||
<pre className="whitespace-pre-wrap break-words break-all">
|
||||
{message.content}
|
||||
</pre>
|
||||
)}
|
||||
@@ -612,7 +612,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<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}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -20,6 +20,8 @@ interface UpcValidationTableAdapterProps<T extends string> {
|
||||
copyDown: (rowIndex: number, fieldKey: string, endRowIndex?: number) => void
|
||||
validatingCells: Set<string>
|
||||
isLoadingTemplates: boolean
|
||||
editingCells: Set<string>
|
||||
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
rowProductLines: Record<string, any[]>
|
||||
rowSublines: Record<string, any[]>
|
||||
isLoadingLines: Record<string, boolean>
|
||||
@@ -53,6 +55,8 @@ function UpcValidationTableAdapter<T extends string>({
|
||||
copyDown,
|
||||
validatingCells: externalValidatingCells,
|
||||
isLoadingTemplates,
|
||||
editingCells,
|
||||
setEditingCells,
|
||||
rowProductLines,
|
||||
rowSublines,
|
||||
isLoadingLines,
|
||||
@@ -86,11 +90,7 @@ function UpcValidationTableAdapter<T extends string>({
|
||||
|
||||
// First add from itemNumbers directly - this is the source of truth for template applications
|
||||
if (itemNumbers) {
|
||||
// Log all numbers for debugging
|
||||
console.log(`[ADAPTER-DEBUG] Received itemNumbers map with ${itemNumbers.size} entries`);
|
||||
|
||||
itemNumbers.forEach((itemNumber, rowIndex) => {
|
||||
console.log(`[ADAPTER-DEBUG] Adding item number for row ${rowIndex} from itemNumbers: ${itemNumber}`);
|
||||
result.set(rowIndex, itemNumber);
|
||||
});
|
||||
}
|
||||
@@ -100,14 +100,12 @@ function UpcValidationTableAdapter<T extends string>({
|
||||
// Check if upcValidation has an item number for this row
|
||||
const itemNumber = upcValidation.getItemNumber(index);
|
||||
if (itemNumber) {
|
||||
console.log(`[ADAPTER-DEBUG] Adding item number for row ${index} from upcValidation: ${itemNumber}`);
|
||||
result.set(index, itemNumber);
|
||||
}
|
||||
|
||||
// Also check if it's directly in the data
|
||||
const dataItemNumber = data[index].item_number;
|
||||
if (dataItemNumber && !result.has(index)) {
|
||||
console.log(`[ADAPTER-DEBUG] Adding item number for row ${index} from data: ${dataItemNumber}`);
|
||||
result.set(index, dataItemNumber);
|
||||
}
|
||||
});
|
||||
@@ -151,6 +149,8 @@ function UpcValidationTableAdapter<T extends string>({
|
||||
rowSublines={rowSublines}
|
||||
isLoadingLines={isLoadingLines}
|
||||
isLoadingSublines={isLoadingSublines}
|
||||
editingCells={editingCells}
|
||||
setEditingCells={setEditingCells}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,9 @@ const BaseCellContent = React.memo(({
|
||||
hasErrors,
|
||||
options = [],
|
||||
className = '',
|
||||
fieldKey = ''
|
||||
fieldKey = '',
|
||||
onStartEdit,
|
||||
onEndEdit
|
||||
}: {
|
||||
field: Field<string>;
|
||||
value: any;
|
||||
@@ -87,6 +89,8 @@ const BaseCellContent = React.memo(({
|
||||
options?: readonly any[];
|
||||
className?: string;
|
||||
fieldKey?: string;
|
||||
onStartEdit?: () => void;
|
||||
onEndEdit?: () => void;
|
||||
}) => {
|
||||
// Get field type information
|
||||
const fieldType = fieldKey === 'line' || fieldKey === 'subline'
|
||||
@@ -113,6 +117,8 @@ const BaseCellContent = React.memo(({
|
||||
field={{...field, fieldType: { type: 'select', options }}}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={onStartEdit}
|
||||
onEndEdit={onEndEdit}
|
||||
options={options}
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
@@ -127,6 +133,8 @@ const BaseCellContent = React.memo(({
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={onStartEdit}
|
||||
onEndEdit={onEndEdit}
|
||||
options={options}
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
@@ -141,6 +149,8 @@ const BaseCellContent = React.memo(({
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={onStartEdit}
|
||||
onEndEdit={onEndEdit}
|
||||
options={options}
|
||||
hasErrors={hasErrors}
|
||||
className={className}
|
||||
@@ -154,6 +164,8 @@ const BaseCellContent = React.memo(({
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onStartEdit={onStartEdit}
|
||||
onEndEdit={onEndEdit}
|
||||
hasErrors={hasErrors}
|
||||
isMultiline={isMultiline}
|
||||
isPrice={isPrice}
|
||||
@@ -191,6 +203,8 @@ export interface ValidationCellProps {
|
||||
rowIndex: number
|
||||
copyDown?: (endRowIndex?: number) => void
|
||||
totalRows?: number
|
||||
editingCells: Set<string>
|
||||
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
}
|
||||
|
||||
// Add efficient error message extraction function
|
||||
@@ -288,7 +302,9 @@ const ValidationCell = React.memo(({
|
||||
width,
|
||||
copyDown,
|
||||
rowIndex,
|
||||
totalRows = 0
|
||||
totalRows = 0,
|
||||
// editingCells not used; keep setEditingCells for API compatibility
|
||||
setEditingCells
|
||||
}: ValidationCellProps) => {
|
||||
// Use the CopyDown context
|
||||
const copyDownContext = React.useContext(CopyDownContext);
|
||||
@@ -297,9 +313,6 @@ const ValidationCell = React.memo(({
|
||||
// This ensures that when the itemNumber changes, the display value changes
|
||||
let displayValue;
|
||||
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
|
||||
displayValue = itemNumber;
|
||||
} else {
|
||||
@@ -324,6 +337,22 @@ const ValidationCell = React.memo(({
|
||||
// Add state for hover on target row
|
||||
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
|
||||
const isLoading = isValidating === true;
|
||||
|
||||
@@ -461,6 +490,8 @@ const ValidationCell = React.memo(({
|
||||
options={options}
|
||||
className={cellClassName}
|
||||
fieldKey={fieldKey}
|
||||
onStartEdit={handleStartEdit}
|
||||
onEndEdit={handleEndEdit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -61,7 +61,9 @@ const ValidationContainer = <T extends string>({
|
||||
fields,
|
||||
isLoadingTemplates,
|
||||
validatingCells,
|
||||
setValidatingCells
|
||||
setValidatingCells,
|
||||
editingCells,
|
||||
setEditingCells
|
||||
} = validationState
|
||||
|
||||
// Use product lines fetching hook
|
||||
@@ -121,9 +123,23 @@ const ValidationContainer = <T extends string>({
|
||||
|
||||
// Function to mark a row for revalidation
|
||||
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 => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(rowIndex);
|
||||
newSet.add(originalIndex);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
@@ -131,16 +147,16 @@ const ValidationContainer = <T extends string>({
|
||||
if (fieldKey) {
|
||||
setFieldsToRevalidateMap(prev => {
|
||||
const newMap = { ...prev };
|
||||
if (!newMap[rowIndex]) {
|
||||
newMap[rowIndex] = [];
|
||||
if (!newMap[originalIndex]) {
|
||||
newMap[originalIndex] = [];
|
||||
}
|
||||
if (!newMap[rowIndex].includes(fieldKey)) {
|
||||
newMap[rowIndex] = [...newMap[rowIndex], fieldKey];
|
||||
if (!newMap[originalIndex].includes(fieldKey)) {
|
||||
newMap[originalIndex] = [...newMap[originalIndex], fieldKey];
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [data, filteredData]);
|
||||
|
||||
// Add a ref to track the last validation time
|
||||
|
||||
@@ -160,8 +176,6 @@ const ValidationContainer = <T extends string>({
|
||||
// Clear the fields map
|
||||
setFieldsToRevalidateMap({});
|
||||
|
||||
console.log(`Validating ${rowsToRevalidate.length} rows with specific fields`);
|
||||
|
||||
// Revalidate each row with specific fields information
|
||||
validationState.revalidateRows(rowsToRevalidate, fieldsMap);
|
||||
}, [fieldsToRevalidate, validationState, fieldsToRevalidateMap]);
|
||||
@@ -488,81 +502,39 @@ const ValidationContainer = <T extends string>({
|
||||
// Detect if this is a direct item_number edit
|
||||
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) {
|
||||
console.log(`Manual edit to item_number: ${value}`);
|
||||
|
||||
// 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
|
||||
const idx = originalIndex >= 0 ? originalIndex : rowIndex;
|
||||
validationState.updateRow(idx, key as unknown as any, processedValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other fields, use standard approach
|
||||
// Always use setData for updating - immediate update for better UX
|
||||
const updatedRow = { ...rowData, [key]: processedValue };
|
||||
// For all other fields, use core updateRow for atomic update + validation
|
||||
const idx = originalIndex >= 0 ? originalIndex : rowIndex;
|
||||
validationState.updateRow(idx, key as unknown as any, processedValue);
|
||||
|
||||
// Mark this row for revalidation to clear any existing errors
|
||||
markRowForRevalidation(rowIndex, key as string);
|
||||
|
||||
// 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(() => {
|
||||
// Secondary effects - using requestAnimationFrame for better performance
|
||||
requestAnimationFrame(() => {
|
||||
// Handle company change - clear line/subline and fetch product lines
|
||||
if (key === 'company' && value) {
|
||||
console.log(`Company changed to ${value} for row ${rowIndex}, updating lines and sublines`);
|
||||
|
||||
// Clear any existing line/subline values immediately
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
const idx = newData.findIndex(item => item.__index === rowId);
|
||||
if (idx >= 0) {
|
||||
console.log(`Clearing line and subline values for row with ID ${rowId}`);
|
||||
newData[idx] = {
|
||||
...newData[idx],
|
||||
line: undefined,
|
||||
subline: undefined
|
||||
};
|
||||
} else {
|
||||
console.warn(`Could not find row with ID ${rowId} to clear line/subline values`);
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Fetch product lines for the new company
|
||||
// Fetch product lines for the new company with debouncing
|
||||
if (rowId && value !== undefined) {
|
||||
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
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -570,29 +542,29 @@ const ValidationContainer = <T extends string>({
|
||||
return newSet;
|
||||
});
|
||||
|
||||
fetchProductLines(rowId, companyId)
|
||||
.then(lines => {
|
||||
console.log(`Successfully loaded ${lines.length} product lines for company ${companyId}`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error fetching product lines for company ${companyId}:`, err);
|
||||
toast.error("Failed to load product lines");
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear loading indicator
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(`${rowIndex}-line`);
|
||||
return newSet;
|
||||
// Debounce the API call to prevent excessive requests
|
||||
setTimeout(() => {
|
||||
fetchProductLines(rowId, companyId)
|
||||
.catch(err => {
|
||||
console.error(`Error fetching product lines for company ${companyId}:`, err);
|
||||
toast.error("Failed to load product lines");
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear loading indicator
|
||||
setValidatingCells(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(`${rowIndex}-line`);
|
||||
return newSet;
|
||||
});
|
||||
});
|
||||
});
|
||||
}, 100); // 100ms debounce
|
||||
}
|
||||
}
|
||||
|
||||
// Handle supplier + UPC validation - using the most recent values
|
||||
if (key === 'supplier' && value) {
|
||||
// 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) {
|
||||
console.log(`Validating UPC: rowIndex=${rowIndex}, supplier=${value}, upc=${upcValue}`);
|
||||
@@ -689,7 +661,7 @@ const ValidationContainer = <T extends string>({
|
||||
// Handle UPC/barcode + supplier validation
|
||||
if ((key === 'upc' || key === 'barcode') && value) {
|
||||
// Get latest supplier from the updated row
|
||||
const supplier = updatedRow.supplier;
|
||||
const supplier = (data[rowIndex] as any)?.supplier;
|
||||
|
||||
if (supplier) {
|
||||
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]);
|
||||
|
||||
// Fix the missing loading indicator clear code
|
||||
@@ -800,15 +772,15 @@ const ValidationContainer = <T extends string>({
|
||||
markRowForRevalidation(targetRowIndex, fieldKey);
|
||||
});
|
||||
|
||||
// Clear the loading state for all cells after a short delay
|
||||
setTimeout(() => {
|
||||
// Clear the loading state for all cells efficiently
|
||||
requestAnimationFrame(() => {
|
||||
setValidatingCells(prev => {
|
||||
if (prev.size === 0) return prev;
|
||||
if (prev.size === 0 || updatingCells.size === 0) return prev;
|
||||
const newSet = new Set(prev);
|
||||
updatingCells.forEach(cell => newSet.delete(cell));
|
||||
return newSet;
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// If copying UPC or supplier fields, validate UPC for all rows
|
||||
if (fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier') {
|
||||
@@ -949,6 +921,8 @@ const ValidationContainer = <T extends string>({
|
||||
filters={filters}
|
||||
templates={templates}
|
||||
applyTemplate={applyTemplateWrapper}
|
||||
editingCells={editingCells}
|
||||
setEditingCells={setEditingCells}
|
||||
getTemplateDisplayText={getTemplateDisplayText}
|
||||
isValidatingUpc={isRowValidatingUpc}
|
||||
validatingUpcRows={Array.from(upcValidation.validatingRows)}
|
||||
@@ -987,7 +961,18 @@ const ValidationContainer = <T extends string>({
|
||||
]);
|
||||
|
||||
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 h-[calc(100vh-9.5rem)] flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
||||
@@ -24,6 +24,10 @@ type ErrorType = {
|
||||
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> {
|
||||
data: RowData<T>[]
|
||||
fields: Fields<T>
|
||||
@@ -46,6 +50,8 @@ interface ValidationTableProps<T extends string> {
|
||||
itemNumbers: Map<number, string>
|
||||
isLoadingTemplates?: boolean
|
||||
copyDown: (rowIndex: number, key: string, endRowIndex?: number) => void
|
||||
editingCells: Set<string>
|
||||
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@@ -106,7 +112,9 @@ const MemoizedCell = React.memo(({
|
||||
width,
|
||||
rowIndex,
|
||||
copyDown,
|
||||
totalRows
|
||||
totalRows,
|
||||
editingCells,
|
||||
setEditingCells
|
||||
}: {
|
||||
field: Field<string>,
|
||||
value: any,
|
||||
@@ -119,7 +127,9 @@ const MemoizedCell = React.memo(({
|
||||
width: number,
|
||||
rowIndex: number,
|
||||
copyDown?: (endRowIndex?: number) => void,
|
||||
totalRows: number
|
||||
totalRows: number,
|
||||
editingCells: Set<string>,
|
||||
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
}) => {
|
||||
return (
|
||||
<ValidationCell
|
||||
@@ -135,37 +145,24 @@ const MemoizedCell = React.memo(({
|
||||
rowIndex={rowIndex}
|
||||
copyDown={copyDown}
|
||||
totalRows={totalRows}
|
||||
editingCells={editingCells}
|
||||
setEditingCells={setEditingCells}
|
||||
/>
|
||||
);
|
||||
}, (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') {
|
||||
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
|
||||
// Only re-render if these essential props change
|
||||
const valueEqual = prev.value === next.value;
|
||||
const isValidatingEqual = prev.isValidating === next.isValidating;
|
||||
|
||||
// Shallow equality check for errors array
|
||||
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;
|
||||
// Simplified memo comparison - most expensive checks removed
|
||||
// Note: editingCells changes are not checked here as they need immediate re-renders
|
||||
return prev.value === next.value &&
|
||||
prev.isValidating === next.isValidating &&
|
||||
prev.errors === next.errors &&
|
||||
prev.options === next.options;
|
||||
});
|
||||
|
||||
MemoizedCell.displayName = 'MemoizedCell';
|
||||
@@ -185,6 +182,8 @@ const ValidationTable = <T extends string>({
|
||||
itemNumbers,
|
||||
isLoadingTemplates = false,
|
||||
copyDown,
|
||||
editingCells,
|
||||
setEditingCells,
|
||||
rowProductLines = {},
|
||||
rowSublines = {},
|
||||
isLoadingLines = {},
|
||||
@@ -394,28 +393,40 @@ const ValidationTable = <T extends string>({
|
||||
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;
|
||||
|
||||
// Check the validatingCells Set first (for item_number and other fields)
|
||||
const cellLoadingKey = `${row.index}-${fieldKey}`;
|
||||
if (validatingCells.has(cellLoadingKey)) {
|
||||
isLoading = true;
|
||||
}
|
||||
// Check if UPC is validating for this row and field is item_number
|
||||
else if (fieldKey === 'item_number' && isRowValidatingUpc(row.index)) {
|
||||
isLoading = true;
|
||||
}
|
||||
// Add loading state for line/subline fields
|
||||
else if (fieldKey === 'line' && rowId && isLoadingLines[rowId]) {
|
||||
isLoading = true;
|
||||
}
|
||||
else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) {
|
||||
isLoading = true;
|
||||
// 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)
|
||||
const cellLoadingKey = `${row.index}-${fieldKey}`;
|
||||
if (validatingCells.has(cellLoadingKey)) {
|
||||
isLoading = true;
|
||||
}
|
||||
// Check if UPC is validating for this row and field is item_number
|
||||
else if (fieldKey === 'item_number' && isRowValidatingUpc(row.index)) {
|
||||
isLoading = true;
|
||||
}
|
||||
// Add loading state for line/subline fields
|
||||
else if (fieldKey === 'line' && rowId && isLoadingLines[rowId]) {
|
||||
isLoading = true;
|
||||
}
|
||||
else if (fieldKey === 'subline' && rowId && isLoadingSublines[rowId]) {
|
||||
isLoading = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
// This forces a complete re-render when the itemNumber changes
|
||||
// Create stable keys that only change when actual content changes
|
||||
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}`;
|
||||
|
||||
return (
|
||||
<MemoizedCell
|
||||
key={cellKey} // CRITICAL: Add key to force re-render when itemNumber changes
|
||||
field={fieldWithType as Field<string>}
|
||||
value={fieldKey === 'item_number' && row.original[field.key]
|
||||
? row.original[field.key] // Use direct value from row data
|
||||
: row.original[field.key as keyof typeof row.original]}
|
||||
value={currentValue}
|
||||
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
|
||||
errors={cellErrors}
|
||||
isValidating={isLoading}
|
||||
@@ -471,6 +479,8 @@ const ValidationTable = <T extends string>({
|
||||
rowIndex={row.index}
|
||||
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
|
||||
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
|
||||
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
|
||||
const prevSelectionKeys = Object.keys(prev.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 { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -17,19 +17,7 @@ interface InputCellProps<T extends string> {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Add efficient price formatting utility
|
||||
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;
|
||||
};
|
||||
// (removed unused formatPrice helper)
|
||||
|
||||
const InputCell = <T extends string>({
|
||||
field,
|
||||
@@ -45,53 +33,25 @@ const InputCell = <T extends string>({
|
||||
}: InputCellProps<T>) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
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);
|
||||
|
||||
// Remove optimistic updates and rely on parent state
|
||||
|
||||
// Helper function to check if a class is present in the className string
|
||||
const hasClass = (cls: string): boolean => {
|
||||
const classNames = className.split(' ');
|
||||
return classNames.includes(cls);
|
||||
};
|
||||
|
||||
// Initialize localDisplayValue on mount and when value changes externally
|
||||
useEffect(() => {
|
||||
if (localDisplayValue === null ||
|
||||
(typeof value === 'string' && typeof localDisplayValue === 'string' &&
|
||||
value.trim() !== localDisplayValue.trim())) {
|
||||
setLocalDisplayValue(value);
|
||||
}
|
||||
}, [value, localDisplayValue]);
|
||||
// No complex initialization needed
|
||||
|
||||
// Efficiently handle price formatting without multiple rerenders
|
||||
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
|
||||
// Handle focus event
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsEditing(true);
|
||||
|
||||
// For price fields, strip formatting when focusing
|
||||
if (value !== undefined && value !== null) {
|
||||
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, '');
|
||||
setEditValue(numericValue);
|
||||
} else {
|
||||
@@ -104,30 +64,17 @@ const InputCell = <T extends string>({
|
||||
onStartEdit?.();
|
||||
}, [value, onStartEdit, isPrice]);
|
||||
|
||||
// Handle blur event - use transition for non-critical updates
|
||||
// Handle blur event - save to parent only
|
||||
const handleBlur = useCallback(() => {
|
||||
// First - lock in the current edit value to prevent it from being lost
|
||||
const finalValue = editValue.trim();
|
||||
|
||||
// Then transition to non-editing state
|
||||
startTransition(() => {
|
||||
setIsEditing(false);
|
||||
// Save to parent - parent must update immediately for this to work
|
||||
onChange(finalValue);
|
||||
|
||||
// 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?.();
|
||||
});
|
||||
}, [editValue, onChange, onEndEdit, isPrice]);
|
||||
// Exit editing mode
|
||||
setIsEditing(false);
|
||||
onEndEdit?.();
|
||||
}, [editValue, onChange, onEndEdit]);
|
||||
|
||||
// Handle direct input change - optimized to be synchronous for typing
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
@@ -135,30 +82,22 @@ const InputCell = <T extends string>({
|
||||
setEditValue(newValue);
|
||||
}, [isPrice]);
|
||||
|
||||
// Get the display value - prioritize local display value
|
||||
// Get the display value - use parent value directly
|
||||
const displayValue = useMemo(() => {
|
||||
// First priority: local display value (for immediate updates)
|
||||
if (localDisplayValue !== null) {
|
||||
if (isPrice) {
|
||||
// Format price value
|
||||
const numValue = parseFloat(localDisplayValue);
|
||||
return !isNaN(numValue) ? numValue.toFixed(2) : localDisplayValue;
|
||||
}
|
||||
return localDisplayValue;
|
||||
}
|
||||
const currentValue = value ?? '';
|
||||
|
||||
// Second priority: handle price formatting for the actual value
|
||||
if (isPrice && value) {
|
||||
if (typeof value === 'number') {
|
||||
return value.toFixed(2);
|
||||
} else if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) {
|
||||
return parseFloat(value).toFixed(2);
|
||||
// Handle price formatting for display
|
||||
if (isPrice && currentValue !== '' && currentValue !== undefined && currentValue !== null) {
|
||||
if (typeof currentValue === 'number') {
|
||||
return currentValue.toFixed(2);
|
||||
} else if (typeof currentValue === 'string' && /^-?\d+(\.\d+)?$/.test(currentValue)) {
|
||||
return parseFloat(currentValue).toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Default: use the actual value or empty string
|
||||
return value ?? '';
|
||||
}, [isPrice, value, localDisplayValue]);
|
||||
// For non-price or invalid price values, return as-is
|
||||
return String(currentValue);
|
||||
}, [isPrice, value]);
|
||||
|
||||
// Add outline even when not in focus
|
||||
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
|
||||
@@ -221,7 +160,6 @@ const InputCell = <T extends string>({
|
||||
className={cn(
|
||||
outlineClass,
|
||||
hasErrors ? "border-destructive" : "",
|
||||
isPending ? "opacity-50" : "",
|
||||
className
|
||||
)}
|
||||
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) => {
|
||||
if (prev.hasErrors !== next.hasErrors) return false;
|
||||
if (prev.isMultiline !== next.isMultiline) return false;
|
||||
if (prev.isPrice !== next.isPrice) return false;
|
||||
if (prev.disabled !== next.disabled) return false;
|
||||
if (prev.field !== next.field) return false;
|
||||
|
||||
// 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;
|
||||
// Only re-render if essential props change
|
||||
return prev.value === next.value &&
|
||||
prev.hasErrors === next.hasErrors &&
|
||||
prev.disabled === next.disabled &&
|
||||
prev.field === next.field;
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
interface FieldOption {
|
||||
label: string;
|
||||
value: string;
|
||||
hex?: string; // optional hex color for colors field
|
||||
}
|
||||
|
||||
interface MultiSelectCellProps<T extends string> {
|
||||
@@ -237,24 +238,43 @@ const MultiSelectCell = <T extends string>({
|
||||
if (providedOptions && providedOptions.length > 0) {
|
||||
// Check if options are already in the right format
|
||||
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),
|
||||
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
|
||||
if (fieldOptions.length > 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),
|
||||
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)}
|
||||
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) && (
|
||||
<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
|
||||
onChange(valueToCommit);
|
||||
|
||||
// 6. Clear processing state after a short delay
|
||||
// 6. Clear processing state after a short delay - reduced for responsiveness
|
||||
setTimeout(() => {
|
||||
setIsProcessing(false);
|
||||
}, 200);
|
||||
}, 50);
|
||||
}, [onChange, onEndEdit]);
|
||||
|
||||
// If disabled, render a static view
|
||||
|
||||
@@ -296,10 +296,24 @@ export const useAiValidation = <T extends string>(
|
||||
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 { __index, ...rest } = item;
|
||||
return rest;
|
||||
const { __index, ...rest } = item as any;
|
||||
// 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:', {
|
||||
@@ -421,10 +435,21 @@ export const useAiValidation = <T extends string>(
|
||||
});
|
||||
}, 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 { __index, ...rest } = item;
|
||||
return rest;
|
||||
const { __index, ...rest } = item as any;
|
||||
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);
|
||||
|
||||
@@ -7,14 +7,24 @@ import { RowData, isEmpty } from './validationTypes';
|
||||
// Create a cache for validation results to avoid repeated validation of the same data
|
||||
const validationResultCache = new Map();
|
||||
|
||||
// Add a function to clear cache for a specific field value
|
||||
export const clearValidationCacheForField = (fieldKey: string) => {
|
||||
// Look for entries that match this field key
|
||||
validationResultCache.forEach((_, key) => {
|
||||
if (key.startsWith(`${fieldKey}-`)) {
|
||||
validationResultCache.delete(key);
|
||||
}
|
||||
});
|
||||
// Optimize cache clearing - only clear when necessary
|
||||
export const clearValidationCacheForField = (fieldKey: string, specificValue?: any) => {
|
||||
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) => {
|
||||
if (key.startsWith(`${fieldKey}-`)) {
|
||||
validationResultCache.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
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) => {
|
||||
// Filter by search text
|
||||
if (filters.searchText) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { RowData } from './validationTypes';
|
||||
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>(
|
||||
data: RowData<T>[],
|
||||
@@ -10,6 +12,93 @@ export const useRowOperations = <T extends string>(
|
||||
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, 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
|
||||
const fieldValidationHelper = useCallback(
|
||||
(rowIndex: number, specificField?: string) => {
|
||||
@@ -27,7 +116,7 @@ export const useRowOperations = <T extends string>(
|
||||
|
||||
// Use state setter instead of direct mutation
|
||||
setValidationErrors((prev) => {
|
||||
const newErrors = new Map(prev);
|
||||
let newErrors = new Map(prev);
|
||||
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
|
||||
|
||||
// Quick check for required fields - this prevents flashing errors
|
||||
@@ -73,6 +162,12 @@ export const useRowOperations = <T extends string>(
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -155,7 +250,8 @@ export const useRowOperations = <T extends string>(
|
||||
// CRITICAL FIX: Combine both validation operations into a single state update
|
||||
// to prevent intermediate rendering that causes error icon flashing
|
||||
setValidationErrors((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
// Start with previous errors
|
||||
let newMap = new Map(prev);
|
||||
const existingErrors = newMap.get(rowIndex) || {};
|
||||
const newRowErrors = { ...existingErrors };
|
||||
|
||||
@@ -203,8 +299,8 @@ export const useRowOperations = <T extends string>(
|
||||
// Update with new validation results
|
||||
if (errors.length > 0) {
|
||||
newRowErrors[key as string] = errors;
|
||||
} else if (!newRowErrors[key as string]) {
|
||||
// If no errors found and no existing errors, ensure field is removed from errors
|
||||
} else {
|
||||
// Clear any existing errors for this field
|
||||
delete newRowErrors[key as string];
|
||||
}
|
||||
|
||||
@@ -215,6 +311,24 @@ export const useRowOperations = <T extends string>(
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -255,9 +369,9 @@ export const useRowOperations = <T extends string>(
|
||||
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
|
||||
@@ -268,7 +382,10 @@ export const useRowOperations = <T extends string>(
|
||||
) => {
|
||||
// Process all specified rows using a single state update to avoid race conditions
|
||||
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
|
||||
for (const rowIndex of rowIndexes) {
|
||||
@@ -300,6 +417,11 @@ export const useRowOperations = <T extends string>(
|
||||
} else {
|
||||
delete existingRowErrors[fieldKey];
|
||||
}
|
||||
|
||||
// If field is uniqueness-constrained, mark for uniqueness pass
|
||||
if (uniquenessFieldKeys.has(fieldKey)) {
|
||||
uniqueFieldsToCheck.add(fieldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the row's errors
|
||||
@@ -324,6 +446,11 @@ export const useRowOperations = <T extends string>(
|
||||
if (errors.length > 0) {
|
||||
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
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
[data, fields, validateFieldFromHook]
|
||||
[data, fields, validateFieldFromHook, mergeUniqueErrorsForFields, uniquenessFieldKeys]
|
||||
);
|
||||
|
||||
// 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
|
||||
const validateUniqueItemNumbers = useCallback(async () => {
|
||||
console.log("Validating unique fields");
|
||||
|
||||
// Skip if no data
|
||||
if (!data.length) return;
|
||||
|
||||
@@ -23,11 +21,6 @@ export const useUniqueItemNumbersValidation = <T extends string>(
|
||||
.filter((field) => field.validations?.some((v) => v.rule === "unique"))
|
||||
.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
|
||||
if (!uniqueFields.includes("item_number")) {
|
||||
uniqueFields.push("item_number");
|
||||
@@ -41,32 +34,44 @@ export const useUniqueItemNumbersValidation = <T extends string>(
|
||||
// Initialize batch updates
|
||||
const errors = new Map<number, Record<string, ValidationError[]>>();
|
||||
|
||||
// Single pass through data to identify all unique values
|
||||
data.forEach((row, index) => {
|
||||
uniqueFields.forEach((fieldKey) => {
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
// ASYNC: Single pass through data to identify all unique values in batches
|
||||
const BATCH_SIZE = 20;
|
||||
for (let batchStart = 0; batchStart < data.length; batchStart += BATCH_SIZE) {
|
||||
const batchEnd = Math.min(batchStart + BATCH_SIZE, data.length);
|
||||
|
||||
// Skip empty values
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return;
|
||||
}
|
||||
for (let index = batchStart; index < batchEnd; index++) {
|
||||
const row = data[index];
|
||||
uniqueFields.forEach((fieldKey) => {
|
||||
const value = row[fieldKey as keyof typeof row];
|
||||
|
||||
const valueStr = String(value);
|
||||
const fieldMap = uniqueFieldsMap.get(fieldKey);
|
||||
// Skip empty values
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fieldMap) {
|
||||
// Get or initialize the array of indices for this value
|
||||
const indices = fieldMap.get(valueStr) || [];
|
||||
indices.push(index);
|
||||
fieldMap.set(valueStr, indices);
|
||||
}
|
||||
});
|
||||
});
|
||||
const valueStr = String(value);
|
||||
const fieldMap = uniqueFieldsMap.get(fieldKey);
|
||||
|
||||
// Process duplicates
|
||||
uniqueFields.forEach((fieldKey) => {
|
||||
if (fieldMap) {
|
||||
// Get or initialize the array of indices for this value
|
||||
const indices = fieldMap.get(valueStr) || [];
|
||||
indices.push(index);
|
||||
fieldMap.set(valueStr, indices);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Yield control back to UI thread after each batch
|
||||
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);
|
||||
if (!fieldMap) return;
|
||||
if (!fieldMap) continue;
|
||||
|
||||
fieldMap.forEach((indices, value) => {
|
||||
// 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
|
||||
if (errors.size > 0) {
|
||||
// OPTIMIZATION: Check if we actually have new errors before updating state
|
||||
let hasChanges = false;
|
||||
processedFields++;
|
||||
// Yield control after every few fields to prevent UI blocking
|
||||
if (processedFields % 2 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// We'll update errors with a single batch operation
|
||||
setValidationErrors((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
// Merge uniqueness errors with existing validation errors
|
||||
setValidationErrors((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
|
||||
// Check each row for changes
|
||||
errors.forEach((rowErrors, rowIndex) => {
|
||||
const existingErrors = newMap.get(rowIndex) || {};
|
||||
const updatedErrors = { ...existingErrors };
|
||||
let rowHasChanges = false;
|
||||
// Add uniqueness errors
|
||||
errors.forEach((rowErrors, rowIndex) => {
|
||||
const existingErrors = newMap.get(rowIndex) || {};
|
||||
const updatedErrors = { ...existingErrors };
|
||||
|
||||
// Check each field for changes
|
||||
// Add uniqueness errors to existing errors
|
||||
Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => {
|
||||
updatedErrors[fieldKey] = fieldErrors;
|
||||
});
|
||||
|
||||
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]) => {
|
||||
// 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;
|
||||
rowHasChanges = true;
|
||||
hasChanges = true;
|
||||
// Keep non-uniqueness errors
|
||||
const nonUniqueErrors = fieldErrors.filter(error => error.type !== ErrorType.Unique);
|
||||
if (nonUniqueErrors.length > 0) {
|
||||
cleanedErrors[fieldKey] = nonUniqueErrors;
|
||||
}
|
||||
});
|
||||
|
||||
// Only update if we have changes
|
||||
if (rowHasChanges) {
|
||||
newMap.set(rowIndex, updatedErrors);
|
||||
// Update the row or remove it if no errors remain
|
||||
if (Object.keys(cleanedErrors).length > 0) {
|
||||
newMap.set(rowIndex, cleanedErrors);
|
||||
} else {
|
||||
newMap.delete(rowIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Only return a new map if we have changes
|
||||
return hasChanges ? newMap : prev;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
|
||||
console.log("Uniqueness validation complete");
|
||||
}, [data, fields, setValidationErrors]);
|
||||
|
||||
@@ -13,6 +13,40 @@ import { useUniqueItemNumbersValidation } from "./useUniqueItemNumbersValidation
|
||||
import { useUpcValidation } from "./useUpcValidation";
|
||||
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>({
|
||||
initialData,
|
||||
onBack,
|
||||
@@ -20,8 +54,8 @@ export const useValidationState = <T extends string>({
|
||||
}: Props<T>) => {
|
||||
const { fields, rowHook, tableHook } = useRsi<T>();
|
||||
|
||||
// Import validateField from useValidation
|
||||
const { validateField: validateFieldFromHook } = useValidation<T>(
|
||||
// Import validateField and validateUniqueField from useValidation
|
||||
const { validateField: validateFieldFromHook, validateUniqueField } = useValidation<T>(
|
||||
fields,
|
||||
rowHook
|
||||
);
|
||||
@@ -71,10 +105,23 @@ export const useValidationState = <T extends string>({
|
||||
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>;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Row selection state
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
@@ -90,8 +137,14 @@ export const useValidationState = <T extends string>({
|
||||
// Add state for tracking cells in loading state
|
||||
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 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
|
||||
const { validateRow, updateRow, revalidateRows, copyDown } = useRowOperations<T>(
|
||||
@@ -128,111 +181,13 @@ export const useValidationState = <T extends string>({
|
||||
// Use filter management hook
|
||||
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(() => {
|
||||
// Skip initial load - we have a separate initialization process
|
||||
if (!initialValidationDoneRef.current) return;
|
||||
|
||||
// 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]);
|
||||
return; // no-op
|
||||
}, [data, fields, hasEditingCells]);
|
||||
|
||||
// Add field options query
|
||||
const { data: fieldOptionsData } = useQuery({
|
||||
@@ -348,11 +303,12 @@ export const useValidationState = <T extends string>({
|
||||
[data, onBack, onNext, validationErrors]
|
||||
);
|
||||
|
||||
// Initialize validation on mount
|
||||
// Initialize validation once, after initial UPC-based item number generation completes
|
||||
useEffect(() => {
|
||||
if (initialValidationDoneRef.current) return;
|
||||
|
||||
console.log("Running initial validation");
|
||||
// Wait for initial UPC validation to finish to avoid double work and ensure
|
||||
// item_number values are in place before uniqueness checks
|
||||
if (!upcValidation.initialValidationDone) return;
|
||||
|
||||
const runCompleteValidation = async () => {
|
||||
if (!data || data.length === 0) return;
|
||||
@@ -379,8 +335,8 @@ export const useValidationState = <T extends string>({
|
||||
`Found ${uniqueFields.length} fields requiring uniqueness validation`
|
||||
);
|
||||
|
||||
// Limit batch size to avoid UI freezing
|
||||
const BATCH_SIZE = 100;
|
||||
// Dynamic batch size based on dataset size
|
||||
const BATCH_SIZE = data.length <= 50 ? data.length : 25; // Process all at once for small datasets
|
||||
const totalRows = data.length;
|
||||
|
||||
// Initialize new data for any modifications
|
||||
@@ -559,9 +515,9 @@ export const useValidationState = <T extends string>({
|
||||
currentBatch = batch;
|
||||
await processBatch();
|
||||
|
||||
// Yield to UI thread periodically
|
||||
if (batch % 2 === 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// Yield to UI thread more frequently for large datasets
|
||||
if (batch % 2 === 1 || totalRows > 500) {
|
||||
await new Promise((resolve) => setTimeout(resolve, totalRows > 1000 ? 10 : 5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,7 +547,73 @@ export const useValidationState = <T extends string>({
|
||||
|
||||
// Run the complete validation
|
||||
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
|
||||
const fieldsWithOptions = useMemo(() => {
|
||||
@@ -680,6 +702,10 @@ export const useValidationState = <T extends string>({
|
||||
validatingCells,
|
||||
setValidatingCells,
|
||||
|
||||
// PERFORMANCE FIX: Export editing state management
|
||||
editingCells,
|
||||
setEditingCells,
|
||||
|
||||
// Row selection
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Info } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
interface Permission {
|
||||
id: number;
|
||||
@@ -21,13 +22,15 @@ interface PermissionSelectorProps {
|
||||
selectedPermissions: number[];
|
||||
onChange: (selectedPermissions: number[]) => void;
|
||||
disabled?: boolean;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function PermissionSelector({
|
||||
permissionsByCategory,
|
||||
selectedPermissions,
|
||||
onChange,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
isAdmin = false
|
||||
}: PermissionSelectorProps) {
|
||||
// Handle permission checkbox change
|
||||
const handlePermissionChange = (permissionId: number) => {
|
||||
@@ -68,13 +71,17 @@ export function PermissionSelector({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Permissions</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Select the permissions you want to grant to this user
|
||||
</p>
|
||||
{isAdmin && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Administrators have access to all permissions by default. Individual permissions cannot be edited for admin users.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{permissionsByCategory.map(category => (
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import config from "@/config";
|
||||
|
||||
interface Permission {
|
||||
id: number;
|
||||
@@ -25,12 +28,22 @@ interface Permission {
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface RocketChatUser {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
type: string;
|
||||
active: boolean;
|
||||
mongo_id?: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
rocket_chat_user_id?: string;
|
||||
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("")),
|
||||
is_admin: z.boolean().default(false),
|
||||
is_active: z.boolean().default(true),
|
||||
rocket_chat_user_id: z.string().default("none"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof userFormSchema>;
|
||||
@@ -80,12 +94,15 @@ interface UserSaveData {
|
||||
password?: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
rocket_chat_user_id?: string;
|
||||
permissions: Permission[];
|
||||
}
|
||||
|
||||
export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps) {
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [rocketChatUsers, setRocketChatUsers] = useState<RocketChatUser[]>([]);
|
||||
const [loadingRocketChatUsers, setLoadingRocketChatUsers] = useState(true);
|
||||
|
||||
// Initialize the form with React Hook Form
|
||||
const form = useForm<FormValues>({
|
||||
@@ -96,29 +113,78 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
console.log("User permissions:", user?.permissions);
|
||||
|
||||
if (user?.permissions && Array.isArray(user.permissions) && user.permissions.length > 0) {
|
||||
// Extract IDs from the permissions
|
||||
const permissionIds = user.permissions.map(p => p.id);
|
||||
console.log("Setting selected permissions:", permissionIds);
|
||||
|
||||
setSelectedPermissions(permissionIds);
|
||||
} else {
|
||||
console.log("No permissions found or empty permissions array");
|
||||
|
||||
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
|
||||
const onSubmit = (data: FormValues) => {
|
||||
try {
|
||||
setFormError(null);
|
||||
console.log("Form submitted with permissions:", selectedPermissions);
|
||||
|
||||
|
||||
// Validate
|
||||
if (!user && !data.password) {
|
||||
@@ -130,6 +196,7 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
||||
const userData: UserSaveData = {
|
||||
...data,
|
||||
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
|
||||
};
|
||||
|
||||
@@ -161,7 +228,7 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
||||
userData.permissions = [];
|
||||
}
|
||||
|
||||
console.log("Saving user data:", userData);
|
||||
|
||||
onSave(userData);
|
||||
} catch (error) {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{formError && (
|
||||
@@ -192,57 +250,109 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Basic Information Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{user ? "New Password" : "Password"}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
placeholder={user ? "Leave blank to keep current password" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
{user && (
|
||||
<FormDescription>
|
||||
Leave blank to keep the current password
|
||||
</FormDescription>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{user ? "New Password" : "Password"}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
placeholder={user ? "Leave blank to keep current password" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
<FormMessage />
|
||||
</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">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -287,41 +397,25 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
||||
/>
|
||||
</div>
|
||||
|
||||
{permissions && permissions.length > 0 && (
|
||||
<>
|
||||
{form.watch("is_admin") ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Permissions</h3>
|
||||
<Alert>
|
||||
{/* Permissions Section */}
|
||||
{permissions && permissions.length > 0 && (
|
||||
<>
|
||||
<PermissionSelector
|
||||
permissionsByCategory={permissions}
|
||||
selectedPermissions={form.watch("is_admin") ? getAllPermissionIds(permissions) : selectedPermissions}
|
||||
onChange={setSelectedPermissions}
|
||||
disabled={form.watch("is_admin")}
|
||||
isAdmin={form.watch("is_admin")}
|
||||
/>
|
||||
{!form.watch("is_admin") && selectedPermissions.length === 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Administrators have access to all permissions by default. Individual permissions cannot be edited for admin users.
|
||||
Warning: This user has no permissions selected. They won't be able to access anything.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<PermissionSelector
|
||||
permissionsByCategory={permissions}
|
||||
selectedPermissions={getAllPermissionIds(permissions)}
|
||||
onChange={() => {}}
|
||||
disabled={true}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PermissionSelector
|
||||
permissionsByCategory={permissions}
|
||||
selectedPermissions={selectedPermissions}
|
||||
onChange={setSelectedPermissions}
|
||||
/>
|
||||
{selectedPermissions.length === 0 && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Warning: This user has no permissions selected. They won't be able to access anything.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
const config = {
|
||||
apiUrl: isDev ? '/api' : 'https://inventory.kent.pw/api',
|
||||
baseUrl: isDev ? '' : 'https://inventory.kent.pw',
|
||||
authUrl: isDev ? '/auth-inv' : 'https://inventory.kent.pw/auth-inv',
|
||||
chatUrl: isDev ? '/chat-api' : 'https://inventory.kent.pw/chat-api'
|
||||
apiUrl: '/api',
|
||||
baseUrl: '',
|
||||
authUrl: '/auth-inv',
|
||||
chatUrl: '/chat-api'
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,15 +1,19 @@
|
||||
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 = {
|
||||
auth: isDev ? '/dashboard-auth' : 'https://dashboard.kent.pw/auth',
|
||||
aircall: isDev ? '/api/aircall' : 'https://dashboard.kent.pw/api/aircall',
|
||||
klaviyo: isDev ? '/api/klaviyo' : 'https://dashboard.kent.pw/api/klaviyo',
|
||||
meta: isDev ? '/api/meta' : 'https://dashboard.kent.pw/api/meta',
|
||||
gorgias: isDev ? '/api/gorgias' : 'https://dashboard.kent.pw/api/gorgias',
|
||||
analytics: isDev ? '/api/dashboard-analytics' : 'https://dashboard.kent.pw/api/analytics',
|
||||
typeform: isDev ? '/api/typeform' : 'https://dashboard.kent.pw/api/typeform',
|
||||
acot: isDev ? '/api/acot' : 'https://dashboard.kent.pw/api/acot',
|
||||
clarity: isDev ? '/api/clarity' : 'https://dashboard.kent.pw/api/clarity'
|
||||
auth: isDev || useProxy ? '/dashboard-auth' : 'https://dashboard.kent.pw/auth',
|
||||
aircall: isDev || useProxy ? '/api/aircall' : 'https://dashboard.kent.pw/api/aircall',
|
||||
klaviyo: isDev || useProxy ? '/api/klaviyo' : 'https://dashboard.kent.pw/api/klaviyo',
|
||||
meta: isDev || useProxy ? '/api/meta' : 'https://dashboard.kent.pw/api/meta',
|
||||
gorgias: isDev || useProxy ? '/api/gorgias' : 'https://dashboard.kent.pw/api/gorgias',
|
||||
analytics: isDev || useProxy ? '/api/dashboard-analytics' : 'https://dashboard.kent.pw/api/analytics',
|
||||
typeform: isDev || useProxy ? '/api/typeform' : 'https://dashboard.kent.pw/api/typeform',
|
||||
acot: isDev || useProxy ? '/api/acot' : 'https://dashboard.kent.pw/api/acot',
|
||||
clarity: isDev || useProxy ? '/api/clarity' : 'https://dashboard.kent.pw/api/clarity'
|
||||
};
|
||||
|
||||
export default liveDashboardConfig;
|
||||
@@ -14,6 +14,7 @@ export interface User {
|
||||
username: string;
|
||||
email?: string;
|
||||
is_admin: boolean;
|
||||
rocket_chat_user_id?: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@@ -66,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const userData = await response.json();
|
||||
console.log("Fetched current user data:", userData);
|
||||
console.log("User permissions:", userData.permissions);
|
||||
console.log("User rocket_chat_user_id:", userData.rocket_chat_user_id);
|
||||
|
||||
setUser(userData);
|
||||
// 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -8,6 +8,7 @@ import { Loader2, Search } from 'lucide-react';
|
||||
import { RoomList } from '@/components/chat/RoomList';
|
||||
import { ChatRoom } from '@/components/chat/ChatRoom';
|
||||
import { SearchResults } from '@/components/chat/SearchResults';
|
||||
import { AuthContext } from '@/contexts/AuthContext';
|
||||
import config from '@/config';
|
||||
|
||||
interface User {
|
||||
@@ -39,11 +40,13 @@ interface SearchResult {
|
||||
}
|
||||
|
||||
export function Chat() {
|
||||
const { user: currentUser } = useContext(AuthContext);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userRocketChatId, setUserRocketChatId] = useState<string | null>(null);
|
||||
|
||||
// Global search state
|
||||
const [globalSearchQuery, setGlobalSearchQuery] = useState('');
|
||||
@@ -51,6 +54,12 @@ export function Chat() {
|
||||
const [showSearchResults, setShowSearchResults] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
setUserRocketChatId(currentUser.rocket_chat_user_id || null);
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
@@ -59,6 +68,25 @@ export function Chat() {
|
||||
|
||||
if (data.status === 'success') {
|
||||
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 {
|
||||
throw new Error(data.error || 'Failed to fetch users');
|
||||
}
|
||||
@@ -70,14 +98,19 @@ export function Chat() {
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
}, []);
|
||||
if (currentUser) {
|
||||
fetchUsers();
|
||||
}
|
||||
}, [currentUser, userRocketChatId]);
|
||||
|
||||
const handleUserChange = (userId: string) => {
|
||||
setSelectedUserId(userId);
|
||||
setSelectedRoomId(null); // Reset room selection when user changes
|
||||
setGlobalSearchQuery(''); // Clear search when user changes
|
||||
setShowSearchResults(false);
|
||||
// Only allow admins to change users, or if the user is selecting their own connected account
|
||||
if (currentUser?.is_admin || userId === userRocketChatId) {
|
||||
setSelectedUserId(userId);
|
||||
setSelectedRoomId(null); // Reset room selection when user changes
|
||||
setGlobalSearchQuery(''); // Clear search when user changes
|
||||
setShowSearchResults(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoomSelect = (roomId: string) => {
|
||||
@@ -181,32 +214,66 @@ export function Chat() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select value={selectedUserId} onValueChange={handleUserChange}>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder="View as user..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage
|
||||
src={user.mongo_id ? `${config.chatUrl}/avatar/${user.mongo_id}` : undefined}
|
||||
alt={user.name || user.username}
|
||||
/>
|
||||
<AvatarFallback className="text-xs">
|
||||
{(user.name || user.username).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className={user.active ? '' : 'text-muted-foreground'}>
|
||||
{user.name || user.username}
|
||||
{!user.active && <span className="text-xs ml-1">(inactive)</span>}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentUser?.is_admin ? (
|
||||
<Select value={selectedUserId} onValueChange={handleUserChange}>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder="View as user..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage
|
||||
src={user.mongo_id ? `${config.chatUrl}/avatar/${user.mongo_id}` : undefined}
|
||||
alt={user.name || user.username}
|
||||
/>
|
||||
<AvatarFallback className="text-xs">
|
||||
{(user.name || user.username).charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className={user.active ? '' : 'text-muted-foreground'}>
|
||||
{user.name || user.username}
|
||||
{!user.active && <span className="text-xs ml-1">(inactive)</span>}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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>
|
||||
|
||||
@@ -233,9 +300,20 @@ export function Chat() {
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">
|
||||
Select a user to view their chat rooms and messages.
|
||||
</p>
|
||||
{currentUser?.is_admin ? (
|
||||
<p className="text-muted-foreground">
|
||||
Select a user to view their chat rooms and messages.
|
||||
</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>
|
||||
</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 { 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 {
|
||||
flexRender,
|
||||
@@ -16,16 +16,55 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
import { columns, ForecastItem, renderSubComponent } from "@/components/forecasting/columns";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { addDays } from "date-fns";
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker";
|
||||
import { addDays, addMonths } from "date-fns";
|
||||
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() {
|
||||
const [selectedBrand, setSelectedBrand] = useState<string>("");
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
from: addDays(new Date(), -30),
|
||||
from: addDays(addMonths(new Date(), -1), 1),
|
||||
to: new Date(),
|
||||
});
|
||||
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) => {
|
||||
if (range) {
|
||||
@@ -61,21 +100,16 @@ export default function Forecasting() {
|
||||
return data.map((item: any) => ({
|
||||
category: item.category_name,
|
||||
categoryPath: item.path,
|
||||
avgDailySales: Number(item.avg_daily_sales) || 0,
|
||||
totalSold: Number(item.total_sold) || 0,
|
||||
numProducts: Number(item.num_products) || 0,
|
||||
avgPrice: Number(item.avg_price) || 0,
|
||||
avgTotalSold: Number(item.avgTotalSold) || 0,
|
||||
minSold: Number(item.minSold) || 0,
|
||||
maxSold: Number(item.maxSold) || 0,
|
||||
products: item.products?.map((p: any) => ({
|
||||
pid: p.pid,
|
||||
title: p.title,
|
||||
sku: p.sku,
|
||||
stock_quantity: Number(p.stock_quantity) || 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
|
||||
}))
|
||||
}));
|
||||
@@ -83,8 +117,60 @@ export default function Forecasting() {
|
||||
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({
|
||||
data: forecastData || [],
|
||||
data: displayData || [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
@@ -97,13 +183,13 @@ export default function Forecasting() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10">
|
||||
<div className="container mx-auto py-10 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Forecasting</CardTitle>
|
||||
<CardTitle>Historical Sales</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="flex gap-4 mb-6 items-center">
|
||||
<div className="w-[200px]">
|
||||
<Select value={selectedBrand} onValueChange={setSelectedBrand}>
|
||||
<SelectTrigger disabled={brandsLoading}>
|
||||
@@ -118,15 +204,36 @@ export default function Forecasting() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DateRangePicker
|
||||
<DateRangePickerQuick
|
||||
value={dateRange}
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
||||
{forecastLoading ? (
|
||||
<div className="h-24 flex items-center justify-center">
|
||||
Loading forecast data...
|
||||
Loading sales data...
|
||||
</div>
|
||||
) : forecastData && (
|
||||
<div className="rounded-md border">
|
||||
@@ -153,6 +260,7 @@ export default function Forecasting() {
|
||||
<Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={String(row.original.category || '').startsWith('Matches:') ? 'bg-muted font-medium' : ''}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
@@ -185,6 +293,17 @@ export default function Forecasting() {
|
||||
)}
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ const BASE_IMPORT_FIELDS = [
|
||||
label: "Cost Each",
|
||||
key: "cost_each",
|
||||
description: "Wholesale cost per unit",
|
||||
alternateMatches: ["wholesale", "wholesale price", "supplier cost each"],
|
||||
alternateMatches: ["wholesale", "wholesale price", "supplier cost each", "cost each"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
price: true
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user