16 Commits

39 changed files with 2731 additions and 893 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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
});

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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": [
{

View File

@@ -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",

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}
/>
)
}

View File

@@ -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>
)}

View File

@@ -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">

View File

@@ -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);

View File

@@ -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;
});

View File

@@ -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" />
)}

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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]);

View File

@@ -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,

View File

@@ -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"

View File

@@ -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}>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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>
)}

View File

@@ -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>
);
}

View File

@@ -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