Compare commits
23 Commits
7d46ebd6ba
...
add-permis
| Author | SHA1 | Date | |
|---|---|---|---|
| d60a8cbc6e | |||
| 1fcbf54989 | |||
| ce75496770 | |||
| 7eae4a0b29 | |||
| f421154c1d | |||
| 03dc119a15 | |||
| 1963bee00c | |||
| 387e7e5e73 | |||
| a51a48ce89 | |||
| aacb3a2fd0 | |||
| 35d2f0df7c | |||
| 675a0fc374 | |||
| ca2653ea1a | |||
| a8d3fd8033 | |||
| 702b956ff1 | |||
| 9b8577f258 | |||
| 9623681a15 | |||
| cc22fd8c35 | |||
| 0ef1b6100e | |||
| a519746ccb | |||
| f29dd8ef8b | |||
| f2a5c06005 | |||
| fb9f959fe5 |
172
docs/PERMISSIONS.md
Normal file
172
docs/PERMISSIONS.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# Permission System Documentation
|
||||||
|
|
||||||
|
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}`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `access:products` - Can access the Products page
|
||||||
|
- `create:products` - Can create new products
|
||||||
|
- `edit:users` - Can edit user accounts
|
||||||
|
|
||||||
|
## Permission Components
|
||||||
|
|
||||||
|
### PermissionGuard
|
||||||
|
|
||||||
|
The core component that conditionally renders content based on permissions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PermissionGuard
|
||||||
|
permission="create:products"
|
||||||
|
fallback={<p>No permission</p>}
|
||||||
|
>
|
||||||
|
<button>Create Product</button>
|
||||||
|
</PermissionGuard>
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `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
|
||||||
|
|
||||||
|
### PermissionProtectedRoute
|
||||||
|
|
||||||
|
Protects entire pages based on page access permissions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Route path="/products" element={
|
||||||
|
<PermissionProtectedRoute page="products">
|
||||||
|
<Products />
|
||||||
|
</PermissionProtectedRoute>
|
||||||
|
} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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="edit:system_settings"
|
||||||
|
>
|
||||||
|
{/* Settings content */}
|
||||||
|
</SettingsSection>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permission Hooks
|
||||||
|
|
||||||
|
### usePermissions
|
||||||
|
|
||||||
|
Core hook for checking any permission.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { hasPermission, hasPageAccess, isAdmin } = usePermissions();
|
||||||
|
if (hasPermission('delete:products')) {
|
||||||
|
// Can delete products
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### usePagePermission
|
||||||
|
|
||||||
|
Specialized hook for page-level permissions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { canView, canCreate, canEdit, canDelete } = usePagePermission('products');
|
||||||
|
if (canEdit()) {
|
||||||
|
// Can edit 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.
|
||||||
|
|
||||||
|
## Common Permission Codes
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `access:dashboard` | Access to Dashboard 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 |
|
||||||
|
|
||||||
|
## Implementation Examples
|
||||||
|
|
||||||
|
### Page Protection
|
||||||
|
|
||||||
|
In `App.tsx`:
|
||||||
|
```tsx
|
||||||
|
<Route path="/products" element={
|
||||||
|
<PermissionProtectedRoute page="products">
|
||||||
|
<Products />
|
||||||
|
</PermissionProtectedRoute>
|
||||||
|
} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Level Protection
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { canEdit } = usePagePermission('products');
|
||||||
|
|
||||||
|
function handleEdit() {
|
||||||
|
if (!canEdit()) {
|
||||||
|
toast.error("You don't have permission");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Edit logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI Element Protection
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PermissionButton
|
||||||
|
page="products"
|
||||||
|
action="delete"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</PermissionButton>
|
||||||
|
```
|
||||||
128
inventory-server/auth/permissions.js
Normal file
128
inventory-server/auth/permissions.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// Get pool from global or create a new one if not available
|
||||||
|
let pool;
|
||||||
|
if (typeof global.pool !== 'undefined') {
|
||||||
|
pool = global.pool;
|
||||||
|
} else {
|
||||||
|
// If global pool is not available, create a new connection
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
pool = new Pool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
port: process.env.DB_PORT,
|
||||||
|
});
|
||||||
|
console.log('Created new database pool in permissions.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user has a specific permission
|
||||||
|
* @param {number} userId - The user ID to check
|
||||||
|
* @param {string} permissionCode - The permission code to check
|
||||||
|
* @returns {Promise<boolean>} - Whether the user has the permission
|
||||||
|
*/
|
||||||
|
async function checkPermission(userId, permissionCode) {
|
||||||
|
try {
|
||||||
|
// First check if the user is an admin
|
||||||
|
const adminResult = await pool.query(
|
||||||
|
'SELECT is_admin FROM users WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// If user is admin, automatically grant permission
|
||||||
|
if (adminResult.rows.length > 0 && adminResult.rows[0].is_admin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise check for specific permission
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS has_permission
|
||||||
|
FROM user_permissions up
|
||||||
|
JOIN permissions p ON up.permission_id = p.id
|
||||||
|
WHERE up.user_id = $1 AND p.code = $2`,
|
||||||
|
[userId, permissionCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0].has_permission > 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking permission:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to require a specific permission
|
||||||
|
* @param {string} permissionCode - The permission code required
|
||||||
|
* @returns {Function} - Express middleware function
|
||||||
|
*/
|
||||||
|
function requirePermission(permissionCode) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Check if user is authenticated
|
||||||
|
if (!req.user || !req.user.id) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = await checkPermission(req.user.id, permissionCode);
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Insufficient permissions',
|
||||||
|
requiredPermission: permissionCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Permission middleware error:', error);
|
||||||
|
res.status(500).json({ error: 'Server error checking permissions' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all permissions for a user
|
||||||
|
* @param {number} userId - The user ID
|
||||||
|
* @returns {Promise<string[]>} - Array of permission codes
|
||||||
|
*/
|
||||||
|
async function getUserPermissions(userId) {
|
||||||
|
try {
|
||||||
|
// Check if user is admin
|
||||||
|
const adminResult = await pool.query(
|
||||||
|
'SELECT is_admin FROM users WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (adminResult.rows.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = adminResult.rows[0].is_admin;
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
// Admin gets all permissions
|
||||||
|
const allPermissions = await pool.query('SELECT code FROM permissions');
|
||||||
|
return allPermissions.rows.map(p => p.code);
|
||||||
|
} else {
|
||||||
|
// Get assigned permissions
|
||||||
|
const permissions = await pool.query(
|
||||||
|
`SELECT p.code
|
||||||
|
FROM permissions p
|
||||||
|
JOIN user_permissions up ON p.id = up.permission_id
|
||||||
|
WHERE up.user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return permissions.rows.map(p => p.code);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting user permissions:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkPermission,
|
||||||
|
requirePermission,
|
||||||
|
getUserPermissions
|
||||||
|
};
|
||||||
513
inventory-server/auth/routes.js
Normal file
513
inventory-server/auth/routes.js
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { requirePermission, getUserPermissions } = require('./permissions');
|
||||||
|
|
||||||
|
// Get pool from global or create a new one if not available
|
||||||
|
let pool;
|
||||||
|
if (typeof global.pool !== 'undefined') {
|
||||||
|
pool = global.pool;
|
||||||
|
} else {
|
||||||
|
// If global pool is not available, create a new connection
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
pool = new Pool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
port: process.env.DB_PORT,
|
||||||
|
});
|
||||||
|
console.log('Created new database pool in routes.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication middleware
|
||||||
|
const authenticate = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT id, username, is_admin FROM users WHERE id = $1',
|
||||||
|
[decoded.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(401).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach user to request
|
||||||
|
req.user = result.rows[0];
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Authentication error:', error);
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Login route
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT id, username, password, is_admin, is_active FROM users WHERE username = $1',
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.rows[0];
|
||||||
|
|
||||||
|
// Check if user is active
|
||||||
|
if (!user.is_active) {
|
||||||
|
return res.status(403).json({ error: 'Account is inactive' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const validPassword = await bcrypt.compare(password, user.password);
|
||||||
|
if (!validPassword) {
|
||||||
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1',
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ userId: user.id, username: user.username },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: '8h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get user permissions
|
||||||
|
const permissions = await getUserPermissions(user.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
is_admin: user.is_admin,
|
||||||
|
permissions
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
router.get('/me', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get user permissions
|
||||||
|
const permissions = await getUserPermissions(req.user.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
is_admin: req.user.is_admin,
|
||||||
|
permissions
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting current user:', error);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all users
|
||||||
|
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
|
||||||
|
FROM users
|
||||||
|
ORDER BY username
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting users:', error);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user with permissions
|
||||||
|
router.get('/users/:id', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.params.id;
|
||||||
|
|
||||||
|
// Get user details
|
||||||
|
const userResult = await pool.query(`
|
||||||
|
SELECT id, username, email, is_admin, is_active, created_at, last_login
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
if (userResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user permissions
|
||||||
|
const permissionsResult = await pool.query(`
|
||||||
|
SELECT p.id, p.name, p.code, p.category, p.description
|
||||||
|
FROM permissions p
|
||||||
|
JOIN user_permissions up ON p.id = up.permission_id
|
||||||
|
WHERE up.user_id = $1
|
||||||
|
ORDER BY p.category, p.name
|
||||||
|
`, [userId]);
|
||||||
|
|
||||||
|
// Combine user and permissions
|
||||||
|
const user = {
|
||||||
|
...userResult.rows[0],
|
||||||
|
permissions: permissionsResult.rows
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting user:', error);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
router.post('/users', authenticate, requirePermission('create:users'), async (req, res) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { username, email, password, is_admin, is_active, permissions } = req.body;
|
||||||
|
|
||||||
|
console.log("Create user request:", {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
is_admin,
|
||||||
|
is_active,
|
||||||
|
permissions: permissions || []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: 'Username and password are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username is taken
|
||||||
|
const existingUser = await client.query(
|
||||||
|
'SELECT id FROM users WHERE username = $1',
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.rows.length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Username already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start transaction
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const saltRounds = 10;
|
||||||
|
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
RETURNING id
|
||||||
|
`, [username, email || null, hashedPassword, !!is_admin, is_active !== false]);
|
||||||
|
|
||||||
|
const userId = userResult.rows[0].id;
|
||||||
|
|
||||||
|
// Assign permissions if provided and not admin
|
||||||
|
if (!is_admin && Array.isArray(permissions) && permissions.length > 0) {
|
||||||
|
console.log("Adding permissions for new user:", userId);
|
||||||
|
console.log("Permissions received:", permissions);
|
||||||
|
|
||||||
|
// Check permission format
|
||||||
|
const permissionIds = permissions.map(p => {
|
||||||
|
if (typeof p === 'object' && p.id) {
|
||||||
|
console.log("Permission is an object with ID:", p.id);
|
||||||
|
return parseInt(p.id, 10);
|
||||||
|
} else if (typeof p === 'number') {
|
||||||
|
console.log("Permission is a number:", p);
|
||||||
|
return p;
|
||||||
|
} else if (typeof p === 'string' && !isNaN(parseInt(p, 10))) {
|
||||||
|
console.log("Permission is a string that can be parsed as a number:", p);
|
||||||
|
return parseInt(p, 10);
|
||||||
|
} else {
|
||||||
|
console.log("Unknown permission format:", typeof p, p);
|
||||||
|
// If it's a permission code, we need to look up the ID
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).filter(id => id !== null);
|
||||||
|
|
||||||
|
console.log("Filtered permission IDs:", permissionIds);
|
||||||
|
|
||||||
|
if (permissionIds.length > 0) {
|
||||||
|
const permissionValues = permissionIds
|
||||||
|
.map(permId => `(${userId}, ${permId})`)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
console.log("Inserting permission values:", permissionValues);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO user_permissions (user_id, permission_id)
|
||||||
|
VALUES ${permissionValues}
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`);
|
||||||
|
console.log("Successfully inserted permissions for new user:", userId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error inserting permissions for new user:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("No valid permission IDs found to insert for new user");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("Not adding permissions: is_admin =", is_admin, "permissions array:", Array.isArray(permissions), "length:", permissions ? permissions.length : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: userId,
|
||||||
|
message: 'User created successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error creating user:', error);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
router.put('/users/:id', authenticate, requirePermission('edit:users'), async (req, res) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const { username, email, password, is_admin, is_active, permissions } = req.body;
|
||||||
|
|
||||||
|
console.log("Update user request:", {
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
is_admin,
|
||||||
|
is_active,
|
||||||
|
permissions: permissions || []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const userExists = await client.query(
|
||||||
|
'SELECT id FROM users WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userExists.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start transaction
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Build update fields
|
||||||
|
const updateFields = [];
|
||||||
|
const updateValues = [userId]; // First parameter is the user ID
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (username !== undefined) {
|
||||||
|
updateFields.push(`username = $${paramIndex++}`);
|
||||||
|
updateValues.push(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email !== undefined) {
|
||||||
|
updateFields.push(`email = $${paramIndex++}`);
|
||||||
|
updateValues.push(email || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_admin !== undefined) {
|
||||||
|
updateFields.push(`is_admin = $${paramIndex++}`);
|
||||||
|
updateValues.push(!!is_admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_active !== undefined) {
|
||||||
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
|
updateValues.push(!!is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password if provided
|
||||||
|
if (password) {
|
||||||
|
const saltRounds = 10;
|
||||||
|
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||||
|
updateFields.push(`password = $${paramIndex++}`);
|
||||||
|
updateValues.push(hashedPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user if there are fields to update
|
||||||
|
if (updateFields.length > 0) {
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
UPDATE users
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $1
|
||||||
|
`, updateValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update permissions if provided
|
||||||
|
if (Array.isArray(permissions)) {
|
||||||
|
console.log("Updating permissions for user:", userId);
|
||||||
|
console.log("Permissions received:", permissions);
|
||||||
|
|
||||||
|
// First remove existing permissions
|
||||||
|
await client.query(
|
||||||
|
'DELETE FROM user_permissions WHERE user_id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
console.log("Deleted existing permissions for user:", userId);
|
||||||
|
|
||||||
|
// Add new permissions if any and not admin
|
||||||
|
const newIsAdmin = is_admin !== undefined ? is_admin : (await client.query('SELECT is_admin FROM users WHERE id = $1', [userId])).rows[0].is_admin;
|
||||||
|
|
||||||
|
console.log("User is admin:", newIsAdmin);
|
||||||
|
|
||||||
|
if (!newIsAdmin && permissions.length > 0) {
|
||||||
|
console.log("Adding permissions:", permissions);
|
||||||
|
|
||||||
|
// Check permission format
|
||||||
|
const permissionIds = permissions.map(p => {
|
||||||
|
if (typeof p === 'object' && p.id) {
|
||||||
|
console.log("Permission is an object with ID:", p.id);
|
||||||
|
return parseInt(p.id, 10);
|
||||||
|
} else if (typeof p === 'number') {
|
||||||
|
console.log("Permission is a number:", p);
|
||||||
|
return p;
|
||||||
|
} else if (typeof p === 'string' && !isNaN(parseInt(p, 10))) {
|
||||||
|
console.log("Permission is a string that can be parsed as a number:", p);
|
||||||
|
return parseInt(p, 10);
|
||||||
|
} else {
|
||||||
|
console.log("Unknown permission format:", typeof p, p);
|
||||||
|
// If it's a permission code, we need to look up the ID
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).filter(id => id !== null);
|
||||||
|
|
||||||
|
console.log("Filtered permission IDs:", permissionIds);
|
||||||
|
|
||||||
|
if (permissionIds.length > 0) {
|
||||||
|
const permissionValues = permissionIds
|
||||||
|
.map(permId => `(${userId}, ${permId})`)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
console.log("Inserting permission values:", permissionValues);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO user_permissions (user_id, permission_id)
|
||||||
|
VALUES ${permissionValues}
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`);
|
||||||
|
console.log("Successfully inserted permissions for user:", userId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error inserting permissions:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("No valid permission IDs found to insert");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
res.json({ message: 'User updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error updating user:', error);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
router.delete('/users/:id', authenticate, requirePermission('delete:users'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.params.id;
|
||||||
|
|
||||||
|
// Check that user is not deleting themselves
|
||||||
|
if (req.user.id === parseInt(userId, 10)) {
|
||||||
|
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user (this will cascade to user_permissions due to FK constraints)
|
||||||
|
const result = await pool.query(
|
||||||
|
'DELETE FROM users WHERE id = $1 RETURNING id',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'User deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user:', error);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all permissions grouped by category
|
||||||
|
router.get('/permissions/categories', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT category, json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', id,
|
||||||
|
'name', name,
|
||||||
|
'code', code,
|
||||||
|
'description', description
|
||||||
|
) ORDER BY name
|
||||||
|
) as permissions
|
||||||
|
FROM permissions
|
||||||
|
GROUP BY category
|
||||||
|
ORDER BY category
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting permissions:', error);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all permissions
|
||||||
|
router.get('/permissions', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT *
|
||||||
|
FROM permissions
|
||||||
|
ORDER BY category, name
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting permissions:', error);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -2,5 +2,88 @@ CREATE TABLE users (
|
|||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
username VARCHAR(255) NOT NULL UNIQUE,
|
username VARCHAR(255) NOT NULL UNIQUE,
|
||||||
password VARCHAR(255) NOT NULL,
|
password VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR UNIQUE,
|
||||||
|
is_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
last_login TIMESTAMP WITH TIME ZONE,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Function to update the updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Sequence and defined type for users table if not exists
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS users_id_seq;
|
||||||
|
|
||||||
|
-- Create permissions table
|
||||||
|
CREATE TABLE IF NOT EXISTS "public"."permissions" (
|
||||||
|
"id" SERIAL PRIMARY KEY,
|
||||||
|
"name" varchar NOT NULL UNIQUE,
|
||||||
|
"code" varchar NOT NULL UNIQUE,
|
||||||
|
"description" text,
|
||||||
|
"category" varchar NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create user_permissions junction table
|
||||||
|
CREATE TABLE IF NOT EXISTS "public"."user_permissions" (
|
||||||
|
"user_id" int4 NOT NULL REFERENCES "public"."users"("id") ON DELETE CASCADE,
|
||||||
|
"permission_id" int4 NOT NULL REFERENCES "public"."permissions"("id") ON DELETE CASCADE,
|
||||||
|
"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY ("user_id", "permission_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add triggers for updated_at on users and permissions
|
||||||
|
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||||
|
CREATE TRIGGER update_users_updated_at
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS update_permissions_updated_at ON permissions;
|
||||||
|
CREATE TRIGGER update_permissions_updated_at
|
||||||
|
BEFORE UPDATE ON permissions
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Insert default permissions by page - only the ones used in application
|
||||||
|
INSERT INTO permissions (name, code, description, category) VALUES
|
||||||
|
('Dashboard Access', 'access:dashboard', 'Can access the Dashboard page', 'Pages'),
|
||||||
|
('Products Access', 'access:products', 'Can access the Products page', 'Pages'),
|
||||||
|
('Categories Access', 'access:categories', 'Can access the Categories page', 'Pages'),
|
||||||
|
('Vendors Access', 'access:vendors', 'Can access the Vendors page', 'Pages'),
|
||||||
|
('Analytics Access', 'access:analytics', 'Can access the Analytics page', 'Pages'),
|
||||||
|
('Forecasting Access', 'access:forecasting', 'Can access the Forecasting page', 'Pages'),
|
||||||
|
('Purchase Orders Access', 'access:purchase_orders', 'Can access the Purchase Orders page', 'Pages'),
|
||||||
|
('Import Access', 'access:import', 'Can access the Import page', 'Pages'),
|
||||||
|
('Settings Access', 'access:settings', 'Can access the Settings page', 'Pages'),
|
||||||
|
('AI Validation Debug Access', 'access:ai_validation_debug', 'Can access the AI Validation Debug page', 'Pages')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
-- Settings section permissions
|
||||||
|
INSERT INTO permissions (name, code, description, category) VALUES
|
||||||
|
('Data Management', 'settings:data_management', 'Access to the Data Management settings section', 'Settings'),
|
||||||
|
('Stock Management', 'settings:stock_management', 'Access to the Stock Management settings section', 'Settings'),
|
||||||
|
('Performance Metrics', 'settings:performance_metrics', 'Access to the Performance Metrics settings section', 'Settings'),
|
||||||
|
('Calculation Settings', 'settings:calculation_settings', 'Access to the Calculation Settings section', 'Settings'),
|
||||||
|
('Template Management', 'settings:templates', 'Access to the Template Management settings section', 'Settings'),
|
||||||
|
('User Management', 'settings:user_management', 'Access to the User Management settings section', 'Settings')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
-- Set any existing users as admin
|
||||||
|
UPDATE users SET is_admin = TRUE WHERE is_admin IS NULL;
|
||||||
|
|
||||||
|
-- Grant all permissions to admin users
|
||||||
|
INSERT INTO user_permissions (user_id, permission_id)
|
||||||
|
SELECT u.id, p.id
|
||||||
|
FROM users u, permissions p
|
||||||
|
WHERE u.is_admin = TRUE
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -5,6 +5,7 @@ const bcrypt = require('bcrypt');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const morgan = require('morgan');
|
const morgan = require('morgan');
|
||||||
|
const authRoutes = require('./routes');
|
||||||
|
|
||||||
// Log startup configuration
|
// Log startup configuration
|
||||||
console.log('Starting auth server with config:', {
|
console.log('Starting auth server with config:', {
|
||||||
@@ -27,11 +28,14 @@ const pool = new Pool({
|
|||||||
port: process.env.DB_PORT,
|
port: process.env.DB_PORT,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Make pool available globally
|
||||||
|
global.pool = pool;
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(morgan('combined'));
|
app.use(morgan('combined'));
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: ['http://localhost:5173', 'https://inventory.kent.pw'],
|
origin: ['http://localhost:5173', 'http://localhost:5174', 'https://inventory.kent.pw'],
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -42,7 +46,7 @@ app.post('/login', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
// Get user from database
|
// Get user from database
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT id, username, password FROM users WHERE username = $1',
|
'SELECT id, username, password, is_admin, is_active FROM users WHERE username = $1',
|
||||||
[username]
|
[username]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -53,6 +57,11 @@ app.post('/login', async (req, res) => {
|
|||||||
return res.status(401).json({ error: 'Invalid username or password' });
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user is active
|
||||||
|
if (!user.is_active) {
|
||||||
|
return res.status(403).json({ error: 'Account is inactive' });
|
||||||
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ userId: user.id, username: user.username },
|
{ userId: user.id, username: user.username },
|
||||||
@@ -60,31 +69,84 @@ app.post('/login', async (req, res) => {
|
|||||||
{ expiresIn: '24h' }
|
{ expiresIn: '24h' }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ token });
|
// Get user permissions for the response
|
||||||
|
const permissionsResult = await pool.query(`
|
||||||
|
SELECT code
|
||||||
|
FROM permissions p
|
||||||
|
JOIN user_permissions up ON p.id = up.permission_id
|
||||||
|
WHERE up.user_id = $1
|
||||||
|
`, [user.id]);
|
||||||
|
|
||||||
|
const permissions = permissionsResult.rows.map(row => row.code);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
is_admin: user.is_admin,
|
||||||
|
permissions: user.is_admin ? [] : permissions
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Protected route to verify token
|
// User info endpoint
|
||||||
app.get('/protected', async (req, res) => {
|
app.get('/me', async (req, res) => {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (!authHeader) {
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = authHeader.split(' ')[1];
|
const token = authHeader.split(' ')[1];
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
res.json({ userId: decoded.userId, username: decoded.username });
|
|
||||||
|
// Get user details from database
|
||||||
|
const userResult = await pool.query(
|
||||||
|
'SELECT id, username, email, is_admin, is_active FROM users WHERE id = $1',
|
||||||
|
[decoded.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
|
// Get user permissions
|
||||||
|
let permissions = [];
|
||||||
|
if (!user.is_admin) {
|
||||||
|
const permissionsResult = await pool.query(`
|
||||||
|
SELECT code
|
||||||
|
FROM permissions p
|
||||||
|
JOIN user_permissions up ON p.id = up.permission_id
|
||||||
|
WHERE up.user_id = $1
|
||||||
|
`, [user.id]);
|
||||||
|
|
||||||
|
permissions = permissionsResult.rows.map(row => row.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
is_admin: user.is_admin,
|
||||||
|
permissions: permissions
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token verification error:', error);
|
console.error('Token verification error:', error);
|
||||||
res.status(401).json({ error: 'Invalid token' });
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mount all routes from routes.js
|
||||||
|
app.use('/', authRoutes);
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'healthy' });
|
res.json({ status: 'healthy' });
|
||||||
|
|||||||
14
inventory-server/package-lock.json
generated
14
inventory-server/package-lock.json
generated
@@ -1537,20 +1537,6 @@
|
|||||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ async function resetDatabase() {
|
|||||||
SELECT string_agg(tablename, ', ') as tables
|
SELECT string_agg(tablename, ', ') as tables
|
||||||
FROM pg_tables
|
FROM pg_tables
|
||||||
WHERE schemaname = 'public'
|
WHERE schemaname = 'public'
|
||||||
AND tablename NOT IN ('users', 'calculate_history', 'import_history');
|
AND tablename NOT IN ('users', 'permissions', 'user_permissions', 'calculate_history', 'import_history');
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (!tablesResult.rows[0].tables) {
|
if (!tablesResult.rows[0].tables) {
|
||||||
|
|||||||
@@ -1,47 +1,10 @@
|
|||||||
const { Pool, Client } = require('pg');
|
const mysql = require('mysql2/promise');
|
||||||
const { Client: SSHClient } = require('ssh2');
|
|
||||||
|
|
||||||
let pool;
|
let pool;
|
||||||
|
|
||||||
function initPool(config) {
|
function initPool(config) {
|
||||||
// Log config without sensitive data
|
pool = mysql.createPool(config);
|
||||||
const safeConfig = {
|
return pool;
|
||||||
host: config.host || process.env.DB_HOST,
|
|
||||||
user: config.user || process.env.DB_USER,
|
|
||||||
database: config.database || process.env.DB_NAME,
|
|
||||||
port: config.port || process.env.DB_PORT || 5432,
|
|
||||||
max: config.max || 10,
|
|
||||||
idleTimeoutMillis: config.idleTimeoutMillis || 30000,
|
|
||||||
connectionTimeoutMillis: config.connectionTimeoutMillis || 2000,
|
|
||||||
ssl: config.ssl || false,
|
|
||||||
password: (config.password || process.env.DB_PASSWORD) ? '[password set]' : '[no password]'
|
|
||||||
};
|
|
||||||
console.log('[Database] Initializing pool with config:', safeConfig);
|
|
||||||
|
|
||||||
// Create the pool with the configuration
|
|
||||||
pool = new Pool({
|
|
||||||
host: config.host || process.env.DB_HOST,
|
|
||||||
user: config.user || process.env.DB_USER,
|
|
||||||
password: config.password || process.env.DB_PASSWORD,
|
|
||||||
database: config.database || process.env.DB_NAME,
|
|
||||||
port: config.port || process.env.DB_PORT || 5432,
|
|
||||||
max: config.max || 10,
|
|
||||||
idleTimeoutMillis: config.idleTimeoutMillis || 30000,
|
|
||||||
connectionTimeoutMillis: config.connectionTimeoutMillis || 2000,
|
|
||||||
ssl: config.ssl || false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test the pool connection
|
|
||||||
return pool.connect()
|
|
||||||
.then(client => {
|
|
||||||
console.log('[Database] Pool connection successful');
|
|
||||||
client.release();
|
|
||||||
return pool;
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('[Database] Connection failed:', err);
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getConnection() {
|
async function getConnection() {
|
||||||
|
|||||||
30
inventory/package-lock.json
generated
30
inventory/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.2",
|
"@radix-ui/react-accordion": "^1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
@@ -71,7 +73,8 @@
|
|||||||
"tanstack": "^1.0.0",
|
"tanstack": "^1.0.0",
|
||||||
"uuid": "^11.0.5",
|
"uuid": "^11.0.5",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5",
|
||||||
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
@@ -1227,6 +1230,15 @@
|
|||||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@hookform/resolvers": {
|
||||||
|
"version": "3.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
|
||||||
|
"integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-hook-form": "^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -6937,6 +6949,22 @@
|
|||||||
"react": ">= 16.8 || 18.0.0"
|
"react": ">= 16.8 || 18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hook-form": {
|
||||||
|
"version": "7.54.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
|
||||||
|
"integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-icons": {
|
"node_modules/react-icons": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz",
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.2",
|
"@radix-ui/react-accordion": "^1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
@@ -73,7 +75,8 @@
|
|||||||
"tanstack": "^1.0.0",
|
"tanstack": "^1.0.0",
|
||||||
"uuid": "^11.0.5",
|
"uuid": "^11.0.5",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5",
|
||||||
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Routes, Route, useNavigate, Navigate } from 'react-router-dom';
|
import { Routes, Route, useNavigate, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { MainLayout } from './components/layout/MainLayout';
|
import { MainLayout } from './components/layout/MainLayout';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Products } from './pages/Products';
|
import { Products } from './pages/Products';
|
||||||
import { Dashboard } from './pages/Dashboard';
|
import { Dashboard } from './pages/Dashboard';
|
||||||
import { Orders } from './pages/Orders';
|
|
||||||
import { Settings } from './pages/Settings';
|
import { Settings } from './pages/Settings';
|
||||||
import { Analytics } from './pages/Analytics';
|
import { Analytics } from './pages/Analytics';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
@@ -16,64 +15,123 @@ import Forecasting from "@/pages/Forecasting";
|
|||||||
import { Vendors } from '@/pages/Vendors';
|
import { Vendors } from '@/pages/Vendors';
|
||||||
import { Categories } from '@/pages/Categories';
|
import { Categories } from '@/pages/Categories';
|
||||||
import { Import } from '@/pages/Import';
|
import { Import } from '@/pages/Import';
|
||||||
import { AiValidationDebug } from "@/pages/AiValidationDebug"
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
import { Protected } from './components/auth/Protected';
|
||||||
|
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
const token = sessionStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true';
|
||||||
|
|
||||||
|
// If we have a token but aren't logged in yet, verify the token
|
||||||
|
if (token && !isLoggedIn) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${config.authUrl}/protected`, {
|
const response = await fetch(`${config.authUrl}/me`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
sessionStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
sessionStorage.removeItem('isLoggedIn');
|
sessionStorage.removeItem('isLoggedIn');
|
||||||
navigate('/login');
|
|
||||||
|
// Only navigate to login if we're not already there
|
||||||
|
if (!location.pathname.includes('/login')) {
|
||||||
|
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If token is valid, set the login flag
|
||||||
|
sessionStorage.setItem('isLoggedIn', 'true');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Token verification failed:', error);
|
console.error('Token verification failed:', error);
|
||||||
sessionStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
sessionStorage.removeItem('isLoggedIn');
|
sessionStorage.removeItem('isLoggedIn');
|
||||||
navigate('/login');
|
|
||||||
|
// Only navigate to login if we're not already there
|
||||||
|
if (!location.pathname.includes('/login')) {
|
||||||
|
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
checkAuth();
|
checkAuth();
|
||||||
}, [navigate]);
|
}, [navigate, location.pathname, location.search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Toaster richColors position="top-center" />
|
<AuthProvider>
|
||||||
<Routes>
|
<Toaster richColors position="top-center" />
|
||||||
<Route path="/login" element={<Login />} />
|
<Routes>
|
||||||
<Route element={
|
<Route path="/login" element={<Login />} />
|
||||||
<RequireAuth>
|
<Route element={
|
||||||
<MainLayout />
|
<RequireAuth>
|
||||||
</RequireAuth>
|
<MainLayout />
|
||||||
}>
|
</RequireAuth>
|
||||||
<Route path="/" element={<Dashboard />} />
|
}>
|
||||||
<Route path="/products" element={<Products />} />
|
<Route index element={
|
||||||
<Route path="/import" element={<Import />} />
|
<Protected page="dashboard" fallback={<FirstAccessiblePage />}>
|
||||||
<Route path="/categories" element={<Categories />} />
|
<Dashboard />
|
||||||
<Route path="/vendors" element={<Vendors />} />
|
</Protected>
|
||||||
<Route path="/orders" element={<Orders />} />
|
} />
|
||||||
<Route path="/purchase-orders" element={<PurchaseOrders />} />
|
<Route path="/" element={
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Protected page="dashboard">
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Dashboard />
|
||||||
<Route path="/forecasting" element={<Forecasting />} />
|
</Protected>
|
||||||
<Route path="/ai-validation/debug" element={<AiValidationDebug />} />
|
} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="/products" element={
|
||||||
</Route>
|
<Protected page="products">
|
||||||
</Routes>
|
<Products />
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
<Route path="/import" element={
|
||||||
|
<Protected page="import">
|
||||||
|
<Import />
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
<Route path="/categories" element={
|
||||||
|
<Protected page="categories">
|
||||||
|
<Categories />
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
<Route path="/vendors" element={
|
||||||
|
<Protected page="vendors">
|
||||||
|
<Vendors />
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
<Route path="/purchase-orders" element={
|
||||||
|
<Protected page="purchase_orders">
|
||||||
|
<PurchaseOrders />
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
<Route path="/analytics" element={
|
||||||
|
<Protected page="analytics">
|
||||||
|
<Analytics />
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
<Route path="/settings" element={
|
||||||
|
<Protected page="settings">
|
||||||
|
<Settings />
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
<Route path="/forecasting" element={
|
||||||
|
<Protected page="forecasting">
|
||||||
|
<Forecasting />
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
44
inventory/src/components/auth/FirstAccessiblePage.tsx
Normal file
44
inventory/src/components/auth/FirstAccessiblePage.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
|
// Define available pages in order of priority
|
||||||
|
const PAGES = [
|
||||||
|
{ path: "/products", permission: "access:products" },
|
||||||
|
{ path: "/categories", permission: "access:categories" },
|
||||||
|
{ path: "/vendors", permission: "access:vendors" },
|
||||||
|
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
||||||
|
{ path: "/analytics", permission: "access:analytics" },
|
||||||
|
{ path: "/forecasting", permission: "access:forecasting" },
|
||||||
|
{ path: "/import", permission: "access:import" },
|
||||||
|
{ path: "/settings", permission: "access:settings" },
|
||||||
|
{ path: "/ai-validation/debug", permission: "access:ai_validation_debug" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FirstAccessiblePage() {
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
|
// If user isn't loaded yet, don't render anything
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin users have access to all pages, so this component
|
||||||
|
// shouldn't be rendering for them (handled by App.tsx)
|
||||||
|
if (user.is_admin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first page the user has access to
|
||||||
|
const firstAccessiblePage = PAGES.find(page => {
|
||||||
|
return user.permissions?.includes(page.permission);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we found a page, redirect to it
|
||||||
|
if (firstAccessiblePage) {
|
||||||
|
return <Navigate to={firstAccessiblePage.path} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has no access to any page, redirect to login
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
104
inventory/src/components/auth/PERMISSIONS.md
Normal file
104
inventory/src/components/auth/PERMISSIONS.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Permission System Documentation
|
||||||
|
|
||||||
|
This document outlines the simplified permission system implemented in the Inventory Manager application.
|
||||||
|
|
||||||
|
## Permission Structure
|
||||||
|
|
||||||
|
Permissions follow this naming convention:
|
||||||
|
|
||||||
|
- Page access: `access:{page_name}`
|
||||||
|
- Actions: `{action}:{resource}`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `access:products` - Can access the Products page
|
||||||
|
- `create:products` - Can create new products
|
||||||
|
- `edit:users` - Can edit user accounts
|
||||||
|
|
||||||
|
## Permission Component
|
||||||
|
|
||||||
|
### Protected
|
||||||
|
|
||||||
|
The core component that conditionally renders content based on permissions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Protected
|
||||||
|
permission="create:products"
|
||||||
|
fallback={<p>No permission</p>}
|
||||||
|
>
|
||||||
|
<button>Create Product</button>
|
||||||
|
</Protected>
|
||||||
|
```
|
||||||
|
|
||||||
|
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)
|
||||||
|
- `adminOnly`: For admin-only sections
|
||||||
|
- `fallback`: Content to show if permission check fails
|
||||||
|
|
||||||
|
### RequireAuth
|
||||||
|
|
||||||
|
Used for basic authentication checks (is user logged in?).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Route element={
|
||||||
|
<RequireAuth>
|
||||||
|
<MainLayout />
|
||||||
|
</RequireAuth>
|
||||||
|
}>
|
||||||
|
{/* Protected routes */}
|
||||||
|
</Route>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Permission Codes
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `access:dashboard` | Access to Dashboard 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 |
|
||||||
|
|
||||||
|
## Implementation Examples
|
||||||
|
|
||||||
|
### Page Protection
|
||||||
|
|
||||||
|
In `App.tsx`:
|
||||||
|
```tsx
|
||||||
|
<Route path="/products" element={
|
||||||
|
<Protected page="products" fallback={<Navigate to="/" />}>
|
||||||
|
<Products />
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Level Protection
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Protected permission="edit:products">
|
||||||
|
<form>
|
||||||
|
{/* Form fields */}
|
||||||
|
<button type="submit">Save Changes</button>
|
||||||
|
</form>
|
||||||
|
</Protected>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Button 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>
|
||||||
|
```
|
||||||
82
inventory/src/components/auth/Protected.tsx
Normal file
82
inventory/src/components/auth/Protected.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { ReactNode, useContext } from "react";
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
|
interface ProtectedProps {
|
||||||
|
// For specific permission code
|
||||||
|
permission?: string;
|
||||||
|
|
||||||
|
// For page access permission format: access:{page}
|
||||||
|
page?: string;
|
||||||
|
|
||||||
|
// For action permission format: {action}:{resource}
|
||||||
|
resource?: string;
|
||||||
|
action?: "view" | "create" | "edit" | "delete" | string;
|
||||||
|
|
||||||
|
// For admin-only access
|
||||||
|
adminOnly?: boolean;
|
||||||
|
|
||||||
|
// Content to render if permission check passes
|
||||||
|
children: ReactNode;
|
||||||
|
|
||||||
|
// Optional fallback content
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simplified component that conditionally renders content based on user permissions
|
||||||
|
*/
|
||||||
|
export function Protected({
|
||||||
|
permission,
|
||||||
|
page,
|
||||||
|
resource,
|
||||||
|
action,
|
||||||
|
adminOnly,
|
||||||
|
children,
|
||||||
|
fallback = null
|
||||||
|
}: ProtectedProps) {
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
|
// If user isn't loaded yet, don't render anything
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin check - admins always have access to everything
|
||||||
|
if (user.is_admin) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-only check
|
||||||
|
if (adminOnly) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions array exists
|
||||||
|
if (!user.permissions) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page access check (access:page)
|
||||||
|
if (page) {
|
||||||
|
const pagePermission = `access:${page.toLowerCase()}`;
|
||||||
|
if (!user.permissions.includes(pagePermission)) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource action check (action:resource)
|
||||||
|
if (resource && action) {
|
||||||
|
const resourcePermission = `${action}:${resource.toLowerCase()}`;
|
||||||
|
if (!user.permissions.includes(resourcePermission)) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single permission check
|
||||||
|
if (permission && !user.permissions.includes(permission)) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all checks pass, render children
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,8 +1,45 @@
|
|||||||
import { Navigate, useLocation } from "react-router-dom"
|
import { Navigate, useLocation } from "react-router-dom"
|
||||||
|
import { useContext, useEffect, useState } from "react"
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext"
|
||||||
|
|
||||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const isLoggedIn = sessionStorage.getItem("isLoggedIn") === "true"
|
const isLoggedIn = sessionStorage.getItem("isLoggedIn") === "true"
|
||||||
|
const { token, user, fetchCurrentUser } = useContext(AuthContext)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const [isLoading, setIsLoading] = useState(!!token && !user)
|
||||||
|
|
||||||
|
// This will make sure the user data is loaded the first time
|
||||||
|
useEffect(() => {
|
||||||
|
const loadUserData = async () => {
|
||||||
|
if (token && !user) {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
await fetchCurrentUser()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch user data:", error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUserData()
|
||||||
|
}, [token, user, fetchCurrentUser])
|
||||||
|
|
||||||
|
// Check if token exists but we're not logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (token && !isLoggedIn) {
|
||||||
|
// Verify the token and fetch user data
|
||||||
|
fetchCurrentUser().catch(() => {
|
||||||
|
// Do nothing - the AuthContext will handle errors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [token, isLoggedIn, fetchCurrentUser])
|
||||||
|
|
||||||
|
// If still loading user data, show nothing yet
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-8 flex justify-center items-center h-screen">Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
// Redirect to login with the current path in the redirect parameter
|
// Redirect to login with the current path in the redirect parameter
|
||||||
|
|||||||
@@ -24,47 +24,56 @@ import {
|
|||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { useLocation, useNavigate, Link } from "react-router-dom";
|
import { useLocation, useNavigate, Link } from "react-router-dom";
|
||||||
|
import { Protected } from "@/components/auth/Protected";
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
title: "Overview",
|
title: "Overview",
|
||||||
icon: Home,
|
icon: Home,
|
||||||
url: "/",
|
url: "/",
|
||||||
|
permission: "access:dashboard"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Products",
|
title: "Products",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
url: "/products",
|
url: "/products",
|
||||||
|
permission: "access:products"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Import",
|
title: "Import",
|
||||||
icon: FileSpreadsheet,
|
icon: FileSpreadsheet,
|
||||||
url: "/import",
|
url: "/import",
|
||||||
|
permission: "access:import"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Forecasting",
|
title: "Forecasting",
|
||||||
icon: IconCrystalBall,
|
icon: IconCrystalBall,
|
||||||
url: "/forecasting",
|
url: "/forecasting",
|
||||||
|
permission: "access:forecasting"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Categories",
|
title: "Categories",
|
||||||
icon: Tags,
|
icon: Tags,
|
||||||
url: "/categories",
|
url: "/categories",
|
||||||
|
permission: "access:categories"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Vendors",
|
title: "Vendors",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
url: "/vendors",
|
url: "/vendors",
|
||||||
|
permission: "access:vendors"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Purchase Orders",
|
title: "Purchase Orders",
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
url: "/purchase-orders",
|
url: "/purchase-orders",
|
||||||
|
permission: "access:purchase_orders"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Analytics",
|
title: "Analytics",
|
||||||
icon: BarChart2,
|
icon: BarChart2,
|
||||||
url: "/analytics",
|
url: "/analytics",
|
||||||
|
permission: "access:analytics"
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -73,8 +82,8 @@ export function AppSidebar() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
sessionStorage.removeItem('isLoggedIn');
|
sessionStorage.removeItem('isLoggedIn');
|
||||||
sessionStorage.removeItem('token');
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,20 +107,26 @@ export function AppSidebar() {
|
|||||||
location.pathname === item.url ||
|
location.pathname === item.url ||
|
||||||
(item.url !== "/" && location.pathname.startsWith(item.url));
|
(item.url !== "/" && location.pathname.startsWith(item.url));
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={item.title}>
|
<Protected
|
||||||
<SidebarMenuButton
|
key={item.title}
|
||||||
asChild
|
permission={item.permission}
|
||||||
tooltip={item.title}
|
fallback={null}
|
||||||
isActive={isActive}
|
>
|
||||||
>
|
<SidebarMenuItem>
|
||||||
<Link to={item.url}>
|
<SidebarMenuButton
|
||||||
<item.icon className="h-4 w-4" />
|
asChild
|
||||||
<span className="group-data-[collapsible=icon]:hidden">
|
tooltip={item.title}
|
||||||
{item.title}
|
isActive={isActive}
|
||||||
</span>
|
>
|
||||||
</Link>
|
<Link to={item.url}>
|
||||||
</SidebarMenuButton>
|
<item.icon className="h-4 w-4" />
|
||||||
</SidebarMenuItem>
|
<span className="group-data-[collapsible=icon]:hidden">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Protected>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -122,24 +137,30 @@ export function AppSidebar() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<Protected
|
||||||
<SidebarMenuButton
|
permission="access:settings"
|
||||||
asChild
|
fallback={null}
|
||||||
tooltip="Settings"
|
>
|
||||||
isActive={location.pathname === "/settings"}
|
<SidebarMenuItem>
|
||||||
>
|
<SidebarMenuButton
|
||||||
<Link to="/settings">
|
asChild
|
||||||
<Settings className="h-4 w-4" />
|
tooltip="Settings"
|
||||||
<span className="group-data-[collapsible=icon]:hidden">
|
isActive={location.pathname === "/settings"}
|
||||||
Settings
|
>
|
||||||
</span>
|
<Link to="/settings">
|
||||||
</Link>
|
<Settings className="h-4 w-4" />
|
||||||
</SidebarMenuButton>
|
<span className="group-data-[collapsible=icon]:hidden">
|
||||||
</SidebarMenuItem>
|
Settings
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Protected>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<SidebarSeparator />
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { AppSidebar } from "./AppSidebar";
|
import { AppSidebar } from "./AppSidebar";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
onBack();
|
onBack();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onNext={(validatedData) => {
|
onNext={(validatedData: any[]) => {
|
||||||
// Go to image upload step with the validated data
|
// Go to image upload step with the validated data
|
||||||
onNext({
|
onNext({
|
||||||
type: StepType.imageUpload,
|
type: StepType.imageUpload,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { AiValidationDialogs } from '../../../components/AiValidationDialogs';
|
||||||
|
import { Product } from '../../../types/product';
|
||||||
|
import { config } from '../../../config';
|
||||||
|
|
||||||
|
interface CurrentPrompt {
|
||||||
|
isOpen: boolean;
|
||||||
|
prompt: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
debugData?: {
|
||||||
|
taxonomyStats: {
|
||||||
|
categories: number;
|
||||||
|
themes: number;
|
||||||
|
colors: number;
|
||||||
|
taxCodes: number;
|
||||||
|
sizeCategories: number;
|
||||||
|
suppliers: number;
|
||||||
|
companies: number;
|
||||||
|
artists: number;
|
||||||
|
} | null;
|
||||||
|
basePrompt: string;
|
||||||
|
sampleFullPrompt: string;
|
||||||
|
promptLength: number;
|
||||||
|
estimatedProcessingTime?: {
|
||||||
|
seconds: number | null;
|
||||||
|
sampleCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ValidationStepNew: React.FC = () => {
|
||||||
|
const [aiValidationProgress, setAiValidationProgress] = useState(0);
|
||||||
|
const [aiValidationDetails, setAiValidationDetails] = useState('');
|
||||||
|
const [currentPrompt, setCurrentPrompt] = useState<CurrentPrompt>({
|
||||||
|
isOpen: false,
|
||||||
|
prompt: '',
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
const [isChangeReverted, setIsChangeReverted] = useState(false);
|
||||||
|
const [fieldData, setFieldData] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
const showCurrentPrompt = async (products: Product[]) => {
|
||||||
|
setCurrentPrompt((prev) => ({ ...prev, isOpen: true, isLoading: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the prompt
|
||||||
|
const promptResponse = await fetch(`${config.apiUrl}/ai-validation/prompt`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ products })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!promptResponse.ok) {
|
||||||
|
throw new Error('Failed to fetch AI prompt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptData = await promptResponse.json();
|
||||||
|
|
||||||
|
// Get the debug data in the same request or as a separate request
|
||||||
|
const debugResponse = await fetch(`${config.apiUrl}/ai-validation/debug-info`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt: promptData.prompt })
|
||||||
|
});
|
||||||
|
|
||||||
|
let debugData;
|
||||||
|
if (debugResponse.ok) {
|
||||||
|
debugData = await debugResponse.json();
|
||||||
|
} else {
|
||||||
|
// If debug-info fails, use a fallback to get taxonomy stats
|
||||||
|
const fallbackResponse = await fetch(`${config.apiUrl}/ai-validation/debug`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ products: [products[0]] }) // Use first product for stats
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fallbackResponse.ok) {
|
||||||
|
debugData = await fallbackResponse.json();
|
||||||
|
// Set promptLength correctly from the actual prompt
|
||||||
|
debugData.promptLength = promptData.prompt.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentPrompt((prev) => ({
|
||||||
|
...prev,
|
||||||
|
prompt: promptData.prompt,
|
||||||
|
isLoading: false,
|
||||||
|
debugData: debugData
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching prompt:', error);
|
||||||
|
setCurrentPrompt((prev) => ({
|
||||||
|
...prev,
|
||||||
|
prompt: 'Error loading prompt',
|
||||||
|
isLoading: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revertAiChange = () => {
|
||||||
|
setIsChangeReverted(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldDisplayValueWithHighlight = (value: string, highlight: string) => {
|
||||||
|
// Implementation of getFieldDisplayValueWithHighlight
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AiValidationDialogs
|
||||||
|
aiValidationProgress={aiValidationProgress}
|
||||||
|
aiValidationDetails={aiValidationDetails}
|
||||||
|
currentPrompt={currentPrompt}
|
||||||
|
setAiValidationProgress={setAiValidationProgress}
|
||||||
|
setAiValidationDetails={setAiValidationDetails}
|
||||||
|
setCurrentPrompt={setCurrentPrompt}
|
||||||
|
revertAiChange={revertAiChange}
|
||||||
|
isChangeReverted={isChangeReverted}
|
||||||
|
getFieldDisplayValueWithHighlight={getFieldDisplayValueWithHighlight}
|
||||||
|
fields={fieldData}
|
||||||
|
debugData={currentPrompt.debugData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ValidationStepNew;
|
||||||
@@ -1,23 +1,72 @@
|
|||||||
import React from 'react';
|
import React, { useState } from "react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import {
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
Dialog,
|
||||||
import { Button } from '@/components/ui/button';
|
DialogContent,
|
||||||
import { Loader2, CheckIcon } from 'lucide-react';
|
DialogHeader,
|
||||||
import { Code } from '@/components/ui/code';
|
DialogTitle,
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
DialogDescription,
|
||||||
import { AiValidationDetails, AiValidationProgress, CurrentPrompt } from '../hooks/useAiValidation';
|
} from "@/components/ui/dialog";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, CheckIcon } from "lucide-react";
|
||||||
|
import { Code } from "@/components/ui/code";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
AiValidationDetails,
|
||||||
|
AiValidationProgress,
|
||||||
|
CurrentPrompt,
|
||||||
|
} from "../hooks/useAiValidation";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface TaxonomyStats {
|
||||||
|
categories: number;
|
||||||
|
themes: number;
|
||||||
|
colors: number;
|
||||||
|
taxCodes: number;
|
||||||
|
sizeCategories: number;
|
||||||
|
suppliers: number;
|
||||||
|
companies: number;
|
||||||
|
artists: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DebugData {
|
||||||
|
taxonomyStats: TaxonomyStats | null;
|
||||||
|
basePrompt: string;
|
||||||
|
sampleFullPrompt: string;
|
||||||
|
promptLength: number;
|
||||||
|
estimatedProcessingTime?: {
|
||||||
|
seconds: number | null;
|
||||||
|
sampleCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface AiValidationDialogsProps {
|
interface AiValidationDialogsProps {
|
||||||
aiValidationProgress: AiValidationProgress;
|
aiValidationProgress: AiValidationProgress;
|
||||||
aiValidationDetails: AiValidationDetails;
|
aiValidationDetails: AiValidationDetails;
|
||||||
currentPrompt: CurrentPrompt;
|
currentPrompt: CurrentPrompt;
|
||||||
setAiValidationProgress: React.Dispatch<React.SetStateAction<AiValidationProgress>>;
|
setAiValidationProgress: React.Dispatch<
|
||||||
setAiValidationDetails: React.Dispatch<React.SetStateAction<AiValidationDetails>>;
|
React.SetStateAction<AiValidationProgress>
|
||||||
|
>;
|
||||||
|
setAiValidationDetails: React.Dispatch<
|
||||||
|
React.SetStateAction<AiValidationDetails>
|
||||||
|
>;
|
||||||
setCurrentPrompt: React.Dispatch<React.SetStateAction<CurrentPrompt>>;
|
setCurrentPrompt: React.Dispatch<React.SetStateAction<CurrentPrompt>>;
|
||||||
revertAiChange: (productIndex: number, fieldKey: string) => void;
|
revertAiChange: (productIndex: number, fieldKey: string) => void;
|
||||||
isChangeReverted: (productIndex: number, fieldKey: string) => boolean;
|
isChangeReverted: (productIndex: number, fieldKey: string) => boolean;
|
||||||
getFieldDisplayValueWithHighlight: (fieldKey: string, originalValue: any, correctedValue: any) => { originalHtml: string, correctedHtml: string };
|
getFieldDisplayValueWithHighlight: (
|
||||||
|
fieldKey: string,
|
||||||
|
originalValue: any,
|
||||||
|
correctedValue: any
|
||||||
|
) => { originalHtml: string; correctedHtml: string };
|
||||||
fields: readonly any[];
|
fields: readonly any[];
|
||||||
|
debugData?: DebugData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
||||||
@@ -30,31 +79,182 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
revertAiChange,
|
revertAiChange,
|
||||||
isChangeReverted,
|
isChangeReverted,
|
||||||
getFieldDisplayValueWithHighlight,
|
getFieldDisplayValueWithHighlight,
|
||||||
fields
|
fields,
|
||||||
|
debugData,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [costPerMillionTokens, setCostPerMillionTokens] = useState(2.5); // Default cost
|
||||||
|
|
||||||
|
// Format time helper
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${Math.round(seconds)} seconds`;
|
||||||
|
} else {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.round(seconds % 60);
|
||||||
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate token costs
|
||||||
|
const calculateTokenCost = (promptLength: number): number => {
|
||||||
|
const estimatedTokens = Math.round(promptLength / 4);
|
||||||
|
return (estimatedTokens / 1_000_000) * costPerMillionTokens * 100; // In cents
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the prompt length from the current prompt
|
||||||
|
const promptLength = currentPrompt.prompt ? currentPrompt.prompt.length : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Current Prompt Dialog */}
|
{/* Current Prompt Dialog with Debug Info */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={currentPrompt.isOpen}
|
open={currentPrompt.isOpen}
|
||||||
onOpenChange={(open) => setCurrentPrompt(prev => ({ ...prev, isOpen: open }))}
|
onOpenChange={(open) =>
|
||||||
|
setCurrentPrompt((prev) => ({ ...prev, isOpen: open }))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-4xl h-[80vh]">
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Current AI Prompt</DialogTitle>
|
<DialogTitle>Current AI Prompt</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This is the exact prompt that would be sent to the AI for validation
|
This is the current prompt that would be sent to the AI for
|
||||||
|
validation
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
{currentPrompt.isLoading ? (
|
<div className="flex flex-col h-[calc(90vh-120px)] overflow-hidden">
|
||||||
<div className="flex items-center justify-center h-full">
|
{/* Debug Information Section */}
|
||||||
<Loader2 className="h-8 w-8 animate-spin" />
|
<div className="mb-4 flex-shrink-0">
|
||||||
</div>
|
{currentPrompt.isLoading ? (
|
||||||
) : (
|
<div className="flex justify-center items-center h-[100px]"></div>
|
||||||
<Code className="whitespace-pre-wrap p-4">{currentPrompt.prompt}</Code>
|
) : (
|
||||||
)}
|
<div className="grid grid-cols-3 gap-4">
|
||||||
</ScrollArea>
|
<Card className="py-2">
|
||||||
|
<CardHeader className="py-2">
|
||||||
|
<CardTitle className="text-base">Prompt Length</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="py-2">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Characters:
|
||||||
|
</span>{" "}
|
||||||
|
<span className="font-semibold">{promptLength}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Tokens:</span>{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
~{Math.round(promptLength / 4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="py-2">
|
||||||
|
<CardHeader className="py-2">
|
||||||
|
<CardTitle className="text-base">Cost Estimate</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="py-2">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<label
|
||||||
|
htmlFor="costPerMillion"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
$
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="costPerMillion"
|
||||||
|
className="w-[40px] px-1 border rounded-md text-sm"
|
||||||
|
defaultValue={costPerMillionTokens.toFixed(2)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
if (!isNaN(value)) {
|
||||||
|
setCostPerMillionTokens(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="costPerMillion"
|
||||||
|
className="text-sm text-muted-foreground ml-1"
|
||||||
|
>
|
||||||
|
per million input tokens
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Cost:</span>{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{calculateTokenCost(promptLength).toFixed(1)}¢
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="py-2">
|
||||||
|
<CardHeader className="py-2">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Processing Time
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="py-2">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
{debugData?.estimatedProcessingTime ? (
|
||||||
|
debugData.estimatedProcessingTime.seconds ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Estimated time:
|
||||||
|
</span>{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{formatTime(
|
||||||
|
debugData.estimatedProcessingTime.seconds
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Based on{" "}
|
||||||
|
{debugData.estimatedProcessingTime.sampleCount}{" "}
|
||||||
|
similar validation
|
||||||
|
{debugData.estimatedProcessingTime
|
||||||
|
.sampleCount !== 1
|
||||||
|
? "s"
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No historical data available for this prompt size
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No processing time data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt Section */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<ScrollArea className="h-full w-full">
|
||||||
|
{currentPrompt.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Code className="whitespace-pre-wrap p-4 break-normal max-w-full">
|
||||||
|
{currentPrompt.prompt}
|
||||||
|
</Code>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
@@ -64,7 +264,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
// Only allow closing if validation failed
|
// Only allow closing if validation failed
|
||||||
if (!open && aiValidationProgress.step === -1) {
|
if (!open && aiValidationProgress.step === -1) {
|
||||||
setAiValidationProgress(prev => ({ ...prev, isOpen: false }));
|
setAiValidationProgress((prev) => ({ ...prev, isOpen: false }));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -79,14 +279,25 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all duration-500"
|
className="h-full bg-primary transition-all duration-500"
|
||||||
style={{
|
style={{
|
||||||
width: `${aiValidationProgress.progressPercent ?? Math.round((aiValidationProgress.step / 5) * 100)}%`,
|
width: `${
|
||||||
backgroundColor: aiValidationProgress.step === -1 ? 'var(--destructive)' : undefined
|
aiValidationProgress.progressPercent ??
|
||||||
|
Math.round((aiValidationProgress.step / 5) * 100)
|
||||||
|
}%`,
|
||||||
|
backgroundColor:
|
||||||
|
aiValidationProgress.step === -1
|
||||||
|
? "var(--destructive)"
|
||||||
|
: undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground w-12 text-right">
|
<div className="text-sm text-muted-foreground w-12 text-right">
|
||||||
{aiValidationProgress.step === -1 ? '❌' : `${aiValidationProgress.progressPercent ?? Math.round((aiValidationProgress.step / 5) * 100)}%`}
|
{aiValidationProgress.step === -1
|
||||||
|
? "❌"
|
||||||
|
: `${
|
||||||
|
aiValidationProgress.progressPercent ??
|
||||||
|
Math.round((aiValidationProgress.step / 5) * 100)
|
||||||
|
}%`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
@@ -94,32 +305,43 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
{(() => {
|
{(() => {
|
||||||
// Only show time remaining if we have an estimate and are in progress
|
// Only show time remaining if we have an estimate and are in progress
|
||||||
return aiValidationProgress.estimatedSeconds &&
|
return (
|
||||||
|
aiValidationProgress.estimatedSeconds &&
|
||||||
aiValidationProgress.elapsedSeconds !== undefined &&
|
aiValidationProgress.elapsedSeconds !== undefined &&
|
||||||
aiValidationProgress.step > 0 &&
|
aiValidationProgress.step > 0 &&
|
||||||
aiValidationProgress.step < 5 && (
|
aiValidationProgress.step < 5 && (
|
||||||
<div className="text-center text-sm">
|
<div className="text-center text-sm">
|
||||||
{(() => {
|
{(() => {
|
||||||
// Calculate time remaining using the elapsed seconds
|
// Calculate time remaining using the elapsed seconds
|
||||||
const elapsedSeconds = aiValidationProgress.elapsedSeconds;
|
const elapsedSeconds =
|
||||||
const totalEstimatedSeconds = aiValidationProgress.estimatedSeconds;
|
aiValidationProgress.elapsedSeconds;
|
||||||
const remainingSeconds = Math.max(0, totalEstimatedSeconds - elapsedSeconds);
|
const totalEstimatedSeconds =
|
||||||
|
aiValidationProgress.estimatedSeconds;
|
||||||
|
const remainingSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
totalEstimatedSeconds - elapsedSeconds
|
||||||
|
);
|
||||||
|
|
||||||
// Format time remaining
|
// Format time remaining
|
||||||
if (remainingSeconds < 60) {
|
if (remainingSeconds < 60) {
|
||||||
return `Approximately ${Math.round(remainingSeconds)} seconds remaining`;
|
return `Approximately ${Math.round(
|
||||||
} else {
|
remainingSeconds
|
||||||
const minutes = Math.floor(remainingSeconds / 60);
|
)} seconds remaining`;
|
||||||
const seconds = Math.round(remainingSeconds % 60);
|
} else {
|
||||||
return `Approximately ${minutes}m ${seconds}s remaining`;
|
const minutes = Math.floor(remainingSeconds / 60);
|
||||||
}
|
const seconds = Math.round(remainingSeconds % 60);
|
||||||
})()}
|
return `Approximately ${minutes}m ${seconds}s remaining`;
|
||||||
{aiValidationProgress.promptLength && (
|
}
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
})()}
|
||||||
Prompt length: {aiValidationProgress.promptLength.toLocaleString()} characters
|
{aiValidationProgress.promptLength && (
|
||||||
</p>
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
)}
|
Prompt length:{" "}
|
||||||
</div>
|
{aiValidationProgress.promptLength.toLocaleString()}{" "}
|
||||||
|
characters
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
@@ -129,7 +351,9 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
{/* AI Validation Results Dialog */}
|
{/* AI Validation Results Dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={aiValidationDetails.isOpen}
|
open={aiValidationDetails.isOpen}
|
||||||
onOpenChange={(open) => setAiValidationDetails(prev => ({ ...prev, isOpen: open }))}
|
onOpenChange={(open) =>
|
||||||
|
setAiValidationDetails((prev) => ({ ...prev, isOpen: open }))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -139,13 +363,18 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<ScrollArea className="max-h-[60vh]">
|
<ScrollArea className="max-h-[60vh]">
|
||||||
{aiValidationDetails.changeDetails && aiValidationDetails.changeDetails.length > 0 ? (
|
{aiValidationDetails.changeDetails &&
|
||||||
|
aiValidationDetails.changeDetails.length > 0 ? (
|
||||||
<div className="mb-6 space-y-6">
|
<div className="mb-6 space-y-6">
|
||||||
<h3 className="font-semibold text-lg">Detailed Changes:</h3>
|
<h3 className="font-semibold text-lg">Detailed Changes:</h3>
|
||||||
{aiValidationDetails.changeDetails.map((product, i) => {
|
{aiValidationDetails.changeDetails.map((product, i) => {
|
||||||
// Find the title change if it exists
|
// Find the title change if it exists
|
||||||
const titleChange = product.changes.find(c => c.field === 'title');
|
const titleChange = product.changes.find(
|
||||||
const titleValue = titleChange ? titleChange.corrected : product.title;
|
(c) => c.field === "title"
|
||||||
|
);
|
||||||
|
const titleValue = titleChange
|
||||||
|
? titleChange.corrected
|
||||||
|
: product.title;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`product-${i}`} className="border rounded-md p-4">
|
<div key={`product-${i}`} className="border rounded-md p-4">
|
||||||
@@ -163,29 +392,43 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{product.changes.map((change, j) => {
|
{product.changes.map((change, j) => {
|
||||||
const field = fields.find(f => f.key === change.field);
|
const field = fields.find(
|
||||||
const fieldLabel = field ? field.label : change.field;
|
(f) => f.key === change.field
|
||||||
const isReverted = isChangeReverted(product.productIndex, change.field);
|
);
|
||||||
|
const fieldLabel = field
|
||||||
|
? field.label
|
||||||
|
: change.field;
|
||||||
|
const isReverted = isChangeReverted(
|
||||||
|
product.productIndex,
|
||||||
|
change.field
|
||||||
|
);
|
||||||
|
|
||||||
// Get highlighted differences
|
// Get highlighted differences
|
||||||
const { originalHtml, correctedHtml } = getFieldDisplayValueWithHighlight(
|
const { originalHtml, correctedHtml } =
|
||||||
change.field,
|
getFieldDisplayValueWithHighlight(
|
||||||
change.original,
|
change.field,
|
||||||
change.corrected
|
change.original,
|
||||||
);
|
change.corrected
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={`change-${j}`}>
|
<TableRow key={`change-${j}`}>
|
||||||
<TableCell className="font-medium">{fieldLabel}</TableCell>
|
<TableCell className="font-medium">
|
||||||
|
{fieldLabel}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div
|
<div
|
||||||
dangerouslySetInnerHTML={{ __html: originalHtml }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: originalHtml,
|
||||||
|
}}
|
||||||
className={isReverted ? "font-medium" : ""}
|
className={isReverted ? "font-medium" : ""}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div
|
<div
|
||||||
dangerouslySetInnerHTML={{ __html: correctedHtml }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: correctedHtml,
|
||||||
|
}}
|
||||||
className={!isReverted ? "font-medium" : ""}
|
className={!isReverted ? "font-medium" : ""}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -207,7 +450,10 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Call the revert function directly
|
// Call the revert function directly
|
||||||
revertAiChange(product.productIndex, change.field);
|
revertAiChange(
|
||||||
|
product.productIndex,
|
||||||
|
change.field
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Revert Change
|
Revert Change
|
||||||
@@ -226,12 +472,17 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-8 text-center text-muted-foreground">
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
{aiValidationDetails.warnings && aiValidationDetails.warnings.length > 0 ? (
|
{aiValidationDetails.warnings &&
|
||||||
|
aiValidationDetails.warnings.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-4">No changes were made, but the AI provided some warnings:</p>
|
<p className="mb-4">
|
||||||
|
No changes were made, but the AI provided some warnings:
|
||||||
|
</p>
|
||||||
<ul className="list-disc pl-8 text-left">
|
<ul className="list-disc pl-8 text-left">
|
||||||
{aiValidationDetails.warnings.map((warning, i) => (
|
{aiValidationDetails.warnings.map((warning, i) => (
|
||||||
<li key={`warning-${i}`} className="mb-2">{warning}</li>
|
<li key={`warning-${i}`} className="mb-2">
|
||||||
|
{warning}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { Template } from '../hooks/useValidationState'
|
import { Template } from '../hooks/validationTypes'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -50,7 +50,6 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
|||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
|
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Set default brand when component mounts or defaultBrand changes
|
// Set default brand when component mounts or defaultBrand changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useMemo } from 'react'
|
|||||||
import ValidationTable from './ValidationTable'
|
import ValidationTable from './ValidationTable'
|
||||||
import { RowSelectionState } from '@tanstack/react-table'
|
import { RowSelectionState } from '@tanstack/react-table'
|
||||||
import { Fields } from '../../../types'
|
import { Fields } from '../../../types'
|
||||||
import { Template } from '../hooks/useValidationState'
|
import { Template } from '../hooks/validationTypes'
|
||||||
|
|
||||||
interface UpcValidationTableAdapterProps<T extends string> {
|
interface UpcValidationTableAdapterProps<T extends string> {
|
||||||
data: any[]
|
data: any[]
|
||||||
@@ -28,6 +28,7 @@ interface UpcValidationTableAdapterProps<T extends string> {
|
|||||||
validatingRows: Set<number>
|
validatingRows: Set<number>
|
||||||
getItemNumber: (rowIndex: number) => string | undefined
|
getItemNumber: (rowIndex: number) => string | undefined
|
||||||
}
|
}
|
||||||
|
itemNumbers?: Map<number, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,75 +57,79 @@ function UpcValidationTableAdapter<T extends string>({
|
|||||||
rowSublines,
|
rowSublines,
|
||||||
isLoadingLines,
|
isLoadingLines,
|
||||||
isLoadingSublines,
|
isLoadingSublines,
|
||||||
upcValidation
|
upcValidation,
|
||||||
|
itemNumbers
|
||||||
}: UpcValidationTableAdapterProps<T>) {
|
}: UpcValidationTableAdapterProps<T>) {
|
||||||
// Prepare the validation table with UPC data
|
// Prepare the validation table with UPC data
|
||||||
const AdaptedTable = useMemo(() => React.memo((props: React.ComponentProps<typeof ValidationTable>) => {
|
|
||||||
// Create validatingCells set from validating rows, but only for item_number fields
|
// Create combined validatingCells set from validating rows and external cells
|
||||||
// This ensures only the item_number column shows loading state during UPC validation
|
const combinedValidatingCells = useMemo(() => {
|
||||||
const combinedValidatingCells = new Set<string>();
|
const combined = new Set<string>();
|
||||||
|
|
||||||
// Add UPC validation cells
|
// Add UPC validation cells
|
||||||
upcValidation.validatingRows.forEach(rowIndex => {
|
upcValidation.validatingRows.forEach(rowIndex => {
|
||||||
// Only mark the item_number cells as validating, NOT the UPC or supplier
|
// Only mark the item_number cells as validating, NOT the UPC or supplier
|
||||||
combinedValidatingCells.add(`${rowIndex}-item_number`);
|
combined.add(`${rowIndex}-item_number`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add any other validating cells from state
|
// Add any other validating cells from state
|
||||||
externalValidatingCells.forEach(cellKey => {
|
externalValidatingCells.forEach(cellKey => {
|
||||||
combinedValidatingCells.add(cellKey);
|
combined.add(cellKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert the Map to the expected format for the ValidationTable
|
return combined;
|
||||||
// Create a new Map from the item numbers to ensure proper typing
|
}, [upcValidation.validatingRows, externalValidatingCells]);
|
||||||
const itemNumbersMap = new Map<number, string>();
|
|
||||||
|
|
||||||
// Merge the item numbers with the data for display purposes only
|
// Create a consolidated item numbers map from all sources
|
||||||
const enhancedData = props.data.map((row: any, index: number) => {
|
const consolidatedItemNumbers = useMemo(() => {
|
||||||
const itemNumber = upcValidation.getItemNumber(index);
|
const result = new Map<number, string>();
|
||||||
if (itemNumber) {
|
|
||||||
// Add to our map for proper prop passing
|
|
||||||
itemNumbersMap.set(index, itemNumber);
|
|
||||||
|
|
||||||
return {
|
// First add from itemNumbers directly - this is the source of truth for template applications
|
||||||
...row,
|
if (itemNumbers) {
|
||||||
item_number: itemNumber
|
// Log all numbers for debugging
|
||||||
};
|
console.log(`[ADAPTER-DEBUG] Received itemNumbers map with ${itemNumbers.size} entries`);
|
||||||
}
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a Map for upcValidationResults with the same structure expected by ValidationTable
|
itemNumbers.forEach((itemNumber, rowIndex) => {
|
||||||
const upcValidationResultsMap = new Map<number, { itemNumber: string }>();
|
console.log(`[ADAPTER-DEBUG] Adding item number for row ${rowIndex} from itemNumbers: ${itemNumber}`);
|
||||||
|
result.set(rowIndex, itemNumber);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Populate with any item numbers we have from validation
|
// For each row, ensure we have the most up-to-date item number
|
||||||
data.forEach((_, index) => {
|
data.forEach((_, index) => {
|
||||||
|
// Check if upcValidation has an item number for this row
|
||||||
const itemNumber = upcValidation.getItemNumber(index);
|
const itemNumber = upcValidation.getItemNumber(index);
|
||||||
if (itemNumber) {
|
if (itemNumber) {
|
||||||
upcValidationResultsMap.set(index, { 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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return result;
|
||||||
<ValidationTable
|
}, [data, itemNumbers, upcValidation]);
|
||||||
{...props}
|
|
||||||
data={enhancedData}
|
// Create upcValidationResults map using the consolidated item numbers
|
||||||
validatingCells={combinedValidatingCells}
|
const upcValidationResults = useMemo(() => {
|
||||||
itemNumbers={itemNumbersMap}
|
const results = new Map<number, { itemNumber: string }>();
|
||||||
isLoadingTemplates={isLoadingTemplates}
|
|
||||||
copyDown={copyDown}
|
// Populate with our consolidated item numbers
|
||||||
rowProductLines={rowProductLines}
|
consolidatedItemNumbers.forEach((itemNumber, rowIndex) => {
|
||||||
rowSublines={rowSublines}
|
results.set(rowIndex, { itemNumber });
|
||||||
isLoadingLines={isLoadingLines}
|
});
|
||||||
isLoadingSublines={isLoadingSublines}
|
|
||||||
upcValidationResults={upcValidationResultsMap}
|
return results;
|
||||||
/>
|
}, [consolidatedItemNumbers]);
|
||||||
);
|
|
||||||
}), [upcValidation.validatingRows, upcValidation.getItemNumber, isLoadingTemplates, copyDown, externalValidatingCells, rowProductLines, rowSublines, isLoadingLines, isLoadingSublines]);
|
|
||||||
|
|
||||||
// Render the validation table with the provided props and UPC data
|
// Render the validation table with the provided props and UPC data
|
||||||
return (
|
return (
|
||||||
<AdaptedTable
|
<ValidationTable
|
||||||
data={data}
|
data={data}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
@@ -137,11 +142,11 @@ function UpcValidationTableAdapter<T extends string>({
|
|||||||
templates={templates}
|
templates={templates}
|
||||||
applyTemplate={applyTemplate}
|
applyTemplate={applyTemplate}
|
||||||
getTemplateDisplayText={getTemplateDisplayText}
|
getTemplateDisplayText={getTemplateDisplayText}
|
||||||
validatingCells={new Set()}
|
validatingCells={combinedValidatingCells}
|
||||||
itemNumbers={new Map()}
|
itemNumbers={consolidatedItemNumbers}
|
||||||
isLoadingTemplates={isLoadingTemplates}
|
isLoadingTemplates={isLoadingTemplates}
|
||||||
copyDown={copyDown}
|
copyDown={copyDown}
|
||||||
upcValidationResults={new Map<number, { itemNumber: string }>()}
|
upcValidationResults={upcValidationResults}
|
||||||
rowProductLines={rowProductLines}
|
rowProductLines={rowProductLines}
|
||||||
rowSublines={rowSublines}
|
rowSublines={rowSublines}
|
||||||
isLoadingLines={isLoadingLines}
|
isLoadingLines={isLoadingLines}
|
||||||
|
|||||||
@@ -293,8 +293,18 @@ const ValidationCell = React.memo(({
|
|||||||
// Use the CopyDown context
|
// Use the CopyDown context
|
||||||
const copyDownContext = React.useContext(CopyDownContext);
|
const copyDownContext = React.useContext(CopyDownContext);
|
||||||
|
|
||||||
// Display value prioritizes itemNumber if available (for item_number fields)
|
// CRITICAL FIX: For item_number fields, always prioritize the itemNumber prop over the value
|
||||||
const displayValue = fieldKey === 'item_number' && itemNumber ? itemNumber : value;
|
// 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 {
|
||||||
|
displayValue = value;
|
||||||
|
}
|
||||||
|
|
||||||
// Use the optimized processErrors function to avoid redundant filtering
|
// Use the optimized processErrors function to avoid redundant filtering
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react'
|
||||||
import { useValidationState, Props } from '../hooks/useValidationState'
|
import { useValidationState } from '../hooks/useValidationState'
|
||||||
|
import { Props } from '../hooks/validationTypes'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Loader2, X, Plus, Edit3, Sparkles, FileText } from 'lucide-react'
|
import { Loader2, X, Plus, Edit3, Sparkles, FileText } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -17,6 +18,7 @@ import { useUpcValidation } from '../hooks/useUpcValidation'
|
|||||||
import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
|
import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
|
||||||
import UpcValidationTableAdapter from './UpcValidationTableAdapter'
|
import UpcValidationTableAdapter from './UpcValidationTableAdapter'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Protected } from '@/components/auth/Protected'
|
||||||
/**
|
/**
|
||||||
* ValidationContainer component - the main wrapper for the validation step
|
* ValidationContainer component - the main wrapper for the validation step
|
||||||
*
|
*
|
||||||
@@ -57,7 +59,10 @@ const ValidationContainer = <T extends string>({
|
|||||||
loadTemplates,
|
loadTemplates,
|
||||||
setData,
|
setData,
|
||||||
fields,
|
fields,
|
||||||
isLoadingTemplates } = validationState
|
isLoadingTemplates,
|
||||||
|
validatingCells,
|
||||||
|
setValidatingCells
|
||||||
|
} = validationState
|
||||||
|
|
||||||
// Use product lines fetching hook
|
// Use product lines fetching hook
|
||||||
const {
|
const {
|
||||||
@@ -69,9 +74,6 @@ const ValidationContainer = <T extends string>({
|
|||||||
fetchSublines
|
fetchSublines
|
||||||
} = useProductLinesFetching(data);
|
} = useProductLinesFetching(data);
|
||||||
|
|
||||||
// Add state for tracking cells in loading state
|
|
||||||
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Use UPC validation hook
|
// Use UPC validation hook
|
||||||
const upcValidation = useUpcValidation(data, setData);
|
const upcValidation = useUpcValidation(data, setData);
|
||||||
|
|
||||||
@@ -958,6 +960,7 @@ const ValidationContainer = <T extends string>({
|
|||||||
isLoadingLines={isLoadingLines}
|
isLoadingLines={isLoadingLines}
|
||||||
isLoadingSublines={isLoadingSublines}
|
isLoadingSublines={isLoadingSublines}
|
||||||
upcValidation={upcValidation}
|
upcValidation={upcValidation}
|
||||||
|
itemNumbers={upcValidation.itemNumbers}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
@@ -1147,15 +1150,17 @@ const ValidationContainer = <T extends string>({
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Show Prompt Button */}
|
{/* Show Prompt Button */}
|
||||||
<Button
|
<Protected permission="admin:debug">
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={aiValidation.showCurrentPrompt}
|
variant="outline"
|
||||||
disabled={aiValidation.isAiValidating}
|
onClick={aiValidation.showCurrentPrompt}
|
||||||
className="flex items-center gap-1"
|
disabled={aiValidation.isAiValidating}
|
||||||
>
|
className="flex items-center gap-1"
|
||||||
<FileText className="h-4 w-4" />
|
>
|
||||||
Show Prompt
|
<FileText className="h-4 w-4" />
|
||||||
</Button>
|
Show Prompt
|
||||||
|
</Button>
|
||||||
|
</Protected>
|
||||||
|
|
||||||
{/* AI Validate Button */}
|
{/* AI Validate Button */}
|
||||||
<Button
|
<Button
|
||||||
@@ -1198,6 +1203,7 @@ const ValidationContainer = <T extends string>({
|
|||||||
isChangeReverted={aiValidation.isChangeReverted}
|
isChangeReverted={aiValidation.isChangeReverted}
|
||||||
getFieldDisplayValueWithHighlight={aiValidation.getFieldDisplayValueWithHighlight}
|
getFieldDisplayValueWithHighlight={aiValidation.getFieldDisplayValueWithHighlight}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
debugData={aiValidation.currentPrompt.debugData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Product Search Dialog */}
|
{/* Product Search Dialog */}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
ColumnDef
|
ColumnDef
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
import { Fields, Field } from '../../../types'
|
import { Fields, Field } from '../../../types'
|
||||||
import { RowData, Template } from '../hooks/useValidationState'
|
import { RowData, Template } from '../hooks/validationTypes'
|
||||||
import ValidationCell, { CopyDownContext } from './ValidationCell'
|
import ValidationCell, { CopyDownContext } from './ValidationCell'
|
||||||
import { useRsi } from '../../../hooks/useRsi'
|
import { useRsi } from '../../../hooks/useRsi'
|
||||||
import SearchableTemplateSelect from './SearchableTemplateSelect'
|
import SearchableTemplateSelect from './SearchableTemplateSelect'
|
||||||
@@ -138,11 +138,15 @@ const MemoizedCell = React.memo(({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, (prev, next) => {
|
}, (prev, next) => {
|
||||||
|
// CRITICAL FIX: Never memoize item_number cells - always re-render them
|
||||||
|
if (prev.fieldKey === 'item_number') {
|
||||||
|
return false; // Never skip re-renders for item_number cells
|
||||||
|
}
|
||||||
|
|
||||||
// Optimize the memo comparison function for better performance
|
// Optimize the memo comparison function for better performance
|
||||||
// Only re-render if these essential props change
|
// Only re-render if these essential props change
|
||||||
const valueEqual = prev.value === next.value;
|
const valueEqual = prev.value === next.value;
|
||||||
const isValidatingEqual = prev.isValidating === next.isValidating;
|
const isValidatingEqual = prev.isValidating === next.isValidating;
|
||||||
const itemNumberEqual = prev.itemNumber === next.itemNumber;
|
|
||||||
|
|
||||||
// Shallow equality check for errors array
|
// Shallow equality check for errors array
|
||||||
const errorsEqual = prev.errors === next.errors || (
|
const errorsEqual = prev.errors === next.errors || (
|
||||||
@@ -161,7 +165,7 @@ const MemoizedCell = React.memo(({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Skip checking for props that rarely change
|
// Skip checking for props that rarely change
|
||||||
return valueEqual && isValidatingEqual && errorsEqual && optionsEqual && itemNumberEqual;
|
return valueEqual && isValidatingEqual && errorsEqual && optionsEqual;
|
||||||
});
|
});
|
||||||
|
|
||||||
MemoizedCell.displayName = 'MemoizedCell';
|
MemoizedCell.displayName = 'MemoizedCell';
|
||||||
@@ -335,10 +339,28 @@ const ValidationTable = <T extends string>({
|
|||||||
return isValidatingUpc?.(rowIndex) || validatingUpcRows.includes(rowIndex);
|
return isValidatingUpc?.(rowIndex) || validatingUpcRows.includes(rowIndex);
|
||||||
}, [isValidatingUpc, validatingUpcRows]);
|
}, [isValidatingUpc, validatingUpcRows]);
|
||||||
|
|
||||||
// Use upcValidationResults for display
|
// Use upcValidationResults for display, prioritizing the most recent values
|
||||||
const getRowUpcResult = useCallback((rowIndex: number) => {
|
const getRowUpcResult = useCallback((rowIndex: number) => {
|
||||||
return upcValidationResults?.get(rowIndex)?.itemNumber;
|
// ALWAYS get from the data array directly - most authoritative source
|
||||||
}, [upcValidationResults]);
|
const rowData = data[rowIndex];
|
||||||
|
if (rowData && rowData.item_number) {
|
||||||
|
return rowData.item_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps are only backup sources when data doesn't have a value
|
||||||
|
const itemNumberFromMap = itemNumbers.get(rowIndex);
|
||||||
|
if (itemNumberFromMap) {
|
||||||
|
return itemNumberFromMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort - upcValidationResults
|
||||||
|
const upcResult = upcValidationResults?.get(rowIndex)?.itemNumber;
|
||||||
|
if (upcResult) {
|
||||||
|
return upcResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [data, itemNumbers, upcValidationResults]);
|
||||||
|
|
||||||
// Memoize field columns with stable handlers
|
// Memoize field columns with stable handlers
|
||||||
const fieldColumns = useMemo(() => fields.map((field): ColumnDef<RowData<T>, any> | null => {
|
const fieldColumns = useMemo(() => fields.map((field): ColumnDef<RowData<T>, any> | null => {
|
||||||
@@ -411,26 +433,34 @@ const ValidationTable = <T extends string>({
|
|||||||
disabled: false
|
disabled: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
console.log(`Field ${fieldKey} in ValidationTable (after deep clone):`, {
|
|
||||||
originalField: field,
|
|
||||||
modifiedField: fieldWithType,
|
|
||||||
options,
|
|
||||||
hasOptions: options && options.length > 0,
|
|
||||||
disabled: fieldWithType.disabled
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get item number from UPC validation results if available
|
// CRITICAL CHANGE: Get item number directly from row data first for item_number fields
|
||||||
let itemNumber = itemNumbers.get(row.index);
|
let itemNumber;
|
||||||
if (!itemNumber && fieldKey === 'item_number') {
|
if (fieldKey === 'item_number') {
|
||||||
itemNumber = getRowUpcResult(row.index);
|
// Check directly in row data first - this is the most accurate source
|
||||||
|
const directValue = row.original[fieldKey];
|
||||||
|
if (directValue) {
|
||||||
|
itemNumber = directValue;
|
||||||
|
} else {
|
||||||
|
// Fall back to centralized getter that checks all sources
|
||||||
|
itemNumber = getRowUpcResult(row.index);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CRITICAL: For item_number fields, create a unique key that includes the itemNumber value
|
||||||
|
// This forces a complete re-render when the itemNumber 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}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MemoizedCell
|
<MemoizedCell
|
||||||
|
key={cellKey} // CRITICAL: Add key to force re-render when itemNumber changes
|
||||||
field={fieldWithType as Field<string>}
|
field={fieldWithType as Field<string>}
|
||||||
value={row.original[field.key as keyof typeof row.original]}
|
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]}
|
||||||
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
|
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
|
||||||
errors={cellErrors}
|
errors={cellErrors}
|
||||||
isValidating={isLoading}
|
isValidating={isLoading}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getApiUrl, RowData } from './useValidationState';
|
import { getApiUrl, RowData } from './validationTypes';
|
||||||
import { Fields } from '../../../types';
|
import { Fields } from '../../../types';
|
||||||
import { Meta } from '../types';
|
import { Meta } from '../types';
|
||||||
import { addErrorsAndRunHooks } from '../utils/dataMutations';
|
import { addErrorsAndRunHooks } from '../utils/dataMutations';
|
||||||
@@ -42,6 +42,25 @@ export interface CurrentPrompt {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
prompt: string | null;
|
prompt: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
debugData?: {
|
||||||
|
taxonomyStats: {
|
||||||
|
categories: number;
|
||||||
|
themes: number;
|
||||||
|
colors: number;
|
||||||
|
taxCodes: number;
|
||||||
|
sizeCategories: number;
|
||||||
|
suppliers: number;
|
||||||
|
companies: number;
|
||||||
|
artists: number;
|
||||||
|
} | null;
|
||||||
|
basePrompt: string;
|
||||||
|
sampleFullPrompt: string;
|
||||||
|
promptLength: number;
|
||||||
|
estimatedProcessingTime?: {
|
||||||
|
seconds: number | null;
|
||||||
|
sampleCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Declare global interface for the timer
|
// Declare global interface for the timer
|
||||||
@@ -250,7 +269,11 @@ export const useAiValidation = <T extends string>(
|
|||||||
// Function to show current prompt
|
// Function to show current prompt
|
||||||
const showCurrentPrompt = useCallback(async () => {
|
const showCurrentPrompt = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setCurrentPrompt(prev => ({ ...prev, isLoading: true, isOpen: true }));
|
setCurrentPrompt(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: true,
|
||||||
|
isOpen: true
|
||||||
|
}));
|
||||||
|
|
||||||
// Debug log the data being sent
|
// Debug log the data being sent
|
||||||
console.log('Sending products data:', {
|
console.log('Sending products data:', {
|
||||||
@@ -272,7 +295,7 @@ export const useAiValidation = <T extends string>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Use POST to send products in request body
|
// Use POST to send products in request body
|
||||||
const response = await fetch(`${getApiUrl()}/ai-validation/debug`, {
|
const response = await fetch(`${await getApiUrl()}/ai-validation/debug`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -294,7 +317,14 @@ export const useAiValidation = <T extends string>(
|
|||||||
setCurrentPrompt(prev => ({
|
setCurrentPrompt(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
prompt: promptContent,
|
prompt: promptContent,
|
||||||
isLoading: false
|
isLoading: false,
|
||||||
|
debugData: {
|
||||||
|
taxonomyStats: result.taxonomyStats || null,
|
||||||
|
basePrompt: result.basePrompt || '',
|
||||||
|
sampleFullPrompt: result.sampleFullPrompt || '',
|
||||||
|
promptLength: result.promptLength || (promptContent ? promptContent.length : 0),
|
||||||
|
estimatedProcessingTime: result.estimatedProcessingTime
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No prompt returned from server');
|
throw new Error('No prompt returned from server');
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import type { Field, Fields, RowHook } from '../../../types';
|
||||||
|
import type { Meta } from '../types';
|
||||||
|
import { ErrorType, ValidationError } from '../../../types';
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a special function to clear all uniqueness validation caches
|
||||||
|
export const clearAllUniquenessCaches = () => {
|
||||||
|
// Clear cache for common unique fields
|
||||||
|
['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'].forEach(fieldKey => {
|
||||||
|
clearValidationCacheForField(fieldKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also clear any cache entries that might involve uniqueness validation
|
||||||
|
validationResultCache.forEach((_, key) => {
|
||||||
|
if (key.includes('unique')) {
|
||||||
|
validationResultCache.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFieldValidation = <T extends string>(
|
||||||
|
fields: Fields<T>,
|
||||||
|
rowHook?: RowHook<T>
|
||||||
|
) => {
|
||||||
|
// Validate a single field
|
||||||
|
const validateField = useCallback((
|
||||||
|
value: any,
|
||||||
|
field: Field<T>
|
||||||
|
): ValidationError[] => {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
if (!field.validations) return errors;
|
||||||
|
|
||||||
|
// Create a cache key using field key, value, and validation rules
|
||||||
|
const cacheKey = `${field.key}-${String(value)}-${JSON.stringify(field.validations)}`;
|
||||||
|
|
||||||
|
// Check cache first to avoid redundant validation
|
||||||
|
if (validationResultCache.has(cacheKey)) {
|
||||||
|
return validationResultCache.get(cacheKey) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
field.validations.forEach(validation => {
|
||||||
|
switch (validation.rule) {
|
||||||
|
case 'required':
|
||||||
|
// Use the shared isEmpty function
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
errors.push({
|
||||||
|
message: validation.errorMessage || 'This field is required',
|
||||||
|
level: validation.level || 'error',
|
||||||
|
type: ErrorType.Required
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'unique':
|
||||||
|
// Unique validation happens at table level, not here
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'regex':
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(validation.value, validation.flags);
|
||||||
|
if (!regex.test(String(value))) {
|
||||||
|
errors.push({
|
||||||
|
message: validation.errorMessage,
|
||||||
|
level: validation.level || 'error',
|
||||||
|
type: ErrorType.Regex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Invalid regex in validation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store results in cache to speed up future validations
|
||||||
|
validationResultCache.set(cacheKey, errors);
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Validate a single row
|
||||||
|
const validateRow = useCallback(async (
|
||||||
|
row: RowData<T>,
|
||||||
|
rowIndex: number,
|
||||||
|
allRows: RowData<T>[]
|
||||||
|
): Promise<Meta> => {
|
||||||
|
// Run field-level validations
|
||||||
|
const fieldErrors: Record<string, ValidationError[]> = {};
|
||||||
|
|
||||||
|
fields.forEach(field => {
|
||||||
|
const value = row[String(field.key) as keyof typeof row];
|
||||||
|
const errors = validateField(value, field as Field<T>);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
fieldErrors[String(field.key)] = errors;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Special validation for supplier and company fields - only apply if the field exists in fields
|
||||||
|
if (fields.some(field => String(field.key) === 'supplier') && isEmpty(row.supplier)) {
|
||||||
|
fieldErrors['supplier'] = [{
|
||||||
|
message: 'Supplier is required',
|
||||||
|
level: 'error',
|
||||||
|
type: ErrorType.Required
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.some(field => String(field.key) === 'company') && isEmpty(row.company)) {
|
||||||
|
fieldErrors['company'] = [{
|
||||||
|
message: 'Company is required',
|
||||||
|
level: 'error',
|
||||||
|
type: ErrorType.Required
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run row hook if provided
|
||||||
|
let rowHookResult: Meta = {
|
||||||
|
__index: row.__index || String(rowIndex)
|
||||||
|
};
|
||||||
|
if (rowHook) {
|
||||||
|
try {
|
||||||
|
// Call the row hook and extract only the __index property
|
||||||
|
const result = await rowHook(row, rowIndex, allRows);
|
||||||
|
rowHookResult.__index = result.__index || rowHookResult.__index;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in row hook:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We no longer need to merge errors since we're not storing them in the row data
|
||||||
|
// The calling code should handle storing errors in the validationErrors Map
|
||||||
|
|
||||||
|
return {
|
||||||
|
__index: row.__index || String(rowIndex)
|
||||||
|
};
|
||||||
|
}, [fields, validateField, rowHook]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateField,
|
||||||
|
validateRow,
|
||||||
|
clearValidationCacheForField,
|
||||||
|
clearAllUniquenessCaches
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { FilterState, RowData } from './validationTypes';
|
||||||
|
import type { Fields } from '../../../types';
|
||||||
|
import { ValidationError } from '../../../types';
|
||||||
|
|
||||||
|
export const useFilterManagement = <T extends string>(
|
||||||
|
data: RowData<T>[],
|
||||||
|
fields: Fields<T>,
|
||||||
|
validationErrors: Map<number, Record<string, ValidationError[]>>
|
||||||
|
) => {
|
||||||
|
// Filter state
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
searchText: "",
|
||||||
|
showErrorsOnly: false,
|
||||||
|
filterField: null,
|
||||||
|
filterValue: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter data based on current filter state
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return data.filter((row, index) => {
|
||||||
|
// Filter by search text
|
||||||
|
if (filters.searchText) {
|
||||||
|
const searchLower = filters.searchText.toLowerCase();
|
||||||
|
const matchesSearch = fields.some((field) => {
|
||||||
|
const value = row[field.key as keyof typeof row];
|
||||||
|
if (value === undefined || value === null) return false;
|
||||||
|
return String(value).toLowerCase().includes(searchLower);
|
||||||
|
});
|
||||||
|
if (!matchesSearch) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by errors
|
||||||
|
if (filters.showErrorsOnly) {
|
||||||
|
const hasErrors =
|
||||||
|
validationErrors.has(index) &&
|
||||||
|
Object.keys(validationErrors.get(index) || {}).length > 0;
|
||||||
|
if (!hasErrors) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by field value
|
||||||
|
if (filters.filterField && filters.filterValue) {
|
||||||
|
const fieldValue = row[filters.filterField as keyof typeof row];
|
||||||
|
if (fieldValue === undefined) return false;
|
||||||
|
|
||||||
|
const valueStr = String(fieldValue).toLowerCase();
|
||||||
|
const filterStr = filters.filterValue.toLowerCase();
|
||||||
|
|
||||||
|
if (!valueStr.includes(filterStr)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [data, fields, filters, validationErrors]);
|
||||||
|
|
||||||
|
// Get filter fields
|
||||||
|
const filterFields = useMemo(() => {
|
||||||
|
return fields.map((field) => ({
|
||||||
|
key: String(field.key),
|
||||||
|
label: field.label,
|
||||||
|
}));
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// Get filter values for the selected field
|
||||||
|
const filterValues = useMemo(() => {
|
||||||
|
if (!filters.filterField) return [];
|
||||||
|
|
||||||
|
// Get unique values for the selected field
|
||||||
|
const uniqueValues = new Set<string>();
|
||||||
|
|
||||||
|
data.forEach((row) => {
|
||||||
|
const value = row[filters.filterField as keyof typeof row];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
uniqueValues.add(String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(uniqueValues).map((value) => ({
|
||||||
|
value,
|
||||||
|
label: value,
|
||||||
|
}));
|
||||||
|
}, [data, filters.filterField]);
|
||||||
|
|
||||||
|
// Update filters
|
||||||
|
const updateFilters = useCallback((newFilters: Partial<FilterState>) => {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...newFilters,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset filters
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
|
setFilters({
|
||||||
|
searchText: "",
|
||||||
|
showErrorsOnly: false,
|
||||||
|
filterField: null,
|
||||||
|
filterValue: null,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
filteredData,
|
||||||
|
filterFields,
|
||||||
|
filterValues,
|
||||||
|
updateFilters,
|
||||||
|
resetFilters
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { RowData } from './validationTypes';
|
||||||
|
import type { Field, Fields } from '../../../types';
|
||||||
|
import { ErrorType, ValidationError } from '../../../types';
|
||||||
|
|
||||||
|
export const useRowOperations = <T extends string>(
|
||||||
|
data: RowData<T>[],
|
||||||
|
fields: Fields<T>,
|
||||||
|
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
|
||||||
|
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
|
||||||
|
validateFieldFromHook: (value: any, field: Field<T>) => ValidationError[]
|
||||||
|
) => {
|
||||||
|
// Helper function to validate a field value
|
||||||
|
const fieldValidationHelper = useCallback(
|
||||||
|
(rowIndex: number, specificField?: string) => {
|
||||||
|
// Skip validation if row doesn't exist
|
||||||
|
if (rowIndex < 0 || rowIndex >= data.length) return;
|
||||||
|
|
||||||
|
// Get the row data
|
||||||
|
const row = data[rowIndex];
|
||||||
|
|
||||||
|
// If validating a specific field, only check that field
|
||||||
|
if (specificField) {
|
||||||
|
const field = fields.find((f) => String(f.key) === specificField);
|
||||||
|
if (field) {
|
||||||
|
const value = row[specificField as keyof typeof row];
|
||||||
|
|
||||||
|
// Use state setter instead of direct mutation
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const newErrors = new Map(prev);
|
||||||
|
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
|
||||||
|
|
||||||
|
// Quick check for required fields - this prevents flashing errors
|
||||||
|
const isRequired = field.validations?.some(
|
||||||
|
(v) => v.rule === "required"
|
||||||
|
);
|
||||||
|
const isEmpty =
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
value === "" ||
|
||||||
|
(Array.isArray(value) && value.length === 0) ||
|
||||||
|
(typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
Object.keys(value).length === 0);
|
||||||
|
|
||||||
|
// For non-empty values, remove required errors immediately
|
||||||
|
if (isRequired && !isEmpty && existingErrors[specificField]) {
|
||||||
|
const nonRequiredErrors = existingErrors[specificField].filter(
|
||||||
|
(e) => e.type !== ErrorType.Required
|
||||||
|
);
|
||||||
|
if (nonRequiredErrors.length === 0) {
|
||||||
|
// If no other errors, remove the field entirely from errors
|
||||||
|
delete existingErrors[specificField];
|
||||||
|
} else {
|
||||||
|
existingErrors[specificField] = nonRequiredErrors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run full validation for the field
|
||||||
|
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||||
|
|
||||||
|
// Update validation errors for this field
|
||||||
|
if (errors.length > 0) {
|
||||||
|
existingErrors[specificField] = errors;
|
||||||
|
} else {
|
||||||
|
delete existingErrors[specificField];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update validation errors map
|
||||||
|
if (Object.keys(existingErrors).length > 0) {
|
||||||
|
newErrors.set(rowIndex, existingErrors);
|
||||||
|
} else {
|
||||||
|
newErrors.delete(rowIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Validate all fields in the row
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const newErrors = new Map(prev);
|
||||||
|
const rowErrors: Record<string, ValidationError[]> = {};
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const fieldKey = String(field.key);
|
||||||
|
const value = row[fieldKey as keyof typeof row];
|
||||||
|
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
rowErrors[fieldKey] = errors;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update validation errors map
|
||||||
|
if (Object.keys(rowErrors).length > 0) {
|
||||||
|
newErrors.set(rowIndex, rowErrors);
|
||||||
|
} else {
|
||||||
|
newErrors.delete(rowIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data, fields, validateFieldFromHook, setValidationErrors]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use validateRow as an alias for fieldValidationHelper for compatibility
|
||||||
|
const validateRow = fieldValidationHelper;
|
||||||
|
|
||||||
|
// Modified updateRow function that properly handles field-specific validation
|
||||||
|
const updateRow = useCallback(
|
||||||
|
(rowIndex: number, key: T, value: any) => {
|
||||||
|
// Process value before updating data
|
||||||
|
let processedValue = value;
|
||||||
|
|
||||||
|
// Strip dollar signs from price fields
|
||||||
|
if (
|
||||||
|
(key === "msrp" || key === "cost_each") &&
|
||||||
|
typeof value === "string"
|
||||||
|
) {
|
||||||
|
processedValue = value.replace(/[$,]/g, "");
|
||||||
|
|
||||||
|
// Also ensure it's a valid number
|
||||||
|
const numValue = parseFloat(processedValue);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
processedValue = numValue.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the row data first
|
||||||
|
const rowData = data[rowIndex];
|
||||||
|
if (!rowData) {
|
||||||
|
console.error(`No row data found for index ${rowIndex}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a copy of the row to avoid mutation
|
||||||
|
const updatedRow = { ...rowData, [key]: processedValue };
|
||||||
|
|
||||||
|
// Update the data immediately - this sets the value
|
||||||
|
setData((prevData) => {
|
||||||
|
const newData = [...prevData];
|
||||||
|
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||||
|
newData[rowIndex] = updatedRow;
|
||||||
|
}
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the field definition
|
||||||
|
const field = fields.find((f) => String(f.key) === key);
|
||||||
|
if (!field) return;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
const existingErrors = newMap.get(rowIndex) || {};
|
||||||
|
const newRowErrors = { ...existingErrors };
|
||||||
|
|
||||||
|
// Check for required field first
|
||||||
|
const isRequired = field.validations?.some(
|
||||||
|
(v) => v.rule === "required"
|
||||||
|
);
|
||||||
|
const isEmpty =
|
||||||
|
processedValue === undefined ||
|
||||||
|
processedValue === null ||
|
||||||
|
processedValue === "" ||
|
||||||
|
(Array.isArray(processedValue) && processedValue.length === 0) ||
|
||||||
|
(typeof processedValue === "object" &&
|
||||||
|
processedValue !== null &&
|
||||||
|
Object.keys(processedValue).length === 0);
|
||||||
|
|
||||||
|
// For required fields with values, remove required errors
|
||||||
|
if (isRequired && !isEmpty && newRowErrors[key as string]) {
|
||||||
|
const hasRequiredError = newRowErrors[key as string].some(
|
||||||
|
(e) => e.type === ErrorType.Required
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasRequiredError) {
|
||||||
|
// Remove required errors but keep other types of errors
|
||||||
|
const nonRequiredErrors = newRowErrors[key as string].filter(
|
||||||
|
(e) => e.type !== ErrorType.Required
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nonRequiredErrors.length === 0) {
|
||||||
|
// If no other errors, delete the field's errors entirely
|
||||||
|
delete newRowErrors[key as string];
|
||||||
|
} else {
|
||||||
|
// Otherwise keep non-required errors
|
||||||
|
newRowErrors[key as string] = nonRequiredErrors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now run full validation for the field (except for required which we already handled)
|
||||||
|
const errors = validateFieldFromHook(
|
||||||
|
processedValue,
|
||||||
|
field as unknown as Field<T>
|
||||||
|
).filter((e) => e.type !== ErrorType.Required || isEmpty);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
delete newRowErrors[key as string];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the map
|
||||||
|
if (Object.keys(newRowErrors).length > 0) {
|
||||||
|
newMap.set(rowIndex, newRowErrors);
|
||||||
|
} else {
|
||||||
|
newMap.delete(rowIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle simple secondary effects here
|
||||||
|
setTimeout(() => {
|
||||||
|
// Use __index to find the actual row in the full data array
|
||||||
|
const rowId = rowData.__index;
|
||||||
|
|
||||||
|
// Handle company change - clear line/subline
|
||||||
|
if (key === "company" && processedValue) {
|
||||||
|
// Clear any existing line/subline values
|
||||||
|
setData((prevData) => {
|
||||||
|
const newData = [...prevData];
|
||||||
|
const idx = newData.findIndex((item) => item.__index === rowId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
newData[idx] = {
|
||||||
|
...newData[idx],
|
||||||
|
line: undefined,
|
||||||
|
subline: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle line change - clear subline
|
||||||
|
if (key === "line" && processedValue) {
|
||||||
|
// Clear any existing subline value
|
||||||
|
setData((prevData) => {
|
||||||
|
const newData = [...prevData];
|
||||||
|
const idx = newData.findIndex((item) => item.__index === rowId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
newData[idx] = {
|
||||||
|
...newData[idx],
|
||||||
|
subline: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
},
|
||||||
|
[data, fields, validateFieldFromHook, setData, setValidationErrors]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Improved revalidateRows function
|
||||||
|
const revalidateRows = useCallback(
|
||||||
|
async (
|
||||||
|
rowIndexes: number[],
|
||||||
|
updatedFields?: { [rowIndex: number]: string[] }
|
||||||
|
) => {
|
||||||
|
// Process all specified rows using a single state update to avoid race conditions
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const newErrors = new Map(prev);
|
||||||
|
|
||||||
|
// Process each row
|
||||||
|
for (const rowIndex of rowIndexes) {
|
||||||
|
if (rowIndex < 0 || rowIndex >= data.length) continue;
|
||||||
|
|
||||||
|
const row = data[rowIndex];
|
||||||
|
if (!row) continue;
|
||||||
|
|
||||||
|
// If we have specific fields to update for this row
|
||||||
|
const fieldsToValidate = updatedFields?.[rowIndex] || [];
|
||||||
|
|
||||||
|
if (fieldsToValidate.length > 0) {
|
||||||
|
// Get existing errors for this row
|
||||||
|
const existingRowErrors = { ...(newErrors.get(rowIndex) || {}) };
|
||||||
|
|
||||||
|
// Validate each specified field
|
||||||
|
for (const fieldKey of fieldsToValidate) {
|
||||||
|
const field = fields.find((f) => String(f.key) === fieldKey);
|
||||||
|
if (!field) continue;
|
||||||
|
|
||||||
|
const value = row[fieldKey as keyof typeof row];
|
||||||
|
|
||||||
|
// Run validation for this field
|
||||||
|
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||||
|
|
||||||
|
// Update errors for this field
|
||||||
|
if (errors.length > 0) {
|
||||||
|
existingRowErrors[fieldKey] = errors;
|
||||||
|
} else {
|
||||||
|
delete existingRowErrors[fieldKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the row's errors
|
||||||
|
if (Object.keys(existingRowErrors).length > 0) {
|
||||||
|
newErrors.set(rowIndex, existingRowErrors);
|
||||||
|
} else {
|
||||||
|
newErrors.delete(rowIndex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No specific fields provided - validate the entire row
|
||||||
|
const rowErrors: Record<string, ValidationError[]> = {};
|
||||||
|
|
||||||
|
// Validate all fields in the row
|
||||||
|
for (const field of fields) {
|
||||||
|
const fieldKey = String(field.key);
|
||||||
|
const value = row[fieldKey as keyof typeof row];
|
||||||
|
|
||||||
|
// Run validation for this field
|
||||||
|
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||||
|
|
||||||
|
// Update errors for this field
|
||||||
|
if (errors.length > 0) {
|
||||||
|
rowErrors[fieldKey] = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the row's errors
|
||||||
|
if (Object.keys(rowErrors).length > 0) {
|
||||||
|
newErrors.set(rowIndex, rowErrors);
|
||||||
|
} else {
|
||||||
|
newErrors.delete(rowIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[data, fields, validateFieldFromHook]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Copy a cell value to all cells below it in the same column
|
||||||
|
const copyDown = useCallback(
|
||||||
|
(rowIndex: number, key: T) => {
|
||||||
|
// Get the source value to copy
|
||||||
|
const sourceValue = data[rowIndex][key];
|
||||||
|
|
||||||
|
// Update all rows below with the same value using the existing updateRow function
|
||||||
|
// This ensures all validation logic runs consistently
|
||||||
|
for (let i = rowIndex + 1; i < data.length; i++) {
|
||||||
|
// Just use updateRow which will handle validation with proper timing
|
||||||
|
updateRow(i, key, sourceValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data, updateRow]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateRow,
|
||||||
|
updateRow,
|
||||||
|
revalidateRows,
|
||||||
|
copyDown
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Template, RowData, TemplateState, getApiUrl } from './validationTypes';
|
||||||
|
import { RowSelectionState } from '@tanstack/react-table';
|
||||||
|
import { ValidationError } from '../../../types';
|
||||||
|
|
||||||
|
export const useTemplateManagement = <T extends string>(
|
||||||
|
data: RowData<T>[],
|
||||||
|
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
|
||||||
|
rowSelection: RowSelectionState,
|
||||||
|
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
|
||||||
|
setRowValidationStatus: React.Dispatch<React.SetStateAction<Map<number, "pending" | "validating" | "validated" | "error">>>,
|
||||||
|
validateRow: (rowIndex: number, specificField?: string) => void,
|
||||||
|
isApplyingTemplateRef: React.MutableRefObject<boolean>,
|
||||||
|
upcValidation: {
|
||||||
|
validateUpc: (rowIndex: number, supplierId: string, upcValue: string) => Promise<{success: boolean, itemNumber?: string}>,
|
||||||
|
applyItemNumbersToData: (onApplied?: (updatedRowIds: number[]) => void) => void
|
||||||
|
},
|
||||||
|
setValidatingCells?: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||||
|
) => {
|
||||||
|
// Template state
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
|
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
|
||||||
|
const [templateState, setTemplateState] = useState<TemplateState>({
|
||||||
|
selectedTemplateId: null,
|
||||||
|
showSaveTemplateDialog: false,
|
||||||
|
newTemplateName: "",
|
||||||
|
newTemplateType: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load templates
|
||||||
|
const loadTemplates = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingTemplates(true);
|
||||||
|
console.log("Fetching templates from:", `${getApiUrl()}/templates`);
|
||||||
|
const response = await fetch(`${getApiUrl()}/templates`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch templates");
|
||||||
|
const templateData = await response.json();
|
||||||
|
const validTemplates = templateData.filter(
|
||||||
|
(t: any) =>
|
||||||
|
t && typeof t === "object" && t.id && t.company && t.product_type
|
||||||
|
);
|
||||||
|
setTemplates(validTemplates);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching templates:", error);
|
||||||
|
toast.error("Failed to load templates");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTemplates(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Refresh templates
|
||||||
|
const refreshTemplates = useCallback(() => {
|
||||||
|
loadTemplates();
|
||||||
|
}, [loadTemplates]);
|
||||||
|
|
||||||
|
// Save a new template
|
||||||
|
const saveTemplate = useCallback(
|
||||||
|
async (name: string, type: string) => {
|
||||||
|
try {
|
||||||
|
// Get selected rows
|
||||||
|
const selectedRowIndex = Number(Object.keys(rowSelection)[0]);
|
||||||
|
const selectedRow = data[selectedRowIndex];
|
||||||
|
|
||||||
|
if (!selectedRow) {
|
||||||
|
toast.error("Please select a row to create a template");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract data for template, removing metadata fields
|
||||||
|
const {
|
||||||
|
__index,
|
||||||
|
__template,
|
||||||
|
__original,
|
||||||
|
__corrected,
|
||||||
|
__changes,
|
||||||
|
...templateData
|
||||||
|
} = selectedRow as any;
|
||||||
|
|
||||||
|
// Clean numeric values (remove $ from price fields)
|
||||||
|
const cleanedData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Process each key-value pair
|
||||||
|
Object.entries(templateData).forEach(([key, value]) => {
|
||||||
|
// Handle numeric values with dollar signs
|
||||||
|
if (typeof value === "string" && value.includes("$")) {
|
||||||
|
cleanedData[key] = value.replace(/[$,\s]/g, "").trim();
|
||||||
|
}
|
||||||
|
// Handle array values (like categories or ship_restrictions)
|
||||||
|
else if (Array.isArray(value)) {
|
||||||
|
cleanedData[key] = value;
|
||||||
|
}
|
||||||
|
// Handle other values
|
||||||
|
else {
|
||||||
|
cleanedData[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the template to the API
|
||||||
|
const response = await fetch(`${getApiUrl()}/templates`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...cleanedData,
|
||||||
|
company: name,
|
||||||
|
product_type: type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(
|
||||||
|
errorData.error || errorData.details || "Failed to save template"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the new template from the response
|
||||||
|
const newTemplate = await response.json();
|
||||||
|
|
||||||
|
// Update the templates list with the new template
|
||||||
|
setTemplates((prev) => [...prev, newTemplate]);
|
||||||
|
|
||||||
|
// Update the row to show it's using this template
|
||||||
|
setData((prev) => {
|
||||||
|
const newData = [...prev];
|
||||||
|
if (newData[selectedRowIndex]) {
|
||||||
|
newData[selectedRowIndex] = {
|
||||||
|
...newData[selectedRowIndex],
|
||||||
|
__template: newTemplate.id.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`Template "${name}" saved successfully`);
|
||||||
|
|
||||||
|
// Reset dialog state
|
||||||
|
setTemplateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
showSaveTemplateDialog: false,
|
||||||
|
newTemplateName: "",
|
||||||
|
newTemplateType: "",
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving template:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to save template"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data, rowSelection, setData]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply template to rows - optimized version
|
||||||
|
const applyTemplate = useCallback(
|
||||||
|
(templateId: string, rowIndexes: number[]) => {
|
||||||
|
const template = templates.find((t) => t.id.toString() === templateId);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
toast.error("Template not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Applying template ${templateId} to rows:`, rowIndexes);
|
||||||
|
|
||||||
|
// Validate row indexes
|
||||||
|
const validRowIndexes = rowIndexes.filter(
|
||||||
|
(index) => index >= 0 && index < data.length && Number.isInteger(index)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validRowIndexes.length === 0) {
|
||||||
|
toast.error("No valid rows to update");
|
||||||
|
console.error("Invalid row indexes:", rowIndexes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the template application flag
|
||||||
|
isApplyingTemplateRef.current = true;
|
||||||
|
|
||||||
|
// Save scroll position
|
||||||
|
const scrollPosition = {
|
||||||
|
left: window.scrollX,
|
||||||
|
top: window.scrollY,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a copy of data and process all rows at once to minimize state updates
|
||||||
|
const newData = [...data];
|
||||||
|
const batchErrors = new Map<number, Record<string, ValidationError[]>>();
|
||||||
|
const batchStatuses = new Map<
|
||||||
|
number,
|
||||||
|
"pending" | "validating" | "validated" | "error"
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Extract template fields once outside the loop
|
||||||
|
const templateFields = Object.entries(template).filter(
|
||||||
|
([key]) =>
|
||||||
|
![
|
||||||
|
"id",
|
||||||
|
"__meta",
|
||||||
|
"__template",
|
||||||
|
"__original",
|
||||||
|
"__corrected",
|
||||||
|
"__changes",
|
||||||
|
].includes(key)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply template to each valid row
|
||||||
|
validRowIndexes.forEach((index) => {
|
||||||
|
// Create a new row with template values
|
||||||
|
const originalRow = newData[index];
|
||||||
|
const updatedRow = { ...originalRow } as Record<string, any>;
|
||||||
|
|
||||||
|
// Apply template fields (excluding metadata fields)
|
||||||
|
for (const [key, value] of templateFields) {
|
||||||
|
updatedRow[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the row as using this template
|
||||||
|
updatedRow.__template = templateId;
|
||||||
|
|
||||||
|
// Update the row in the data array
|
||||||
|
newData[index] = updatedRow as RowData<T>;
|
||||||
|
|
||||||
|
// Clear validation errors and mark as validated
|
||||||
|
batchErrors.set(index, {});
|
||||||
|
batchStatuses.set(index, "validated");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check which rows need UPC validation
|
||||||
|
const upcValidationRows = validRowIndexes.filter((rowIndex) => {
|
||||||
|
const row = newData[rowIndex];
|
||||||
|
return row && row.upc && row.supplier;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Perform a single update for all rows
|
||||||
|
setData(newData);
|
||||||
|
|
||||||
|
// Update all validation errors and statuses at once
|
||||||
|
setValidationErrors((prev) => {
|
||||||
|
const newErrors = new Map(prev);
|
||||||
|
for (const [rowIndex, errors] of batchErrors.entries()) {
|
||||||
|
newErrors.set(rowIndex, errors);
|
||||||
|
}
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
|
||||||
|
setRowValidationStatus((prev) => {
|
||||||
|
const newStatus = new Map(prev);
|
||||||
|
for (const [rowIndex, status] of batchStatuses.entries()) {
|
||||||
|
newStatus.set(rowIndex, status);
|
||||||
|
}
|
||||||
|
return newStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore scroll position
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo(scrollPosition.left, scrollPosition.top);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
if (validRowIndexes.length === 1) {
|
||||||
|
toast.success("Template applied");
|
||||||
|
} else if (validRowIndexes.length > 1) {
|
||||||
|
toast.success(`Template applied to ${validRowIndexes.length} rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset template application flag to allow validation
|
||||||
|
isApplyingTemplateRef.current = false;
|
||||||
|
|
||||||
|
// If there are rows with both UPC and supplier, validate them
|
||||||
|
if (upcValidationRows.length > 0) {
|
||||||
|
console.log(`Validating UPCs for ${upcValidationRows.length} rows after template application`);
|
||||||
|
|
||||||
|
// Process each row sequentially - this mimics the exact manual edit behavior
|
||||||
|
const processNextValidation = (index = 0) => {
|
||||||
|
if (index >= upcValidationRows.length) {
|
||||||
|
return; // All rows processed
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowIndex = upcValidationRows[index];
|
||||||
|
const row = newData[rowIndex];
|
||||||
|
|
||||||
|
if (row && row.supplier && row.upc) {
|
||||||
|
// The EXACT implementation from handleUpdateRow when supplier is edited manually:
|
||||||
|
|
||||||
|
// 1. Mark the item_number cell as being validated - THIS IS CRITICAL FOR LOADING STATE
|
||||||
|
const cellKey = `${rowIndex}-item_number`;
|
||||||
|
|
||||||
|
// Clear validation errors for this field
|
||||||
|
setValidationErrors(prev => {
|
||||||
|
const newErrors = new Map(prev);
|
||||||
|
if (newErrors.has(rowIndex)) {
|
||||||
|
const rowErrors = { ...newErrors.get(rowIndex) };
|
||||||
|
if (rowErrors.item_number) {
|
||||||
|
delete rowErrors.item_number;
|
||||||
|
}
|
||||||
|
newErrors.set(rowIndex, rowErrors);
|
||||||
|
}
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set loading state - using setValidatingCells from props
|
||||||
|
if (setValidatingCells) {
|
||||||
|
setValidatingCells(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.add(cellKey);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate UPC for this row
|
||||||
|
upcValidation.validateUpc(rowIndex, row.supplier.toString(), row.upc.toString())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success && result.itemNumber) {
|
||||||
|
// CRITICAL FIX: Directly update data with the item number to ensure immediate UI update
|
||||||
|
setData(prevData => {
|
||||||
|
const newData = [...prevData];
|
||||||
|
|
||||||
|
// Update this specific row with the item number
|
||||||
|
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||||
|
newData[rowIndex] = {
|
||||||
|
...newData[rowIndex],
|
||||||
|
item_number: result.itemNumber
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also trigger other relevant updates
|
||||||
|
upcValidation.applyItemNumbersToData();
|
||||||
|
|
||||||
|
// Mark for revalidation after item numbers are updated
|
||||||
|
setTimeout(() => {
|
||||||
|
// Validate the row EXACTLY like in manual edit
|
||||||
|
validateRow(rowIndex, 'item_number');
|
||||||
|
|
||||||
|
// CRITICAL FIX: Make one final check to ensure data is correct
|
||||||
|
setTimeout(() => {
|
||||||
|
// Get the current item number from the data
|
||||||
|
const currentItemNumber = (() => {
|
||||||
|
try {
|
||||||
|
const dataAtThisPointInTime = data[rowIndex];
|
||||||
|
return dataAtThisPointInTime?.item_number;
|
||||||
|
} catch (e) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// If the data is wrong at this point, fix it directly
|
||||||
|
if (currentItemNumber !== result.itemNumber) {
|
||||||
|
// Directly update the data to fix the issue
|
||||||
|
setData(dataRightNow => {
|
||||||
|
const fixedData = [...dataRightNow];
|
||||||
|
if (rowIndex >= 0 && rowIndex < fixedData.length) {
|
||||||
|
fixedData[rowIndex] = {
|
||||||
|
...fixedData[rowIndex],
|
||||||
|
item_number: result.itemNumber
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return fixedData;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then do a force update after a brief delay
|
||||||
|
setTimeout(() => {
|
||||||
|
setData(currentData => {
|
||||||
|
// Critical fix: ensure the item number is correct
|
||||||
|
if (currentData[rowIndex] && currentData[rowIndex].item_number !== result.itemNumber) {
|
||||||
|
// Create a completely new array with the correct item number
|
||||||
|
const fixedData = [...currentData];
|
||||||
|
fixedData[rowIndex] = {
|
||||||
|
...fixedData[rowIndex],
|
||||||
|
item_number: result.itemNumber
|
||||||
|
};
|
||||||
|
return fixedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a completely new array
|
||||||
|
return [...currentData];
|
||||||
|
});
|
||||||
|
}, 20);
|
||||||
|
} else {
|
||||||
|
// Item number is already correct, just do the force update
|
||||||
|
setData(currentData => {
|
||||||
|
// Create a completely new array
|
||||||
|
return [...currentData];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
// Clear loading state
|
||||||
|
if (setValidatingCells) {
|
||||||
|
setValidatingCells(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(cellKey);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to next row after validation is complete
|
||||||
|
setTimeout(() => processNextValidation(index + 1), 100);
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
// Clear loading state on failure
|
||||||
|
if (setValidatingCells) {
|
||||||
|
setValidatingCells(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(cellKey);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to next row if validation fails
|
||||||
|
setTimeout(() => processNextValidation(index + 1), 100);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(`Error validating UPC for row ${rowIndex}:`, err);
|
||||||
|
|
||||||
|
// Clear loading state on error
|
||||||
|
if (setValidatingCells) {
|
||||||
|
setValidatingCells(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(cellKey);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to next row despite error
|
||||||
|
setTimeout(() => processNextValidation(index + 1), 100);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Skip this row and continue to the next
|
||||||
|
processNextValidation(index + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start processing validations
|
||||||
|
processNextValidation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
data,
|
||||||
|
templates,
|
||||||
|
setData,
|
||||||
|
setValidationErrors,
|
||||||
|
setRowValidationStatus,
|
||||||
|
validateRow,
|
||||||
|
upcValidation,
|
||||||
|
setValidatingCells
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply template to selected rows
|
||||||
|
const applyTemplateToSelected = useCallback(
|
||||||
|
(templateId: string) => {
|
||||||
|
if (!templateId) return;
|
||||||
|
|
||||||
|
// Update the selected template ID
|
||||||
|
setTemplateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
selectedTemplateId: templateId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get selected row keys (which may be UUIDs)
|
||||||
|
const selectedKeys = Object.entries(rowSelection)
|
||||||
|
.filter(([_, selected]) => selected === true)
|
||||||
|
.map(([key, _]) => key);
|
||||||
|
|
||||||
|
console.log("Selected row keys:", selectedKeys);
|
||||||
|
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
toast.error("No rows selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map UUID keys to array indices
|
||||||
|
const selectedIndexes = selectedKeys
|
||||||
|
.map((key) => {
|
||||||
|
// Find the matching row index in the data array
|
||||||
|
const index = data.findIndex(
|
||||||
|
(row) =>
|
||||||
|
(row.__index && row.__index === key) || // Match by __index
|
||||||
|
String(data.indexOf(row)) === key // Or by numeric index
|
||||||
|
);
|
||||||
|
return index;
|
||||||
|
})
|
||||||
|
.filter((index) => index !== -1); // Filter out any not found
|
||||||
|
|
||||||
|
console.log("Mapped row indices:", selectedIndexes);
|
||||||
|
|
||||||
|
if (selectedIndexes.length === 0) {
|
||||||
|
toast.error("Could not find selected rows");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply template to selected rows
|
||||||
|
applyTemplate(templateId, selectedIndexes);
|
||||||
|
},
|
||||||
|
[rowSelection, applyTemplate, setTemplateState, data]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
isLoadingTemplates,
|
||||||
|
templateState,
|
||||||
|
setTemplateState,
|
||||||
|
loadTemplates,
|
||||||
|
refreshTemplates,
|
||||||
|
saveTemplate,
|
||||||
|
applyTemplate,
|
||||||
|
applyTemplateToSelected
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { RowData } from './validationTypes';
|
||||||
|
import type { Fields } from '../../../types';
|
||||||
|
import { ErrorSources, ErrorType, ValidationError } from '../../../types';
|
||||||
|
|
||||||
|
export const useUniqueItemNumbersValidation = <T extends string>(
|
||||||
|
data: RowData<T>[],
|
||||||
|
fields: Fields<T>,
|
||||||
|
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>
|
||||||
|
) => {
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Track unique identifiers in maps
|
||||||
|
const uniqueFieldsMap = new Map<string, Map<string, number[]>>();
|
||||||
|
|
||||||
|
// Find fields that need uniqueness validation
|
||||||
|
const uniqueFields = fields
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize maps for each unique field
|
||||||
|
uniqueFields.forEach((fieldKey) => {
|
||||||
|
uniqueFieldsMap.set(fieldKey, new Map<string, number[]>());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
|
||||||
|
// Skip empty values
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueStr = String(value);
|
||||||
|
const fieldMap = uniqueFieldsMap.get(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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process duplicates
|
||||||
|
uniqueFields.forEach((fieldKey) => {
|
||||||
|
const fieldMap = uniqueFieldsMap.get(fieldKey);
|
||||||
|
if (!fieldMap) return;
|
||||||
|
|
||||||
|
fieldMap.forEach((indices, value) => {
|
||||||
|
// Only process if there are duplicates
|
||||||
|
if (indices.length > 1) {
|
||||||
|
// Get the validation rule for this field
|
||||||
|
const field = fields.find((f) => String(f.key) === fieldKey);
|
||||||
|
const validationRule = field?.validations?.find(
|
||||||
|
(v) => v.rule === "unique"
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorObj = {
|
||||||
|
message:
|
||||||
|
validationRule?.errorMessage || `Duplicate ${fieldKey}: ${value}`,
|
||||||
|
level: validationRule?.level || ("error" as "error"),
|
||||||
|
source: ErrorSources.Table,
|
||||||
|
type: ErrorType.Unique,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add error to each row with this value
|
||||||
|
indices.forEach((rowIndex) => {
|
||||||
|
const rowErrors = errors.get(rowIndex) || {};
|
||||||
|
rowErrors[fieldKey] = [errorObj];
|
||||||
|
errors.set(rowIndex, rowErrors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// We'll update errors with a single batch operation
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Check each field for changes
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only update if we have changes
|
||||||
|
if (rowHasChanges) {
|
||||||
|
newMap.set(rowIndex, updatedErrors);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only return a new map if we have changes
|
||||||
|
return hasChanges ? newMap : prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Uniqueness validation complete");
|
||||||
|
}, [data, fields, setValidationErrors]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateUniqueItemNumbers
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import type { Fields } from '../../../types';
|
||||||
|
import { ErrorSources, ErrorType } from '../../../types';
|
||||||
|
import { RowData, InfoWithSource, isEmpty } from './validationTypes';
|
||||||
|
|
||||||
|
export const useUniqueValidation = <T extends string>(
|
||||||
|
fields: Fields<T>
|
||||||
|
) => {
|
||||||
|
// Additional function to explicitly validate uniqueness for specified fields
|
||||||
|
const validateUniqueField = useCallback((data: RowData<T>[], fieldKey: string) => {
|
||||||
|
// Field keys that need special handling for uniqueness
|
||||||
|
const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
||||||
|
|
||||||
|
// If the field doesn't need uniqueness validation, return empty errors
|
||||||
|
if (!uniquenessFields.includes(fieldKey)) {
|
||||||
|
const field = fields.find(f => String(f.key) === fieldKey);
|
||||||
|
if (!field || !field.validations?.some(v => v.rule === 'unique')) {
|
||||||
|
return new Map<number, Record<string, InfoWithSource>>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create map to track errors
|
||||||
|
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
|
||||||
|
|
||||||
|
// Find the field definition
|
||||||
|
const field = fields.find(f => String(f.key) === fieldKey);
|
||||||
|
if (!field) return uniqueErrors;
|
||||||
|
|
||||||
|
// Get validation properties
|
||||||
|
const validation = field.validations?.find(v => v.rule === 'unique');
|
||||||
|
const allowEmpty = validation?.allowEmpty ?? false;
|
||||||
|
const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
|
||||||
|
const level = validation?.level || 'error';
|
||||||
|
|
||||||
|
// Track values for uniqueness check
|
||||||
|
const valueMap = new Map<string, number[]>();
|
||||||
|
|
||||||
|
// Build value map
|
||||||
|
data.forEach((row, rowIndex) => {
|
||||||
|
const value = String(row[fieldKey as keyof typeof row] || '');
|
||||||
|
|
||||||
|
// Skip empty values if allowed
|
||||||
|
if (allowEmpty && isEmpty(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valueMap.has(value)) {
|
||||||
|
valueMap.set(value, [rowIndex]);
|
||||||
|
} else {
|
||||||
|
valueMap.get(value)?.push(rowIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add errors for duplicate values
|
||||||
|
valueMap.forEach((rowIndexes, value) => {
|
||||||
|
if (rowIndexes.length > 1) {
|
||||||
|
// Skip empty values
|
||||||
|
if (!value || value.trim() === '') return;
|
||||||
|
|
||||||
|
// Add error to all duplicate rows
|
||||||
|
rowIndexes.forEach(rowIndex => {
|
||||||
|
// Create errors object if needed
|
||||||
|
if (!uniqueErrors.has(rowIndex)) {
|
||||||
|
uniqueErrors.set(rowIndex, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add error for this field
|
||||||
|
uniqueErrors.get(rowIndex)![fieldKey] = {
|
||||||
|
message: errorMessage,
|
||||||
|
level: level as 'info' | 'warning' | 'error',
|
||||||
|
source: ErrorSources.Table,
|
||||||
|
type: ErrorType.Unique
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return uniqueErrors;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// Validate uniqueness for multiple fields
|
||||||
|
const validateUniqueFields = useCallback((data: RowData<T>[], fieldKeys: string[]) => {
|
||||||
|
// Process each field and merge results
|
||||||
|
const allErrors = new Map<number, Record<string, InfoWithSource>>();
|
||||||
|
|
||||||
|
fieldKeys.forEach(fieldKey => {
|
||||||
|
const fieldErrors = validateUniqueField(data, fieldKey);
|
||||||
|
|
||||||
|
// Merge errors
|
||||||
|
fieldErrors.forEach((errors, rowIdx) => {
|
||||||
|
if (!allErrors.has(rowIdx)) {
|
||||||
|
allErrors.set(rowIdx, {});
|
||||||
|
}
|
||||||
|
Object.assign(allErrors.get(rowIdx)!, errors);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return allErrors;
|
||||||
|
}, [validateUniqueField]);
|
||||||
|
|
||||||
|
// Run complete validation for uniqueness
|
||||||
|
const validateAllUniqueFields = useCallback((data: RowData<T>[]) => {
|
||||||
|
// Get fields requiring uniqueness validation
|
||||||
|
const uniqueFields = fields
|
||||||
|
.filter(field => field.validations?.some(v => v.rule === 'unique'))
|
||||||
|
.map(field => String(field.key));
|
||||||
|
|
||||||
|
// Also add standard unique fields that might not be explicitly marked as unique
|
||||||
|
const standardUniqueFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
||||||
|
|
||||||
|
// Combine all fields that need uniqueness validation
|
||||||
|
const allUniqueFieldKeys = [...new Set([
|
||||||
|
...uniqueFields,
|
||||||
|
...standardUniqueFields
|
||||||
|
])];
|
||||||
|
|
||||||
|
// Filter to only fields that exist in the data
|
||||||
|
const existingFields = allUniqueFieldKeys.filter(fieldKey =>
|
||||||
|
data.some(row => fieldKey in row)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate all fields at once
|
||||||
|
return validateUniqueFields(data, existingFields);
|
||||||
|
}, [fields, validateUniqueFields]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateUniqueField,
|
||||||
|
validateUniqueFields,
|
||||||
|
validateAllUniqueFields
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -49,17 +49,40 @@ export const useUpcValidation = (
|
|||||||
|
|
||||||
// Update item number
|
// Update item number
|
||||||
const updateItemNumber = useCallback((rowIndex: number, itemNumber: string) => {
|
const updateItemNumber = useCallback((rowIndex: number, itemNumber: string) => {
|
||||||
console.log(`Setting item number for row ${rowIndex} to ${itemNumber}`);
|
// CRITICAL: Update BOTH the data state and the ref
|
||||||
validationStateRef.current.itemNumbers.set(rowIndex, itemNumber);
|
// First, update the data directly to ensure UI consistency
|
||||||
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
setData(prevData => {
|
||||||
}, []);
|
// Create a new copy of the data
|
||||||
|
const newData = [...prevData];
|
||||||
|
|
||||||
// Mark a row as being validated
|
// Only update if the row exists
|
||||||
const startValidatingRow = useCallback((rowIndex: number) => {
|
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||||
validationStateRef.current.validatingRows.add(rowIndex);
|
// First, we need a new object reference for the row to force a re-render
|
||||||
setValidatingRows(new Set(validationStateRef.current.validatingRows));
|
newData[rowIndex] = {
|
||||||
setIsValidatingUpc(true);
|
...newData[rowIndex],
|
||||||
}, []);
|
item_number: itemNumber
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also update the itemNumbers map AFTER the data is updated
|
||||||
|
// This ensures the map reflects the current state of the data
|
||||||
|
setTimeout(() => {
|
||||||
|
// Update the ref with the same value
|
||||||
|
validationStateRef.current.itemNumbers.set(rowIndex, itemNumber);
|
||||||
|
|
||||||
|
// CRITICAL: Force a React state update to ensure all components re-render
|
||||||
|
// Created a brand new Map object to ensure React detects the change
|
||||||
|
const newItemNumbersMap = new Map(validationStateRef.current.itemNumbers);
|
||||||
|
setItemNumberUpdates(newItemNumbersMap);
|
||||||
|
|
||||||
|
// Force an immediate React render cycle by triggering state updates
|
||||||
|
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
|
||||||
|
setValidatingRows(new Set(validationStateRef.current.validatingRows));
|
||||||
|
}, 0);
|
||||||
|
}, [setData]);
|
||||||
|
|
||||||
// Mark a row as no longer being validated
|
// Mark a row as no longer being validated
|
||||||
const stopValidatingRow = useCallback((rowIndex: number) => {
|
const stopValidatingRow = useCallback((rowIndex: number) => {
|
||||||
@@ -132,11 +155,22 @@ export const useUpcValidation = (
|
|||||||
);
|
);
|
||||||
previousKeys.forEach(key => validationStateRef.current.activeValidations.delete(key));
|
previousKeys.forEach(key => validationStateRef.current.activeValidations.delete(key));
|
||||||
|
|
||||||
// Start validation - track this with the ref to avoid race conditions
|
// Log validation start to help debug template issues
|
||||||
startValidatingRow(rowIndex);
|
console.log(`[UPC-DEBUG] Starting UPC validation for row ${rowIndex} with supplier ${supplierId}, upc ${upcValue}`);
|
||||||
startValidatingCell(rowIndex, 'item_number');
|
|
||||||
|
|
||||||
console.log(`Validating UPC: rowIndex=${rowIndex}, supplierId=${supplierId}, upc=${upcValue}`);
|
// IMPORTANT: Set validation state using setState to FORCE UI updates
|
||||||
|
validationStateRef.current.validatingRows.add(rowIndex);
|
||||||
|
setValidatingRows(new Set(validationStateRef.current.validatingRows));
|
||||||
|
setIsValidatingUpc(true);
|
||||||
|
|
||||||
|
// Start cell validation and explicitly update UI via setState
|
||||||
|
const cellKey = getCellKey(rowIndex, 'item_number');
|
||||||
|
validationStateRef.current.validatingCells.add(cellKey);
|
||||||
|
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
|
||||||
|
|
||||||
|
console.log(`[UPC-DEBUG] Set loading state for row ${rowIndex}, cell key ${cellKey}`);
|
||||||
|
console.log(`[UPC-DEBUG] Current validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
|
||||||
|
console.log(`[UPC-DEBUG] Current validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a unique key for this validation to track it
|
// Create a unique key for this validation to track it
|
||||||
@@ -157,18 +191,43 @@ export const useUpcValidation = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch the product by UPC
|
// Fetch the product by UPC
|
||||||
|
console.log(`[UPC-DEBUG] Fetching product data for UPC ${upcValue} with supplier ${supplierId}`);
|
||||||
const product = await fetchProductByUpc(supplierId, upcValue);
|
const product = await fetchProductByUpc(supplierId, upcValue);
|
||||||
|
console.log(`[UPC-DEBUG] Fetch complete for row ${rowIndex}, success: ${!product.error}`);
|
||||||
|
|
||||||
// Check if this validation is still relevant (hasn't been superseded by another)
|
// Check if this validation is still relevant (hasn't been superseded by another)
|
||||||
if (!validationStateRef.current.activeValidations.has(validationKey)) {
|
if (!validationStateRef.current.activeValidations.has(validationKey)) {
|
||||||
console.log(`Validation ${validationKey} was cancelled`);
|
console.log(`[UPC-DEBUG] Validation ${validationKey} was cancelled`);
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the item number from the API response - check for !error since API returns { error: boolean, data: any }
|
// Extract the item number from the API response - check for !error since API returns { error: boolean, data: any }
|
||||||
if (product && !product.error && product.data?.itemNumber) {
|
if (product && !product.error && product.data?.itemNumber) {
|
||||||
// Store this validation result
|
console.log(`[UPC-DEBUG] Got item number for row ${rowIndex}: ${product.data.itemNumber}`);
|
||||||
updateItemNumber(rowIndex, product.data.itemNumber);
|
|
||||||
|
// CRITICAL FIX: Directly update the data with the new item number first
|
||||||
|
setData(prevData => {
|
||||||
|
const newData = [...prevData];
|
||||||
|
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||||
|
// This should happen before updating the map
|
||||||
|
newData[rowIndex] = {
|
||||||
|
...newData[rowIndex],
|
||||||
|
item_number: product.data.itemNumber
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then, update the map to match what's now in the data
|
||||||
|
validationStateRef.current.itemNumbers.set(rowIndex, product.data.itemNumber);
|
||||||
|
|
||||||
|
// CRITICAL: Force a React state update to ensure all components re-render
|
||||||
|
// Created a brand new Map object to ensure React detects the change
|
||||||
|
const newItemNumbersMap = new Map(validationStateRef.current.itemNumbers);
|
||||||
|
setItemNumberUpdates(newItemNumbersMap);
|
||||||
|
|
||||||
|
// Force a shallow copy of the itemNumbers map to trigger useEffect dependencies
|
||||||
|
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -176,7 +235,7 @@ export const useUpcValidation = (
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// No item number found but validation was still attempted
|
// No item number found but validation was still attempted
|
||||||
console.log(`No item number found for UPC ${upcValue}`);
|
console.log(`[UPC-DEBUG] No item number found for UPC ${upcValue}`);
|
||||||
|
|
||||||
// Clear any existing item number to show validation was attempted and failed
|
// Clear any existing item number to show validation was attempted and failed
|
||||||
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
|
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
|
||||||
@@ -187,58 +246,71 @@ export const useUpcValidation = (
|
|||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error validating UPC:', error);
|
console.error('[UPC-DEBUG] Error validating UPC:', error);
|
||||||
return { success: false };
|
return { success: false };
|
||||||
} finally {
|
} finally {
|
||||||
// End validation
|
// End validation - FORCE UI update by using setState directly
|
||||||
stopValidatingRow(rowIndex);
|
console.log(`[UPC-DEBUG] Ending validation for row ${rowIndex}`);
|
||||||
stopValidatingCell(rowIndex, 'item_number');
|
|
||||||
|
validationStateRef.current.validatingRows.delete(rowIndex);
|
||||||
|
setValidatingRows(new Set(validationStateRef.current.validatingRows));
|
||||||
|
|
||||||
|
if (validationStateRef.current.validatingRows.size === 0) {
|
||||||
|
setIsValidatingUpc(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
validationStateRef.current.validatingCells.delete(cellKey);
|
||||||
|
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
|
||||||
|
|
||||||
|
console.log(`[UPC-DEBUG] Cleared loading state for row ${rowIndex}`);
|
||||||
|
console.log(`[UPC-DEBUG] Updated validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
|
||||||
|
console.log(`[UPC-DEBUG] Updated validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
|
||||||
}
|
}
|
||||||
}, [fetchProductByUpc, updateItemNumber, startValidatingCell, stopValidatingCell, startValidatingRow, stopValidatingRow, setData]);
|
}, [fetchProductByUpc, updateItemNumber, setData]);
|
||||||
|
|
||||||
// Apply item numbers to data
|
// Apply all pending item numbers to the data state
|
||||||
const applyItemNumbersToData = useCallback((onApplied?: (updatedRowIds: number[]) => void) => {
|
const applyItemNumbersToData = useCallback((callback?: (updatedRows: number[]) => void) => {
|
||||||
// Create a copy of the current item numbers map to avoid race conditions
|
// Skip if we have nothing to apply
|
||||||
const currentItemNumbers = new Map(validationStateRef.current.itemNumbers);
|
if (validationStateRef.current.itemNumbers.size === 0) {
|
||||||
|
if (callback) callback([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Only apply if we have any item numbers
|
// Gather all row IDs that will be updated
|
||||||
if (currentItemNumbers.size === 0) return;
|
const rowIds: number[] = [];
|
||||||
|
|
||||||
// Track updated row indices to pass to callback
|
|
||||||
const updatedRowIndices: number[] = [];
|
|
||||||
|
|
||||||
// Log for debugging
|
|
||||||
console.log(`Applying ${currentItemNumbers.size} item numbers to data`);
|
|
||||||
|
|
||||||
|
// Update the data state with all item numbers
|
||||||
setData(prevData => {
|
setData(prevData => {
|
||||||
// Create a new copy of the data
|
|
||||||
const newData = [...prevData];
|
const newData = [...prevData];
|
||||||
|
|
||||||
// Update each row with its item number without affecting other fields
|
// Apply each item number to the data
|
||||||
currentItemNumbers.forEach((itemNumber, rowIndex) => {
|
validationStateRef.current.itemNumbers.forEach((itemNumber, rowIndex) => {
|
||||||
if (rowIndex < newData.length) {
|
// Ensure row exists and value has actually changed
|
||||||
console.log(`Setting item_number for row ${rowIndex} to ${itemNumber}`);
|
if (rowIndex >= 0 && rowIndex < newData.length &&
|
||||||
|
newData[rowIndex]?.item_number !== itemNumber) {
|
||||||
|
|
||||||
// Only update the item_number field, leaving other fields unchanged
|
// Create a new row object to force re-rendering
|
||||||
newData[rowIndex] = {
|
newData[rowIndex] = {
|
||||||
...newData[rowIndex],
|
...newData[rowIndex],
|
||||||
item_number: itemNumber
|
item_number: itemNumber
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track which rows were updated
|
// Track which row was updated for the callback
|
||||||
updatedRowIndices.push(rowIndex);
|
rowIds.push(rowIndex);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Call the callback if provided, after state updates are processed
|
// Force a re-render by updating React state
|
||||||
if (onApplied && updatedRowIndices.length > 0) {
|
setTimeout(() => {
|
||||||
// Use setTimeout to ensure this happens after the state update
|
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
||||||
setTimeout(() => {
|
}, 0);
|
||||||
onApplied(updatedRowIndices);
|
|
||||||
}, 100); // Use 100ms to ensure the data update is fully processed
|
// Call the callback with the updated row IDs
|
||||||
|
if (callback) {
|
||||||
|
callback(rowIds);
|
||||||
}
|
}
|
||||||
}, [setData]);
|
}, [setData]);
|
||||||
|
|
||||||
@@ -405,6 +477,9 @@ export const useUpcValidation = (
|
|||||||
getItemNumber,
|
getItemNumber,
|
||||||
applyItemNumbersToData,
|
applyItemNumbersToData,
|
||||||
|
|
||||||
|
// CRITICAL: Expose the itemNumbers map directly
|
||||||
|
itemNumbers: validationStateRef.current.itemNumbers,
|
||||||
|
|
||||||
// Initialization state
|
// Initialization state
|
||||||
initialValidationDone: initialUpcValidationDoneRef.current
|
initialValidationDone: initialUpcValidationDoneRef.current
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,248 +1,23 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import type { Field, Fields, RowHook, TableHook } from '../../../types'
|
import type { Field, Fields, RowHook } from '../../../types'
|
||||||
import type { Meta } from '../types'
|
import { ErrorSources } from '../../../types'
|
||||||
import { ErrorSources, ErrorType, ValidationError } from '../../../types'
|
import { RowData, InfoWithSource } from './validationTypes'
|
||||||
import { RowData } from './useValidationState'
|
import { useFieldValidation, clearValidationCacheForField, clearAllUniquenessCaches } from './useFieldValidation'
|
||||||
|
import { useUniqueValidation } from './useUniqueValidation'
|
||||||
// Define InfoWithSource to match the expected structure
|
|
||||||
// Make sure source is required (not optional)
|
|
||||||
export interface InfoWithSource {
|
|
||||||
message: string;
|
|
||||||
level: 'info' | 'warning' | 'error';
|
|
||||||
source: ErrorSources;
|
|
||||||
type: ErrorType;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared utility function for checking empty values - defined once to avoid duplication
|
|
||||||
const isEmpty = (value: any): boolean =>
|
|
||||||
value === undefined ||
|
|
||||||
value === null ||
|
|
||||||
value === '' ||
|
|
||||||
(Array.isArray(value) && value.length === 0) ||
|
|
||||||
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add a special function to clear all uniqueness validation caches
|
|
||||||
export const clearAllUniquenessCaches = () => {
|
|
||||||
// Clear cache for common unique fields
|
|
||||||
['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'].forEach(fieldKey => {
|
|
||||||
clearValidationCacheForField(fieldKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also clear any cache entries that might involve uniqueness validation
|
|
||||||
validationResultCache.forEach((_, key) => {
|
|
||||||
if (key.includes('unique')) {
|
|
||||||
validationResultCache.delete(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Main validation hook that brings together field and uniqueness validation
|
||||||
export const useValidation = <T extends string>(
|
export const useValidation = <T extends string>(
|
||||||
fields: Fields<T>,
|
fields: Fields<T>,
|
||||||
rowHook?: RowHook<T>,
|
rowHook?: RowHook<T>
|
||||||
tableHook?: TableHook<T>
|
|
||||||
) => {
|
) => {
|
||||||
// Validate a single field
|
// Use the field validation hook
|
||||||
const validateField = useCallback((
|
const { validateField, validateRow } = useFieldValidation(fields, rowHook);
|
||||||
value: any,
|
|
||||||
field: Field<T>
|
|
||||||
): ValidationError[] => {
|
|
||||||
const errors: ValidationError[] = []
|
|
||||||
|
|
||||||
if (!field.validations) return errors
|
// Use the uniqueness validation hook
|
||||||
|
const {
|
||||||
// Create a cache key using field key, value, and validation rules
|
validateUniqueField,
|
||||||
const cacheKey = `${field.key}-${String(value)}-${JSON.stringify(field.validations)}`;
|
validateAllUniqueFields
|
||||||
|
} = useUniqueValidation(fields);
|
||||||
// Check cache first to avoid redundant validation
|
|
||||||
if (validationResultCache.has(cacheKey)) {
|
|
||||||
return validationResultCache.get(cacheKey) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
field.validations.forEach(validation => {
|
|
||||||
switch (validation.rule) {
|
|
||||||
case 'required':
|
|
||||||
// Use the shared isEmpty function
|
|
||||||
if (isEmpty(value)) {
|
|
||||||
errors.push({
|
|
||||||
message: validation.errorMessage || 'This field is required',
|
|
||||||
level: validation.level || 'error',
|
|
||||||
type: ErrorType.Required
|
|
||||||
})
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'unique':
|
|
||||||
// Unique validation happens at table level, not here
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'regex':
|
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
|
||||||
try {
|
|
||||||
const regex = new RegExp(validation.value, validation.flags)
|
|
||||||
if (!regex.test(String(value))) {
|
|
||||||
errors.push({
|
|
||||||
message: validation.errorMessage,
|
|
||||||
level: validation.level || 'error',
|
|
||||||
type: ErrorType.Regex
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Invalid regex in validation:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Store results in cache to speed up future validations
|
|
||||||
validationResultCache.set(cacheKey, errors);
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Validate a single row
|
|
||||||
const validateRow = useCallback(async (
|
|
||||||
row: RowData<T>,
|
|
||||||
rowIndex: number,
|
|
||||||
allRows: RowData<T>[]
|
|
||||||
): Promise<Meta> => {
|
|
||||||
// Run field-level validations
|
|
||||||
const fieldErrors: Record<string, ValidationError[]> = {}
|
|
||||||
|
|
||||||
// Use the shared isEmpty function
|
|
||||||
|
|
||||||
fields.forEach(field => {
|
|
||||||
const value = row[String(field.key) as keyof typeof row]
|
|
||||||
const errors = validateField(value, field as Field<T>)
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
fieldErrors[String(field.key)] = errors
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Special validation for supplier and company fields - only apply if the field exists in fields
|
|
||||||
if (fields.some(field => String(field.key) === 'supplier') && isEmpty(row.supplier)) {
|
|
||||||
fieldErrors['supplier'] = [{
|
|
||||||
message: 'Supplier is required',
|
|
||||||
level: 'error',
|
|
||||||
type: ErrorType.Required
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields.some(field => String(field.key) === 'company') && isEmpty(row.company)) {
|
|
||||||
fieldErrors['company'] = [{
|
|
||||||
message: 'Company is required',
|
|
||||||
level: 'error',
|
|
||||||
type: ErrorType.Required
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run row hook if provided
|
|
||||||
let rowHookResult: Meta = {
|
|
||||||
__index: row.__index || String(rowIndex)
|
|
||||||
}
|
|
||||||
if (rowHook) {
|
|
||||||
try {
|
|
||||||
// Call the row hook and extract only the __index property
|
|
||||||
const result = await rowHook(row, rowIndex, allRows);
|
|
||||||
rowHookResult.__index = result.__index || rowHookResult.__index;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in row hook:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We no longer need to merge errors since we're not storing them in the row data
|
|
||||||
// The calling code should handle storing errors in the validationErrors Map
|
|
||||||
|
|
||||||
return {
|
|
||||||
__index: row.__index || String(rowIndex)
|
|
||||||
}
|
|
||||||
}, [fields, validateField, rowHook])
|
|
||||||
|
|
||||||
// Additional function to explicitly validate uniqueness for specified fields
|
|
||||||
const validateUniqueField = useCallback((data: RowData<T>[], fieldKey: string) => {
|
|
||||||
// Field keys that need special handling for uniqueness
|
|
||||||
const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
|
||||||
|
|
||||||
// If the field doesn't need uniqueness validation, return empty errors
|
|
||||||
if (!uniquenessFields.includes(fieldKey)) {
|
|
||||||
const field = fields.find(f => String(f.key) === fieldKey);
|
|
||||||
if (!field || !field.validations?.some(v => v.rule === 'unique')) {
|
|
||||||
return new Map<number, Record<string, InfoWithSource>>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create map to track errors
|
|
||||||
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
|
|
||||||
|
|
||||||
// Find the field definition
|
|
||||||
const field = fields.find(f => String(f.key) === fieldKey);
|
|
||||||
if (!field) return uniqueErrors;
|
|
||||||
|
|
||||||
// Get validation properties
|
|
||||||
const validation = field.validations?.find(v => v.rule === 'unique');
|
|
||||||
const allowEmpty = validation?.allowEmpty ?? false;
|
|
||||||
const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
|
|
||||||
const level = validation?.level || 'error';
|
|
||||||
|
|
||||||
// Track values for uniqueness check
|
|
||||||
const valueMap = new Map<string, number[]>();
|
|
||||||
|
|
||||||
// Build value map
|
|
||||||
data.forEach((row, rowIndex) => {
|
|
||||||
const value = String(row[fieldKey as keyof typeof row] || '');
|
|
||||||
|
|
||||||
// Skip empty values if allowed
|
|
||||||
if (allowEmpty && isEmpty(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!valueMap.has(value)) {
|
|
||||||
valueMap.set(value, [rowIndex]);
|
|
||||||
} else {
|
|
||||||
valueMap.get(value)?.push(rowIndex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add errors for duplicate values
|
|
||||||
valueMap.forEach((rowIndexes, value) => {
|
|
||||||
if (rowIndexes.length > 1) {
|
|
||||||
// Skip empty values
|
|
||||||
if (!value || value.trim() === '') return;
|
|
||||||
|
|
||||||
// Add error to all duplicate rows
|
|
||||||
rowIndexes.forEach(rowIndex => {
|
|
||||||
// Create errors object if needed
|
|
||||||
if (!uniqueErrors.has(rowIndex)) {
|
|
||||||
uniqueErrors.set(rowIndex, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add error for this field
|
|
||||||
uniqueErrors.get(rowIndex)![fieldKey] = {
|
|
||||||
message: errorMessage,
|
|
||||||
level: level as 'info' | 'warning' | 'error',
|
|
||||||
source: ErrorSources.Table,
|
|
||||||
type: ErrorType.Unique
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return uniqueErrors;
|
|
||||||
}, [fields]);
|
|
||||||
|
|
||||||
// Run complete validation
|
// Run complete validation
|
||||||
const validateData = useCallback(async (data: RowData<T>[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => {
|
const validateData = useCallback(async (data: RowData<T>[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => {
|
||||||
@@ -341,9 +116,6 @@ export const useValidation = <T extends string>(
|
|||||||
// Full validation - all fields for all rows
|
// Full validation - all fields for all rows
|
||||||
console.log('Running full validation for all fields and rows');
|
console.log('Running full validation for all fields and rows');
|
||||||
|
|
||||||
// Clear validation cache for full validation
|
|
||||||
validationResultCache.clear();
|
|
||||||
|
|
||||||
// Process each row for field-level validations
|
// Process each row for field-level validations
|
||||||
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
|
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
|
||||||
const row = data[rowIndex];
|
const row = data[rowIndex];
|
||||||
@@ -371,38 +143,15 @@ export const useValidation = <T extends string>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get fields requiring uniqueness validation
|
// Validate all unique fields
|
||||||
const uniqueFields = fields.filter(field =>
|
const uniqueErrors = validateAllUniqueFields(data);
|
||||||
field.validations?.some(v => v.rule === 'unique')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also add standard unique fields that might not be explicitly marked as unique
|
// Merge in unique errors
|
||||||
const standardUniqueFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
|
uniqueErrors.forEach((errors, rowIdx) => {
|
||||||
|
if (!validationErrors.has(rowIdx)) {
|
||||||
// Combine all fields that need uniqueness validation
|
validationErrors.set(rowIdx, {});
|
||||||
const allUniqueFieldKeys = new Set([
|
}
|
||||||
...uniqueFields.map(field => String(field.key)),
|
Object.assign(validationErrors.get(rowIdx)!, errors);
|
||||||
...standardUniqueFields
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Log uniqueness validation fields
|
|
||||||
console.log('Validating unique fields:', Array.from(allUniqueFieldKeys));
|
|
||||||
|
|
||||||
// Run uniqueness validation for each unique field
|
|
||||||
allUniqueFieldKeys.forEach(fieldKey => {
|
|
||||||
// Check if this field exists in the data
|
|
||||||
const hasField = data.some(row => fieldKey in row);
|
|
||||||
if (!hasField) return;
|
|
||||||
|
|
||||||
const uniqueErrors = validateUniqueField(data, fieldKey);
|
|
||||||
|
|
||||||
// Add unique errors to validation errors
|
|
||||||
uniqueErrors.forEach((errors, rowIdx) => {
|
|
||||||
if (!validationErrors.has(rowIdx)) {
|
|
||||||
validationErrors.set(rowIdx, {});
|
|
||||||
}
|
|
||||||
Object.assign(validationErrors.get(rowIdx)!, errors);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Uniqueness validation complete');
|
console.log('Uniqueness validation complete');
|
||||||
@@ -412,7 +161,7 @@ export const useValidation = <T extends string>(
|
|||||||
data,
|
data,
|
||||||
validationErrors
|
validationErrors
|
||||||
};
|
};
|
||||||
}, [fields, validateField, validateUniqueField]);
|
}, [fields, validateField, validateUniqueField, validateAllUniqueFields]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validateData,
|
validateData,
|
||||||
@@ -421,5 +170,5 @@ export const useValidation = <T extends string>(
|
|||||||
validateUniqueField,
|
validateUniqueField,
|
||||||
clearValidationCacheForField,
|
clearValidationCacheForField,
|
||||||
clearAllUniquenessCaches
|
clearAllUniquenessCaches
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
|||||||
|
import type { Data } from "../../../types";
|
||||||
|
import { ErrorSources, ErrorType } from "../../../types";
|
||||||
|
import config from "@/config";
|
||||||
|
|
||||||
|
// Define the Props interface for ValidationStepNew
|
||||||
|
export interface Props<T extends string> {
|
||||||
|
initialData: RowData<T>[];
|
||||||
|
file?: File;
|
||||||
|
onBack?: () => void;
|
||||||
|
onNext?: (data: RowData<T>[]) => void;
|
||||||
|
isFromScratch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended Data type with meta information
|
||||||
|
export type RowData<T extends string> = Data<T> & {
|
||||||
|
__index?: string;
|
||||||
|
__template?: string;
|
||||||
|
__original?: Record<string, any>;
|
||||||
|
__corrected?: Record<string, any>;
|
||||||
|
__changes?: Record<string, boolean>;
|
||||||
|
upc?: string;
|
||||||
|
barcode?: string;
|
||||||
|
supplier?: string;
|
||||||
|
company?: string;
|
||||||
|
item_number?: string;
|
||||||
|
[key: string]: any; // Allow any string key for dynamic fields
|
||||||
|
};
|
||||||
|
|
||||||
|
// Template interface
|
||||||
|
export interface Template {
|
||||||
|
id: number;
|
||||||
|
company: string;
|
||||||
|
product_type: string;
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props for the useValidationState hook
|
||||||
|
export interface ValidationStateProps<T extends string> extends Props<T> {}
|
||||||
|
|
||||||
|
// Interface for validation results
|
||||||
|
export interface ValidationResult {
|
||||||
|
error?: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
type?: ErrorType;
|
||||||
|
source?: ErrorSources;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter state interface
|
||||||
|
export interface FilterState {
|
||||||
|
searchText: string;
|
||||||
|
showErrorsOnly: boolean;
|
||||||
|
filterField: string | null;
|
||||||
|
filterValue: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI validation state interface for useUpcValidation
|
||||||
|
export interface ValidationState {
|
||||||
|
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
|
||||||
|
itemNumbers: Map<number, string>; // Using rowIndex as key
|
||||||
|
validatingRows: Set<number>; // Rows currently being validated
|
||||||
|
activeValidations: Set<string>; // Active validations
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoWithSource interface for validation errors
|
||||||
|
export interface InfoWithSource {
|
||||||
|
message: string;
|
||||||
|
level: 'info' | 'warning' | 'error';
|
||||||
|
source: ErrorSources;
|
||||||
|
type: ErrorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template state interface
|
||||||
|
export interface TemplateState {
|
||||||
|
selectedTemplateId: string | null;
|
||||||
|
showSaveTemplateDialog: boolean;
|
||||||
|
newTemplateName: string;
|
||||||
|
newTemplateType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add config at the top of the file
|
||||||
|
// Import the config or access it through window
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
config?: {
|
||||||
|
apiUrl: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a helper to get API URL consistently
|
||||||
|
export const getApiUrl = () => config.apiUrl;
|
||||||
|
|
||||||
|
// Shared utility function for checking empty values
|
||||||
|
export const isEmpty = (value: any): boolean =>
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
value === '' ||
|
||||||
|
(Array.isArray(value) && value.length === 0) ||
|
||||||
|
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import ValidationContainer from './components/ValidationContainer'
|
import ValidationContainer from './components/ValidationContainer'
|
||||||
import { Props } from './hooks/useValidationState'
|
import { Props } from './hooks/validationTypes'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ValidationStepNew component - modern implementation of the validation step
|
* ValidationStepNew component - modern implementation of the validation step
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { ErrorType } from '../types/index'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts an InfoWithSource or similar error object to our Error type
|
|
||||||
* @param error The error object to convert
|
|
||||||
* @returns Our standardized Error object
|
|
||||||
*/
|
|
||||||
export const convertToError = (error: any): ErrorType => {
|
|
||||||
return {
|
|
||||||
message: typeof error.message === 'string' ? error.message : String(error.message || ''),
|
|
||||||
level: error.level || 'error',
|
|
||||||
source: error.source || 'row',
|
|
||||||
type: error.type || 'custom'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely convert an error or array of errors to our Error[] format
|
|
||||||
* @param errors The error or array of errors to convert
|
|
||||||
* @returns Array of our Error objects
|
|
||||||
*/
|
|
||||||
export const convertToErrorArray = (errors: any): ErrorType[] => {
|
|
||||||
if (Array.isArray(errors)) {
|
|
||||||
return errors.map(convertToError)
|
|
||||||
}
|
|
||||||
return [convertToError(errors)]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a record of errors to our standardized format
|
|
||||||
* @param errorRecord Record with string keys and error values
|
|
||||||
* @returns Standardized error record
|
|
||||||
*/
|
|
||||||
export const convertErrorRecord = (errorRecord: Record<string, any>): Record<string, ErrorType[]> => {
|
|
||||||
const result: Record<string, ErrorType[]> = {}
|
|
||||||
|
|
||||||
Object.entries(errorRecord).forEach(([key, errors]) => {
|
|
||||||
result[key] = convertToErrorArray(errors)
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for validating UPC codes
|
|
||||||
* @param upcValue UPC value to validate
|
|
||||||
* @returns Validation result
|
|
||||||
*/
|
|
||||||
export const validateUpc = async (upcValue: string): Promise<any> => {
|
|
||||||
// Basic validation - UPC should be 12-14 digits
|
|
||||||
if (!/^\d{12,14}$/.test(upcValue)) {
|
|
||||||
toast.error('Invalid UPC format. UPC should be 12-14 digits.')
|
|
||||||
return {
|
|
||||||
error: true,
|
|
||||||
message: 'Invalid UPC format'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In a real implementation, call an API to validate the UPC
|
|
||||||
// For now, just return a successful result
|
|
||||||
return {
|
|
||||||
error: false,
|
|
||||||
data: {
|
|
||||||
// Mock data that would be returned from the API
|
|
||||||
item_number: `ITEM-${upcValue.substring(0, 6)}`,
|
|
||||||
sku: `SKU-${upcValue.substring(0, 4)}`,
|
|
||||||
description: `Sample Product ${upcValue.substring(0, 4)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates an item number for a UPC
|
|
||||||
* @param upcValue UPC value
|
|
||||||
* @returns Generated item number
|
|
||||||
*/
|
|
||||||
export const generateItemNumber = (upcValue: string): string => {
|
|
||||||
// Simple item number generation logic
|
|
||||||
return `ITEM-${upcValue.substring(0, 6)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for handling UPC validation process
|
|
||||||
* @param upcValue UPC value to validate
|
|
||||||
* @param rowIndex Row index being validated
|
|
||||||
* @param updateRow Function to update row data
|
|
||||||
*/
|
|
||||||
export const handleUpcValidation = async (
|
|
||||||
upcValue: string,
|
|
||||||
rowIndex: number,
|
|
||||||
updateRow: (rowIndex: number, key: string, value: any) => void
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Validate the UPC
|
|
||||||
const result = await validateUpc(upcValue)
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.message || 'UPC validation failed')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update row with the validation result data
|
|
||||||
if (result.data) {
|
|
||||||
// Update each field returned from the API
|
|
||||||
Object.entries(result.data).forEach(([key, value]) => {
|
|
||||||
updateRow(rowIndex, key, value)
|
|
||||||
})
|
|
||||||
|
|
||||||
toast.success('UPC validated successfully')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error validating UPC:', error)
|
|
||||||
toast.error('Failed to validate UPC')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* Helper functions for validation that ensure proper error objects
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Create a standard error object
|
|
||||||
export const createError = (message, level = 'error', source = 'row') => {
|
|
||||||
return { message, level, source };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert any error to standard format
|
|
||||||
export const convertError = (error) => {
|
|
||||||
if (!error) return createError('Unknown error');
|
|
||||||
|
|
||||||
if (typeof error === 'string') {
|
|
||||||
return createError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: error.message || 'Unknown error',
|
|
||||||
level: error.level || 'error',
|
|
||||||
source: error.source || 'row'
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert array of errors or single error to array
|
|
||||||
export const convertToErrorArray = (errors) => {
|
|
||||||
if (Array.isArray(errors)) {
|
|
||||||
return errors.map(convertError);
|
|
||||||
}
|
|
||||||
return [convertError(errors)];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert a record of errors to standard format
|
|
||||||
export const convertErrorRecord = (errorRecord) => {
|
|
||||||
const result = {};
|
|
||||||
|
|
||||||
if (!errorRecord) return result;
|
|
||||||
|
|
||||||
Object.entries(errorRecord).forEach(([key, errors]) => {
|
|
||||||
result[key] = convertToErrorArray(errors);
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Field, Data, ErrorSources, ErrorType as ValidationErrorType } from '../../../types'
|
|
||||||
import { ErrorType } from '../types/index'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a price value to a consistent format
|
|
||||||
* @param value The price value to format
|
|
||||||
* @returns The formatted price string
|
|
||||||
*/
|
|
||||||
export const formatPrice = (value: string | number): string => {
|
|
||||||
if (!value) return ''
|
|
||||||
|
|
||||||
// Convert to string and clean
|
|
||||||
const numericValue = String(value).replace(/[^\d.]/g, '')
|
|
||||||
|
|
||||||
// Parse the number
|
|
||||||
const number = parseFloat(numericValue)
|
|
||||||
if (isNaN(number)) return ''
|
|
||||||
|
|
||||||
// Format as currency
|
|
||||||
return number.toLocaleString('en-US', {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a field is a price field
|
|
||||||
* @param field The field to check
|
|
||||||
* @returns True if the field is a price field
|
|
||||||
*/
|
|
||||||
export const isPriceField = (field: Field<any>): boolean => {
|
|
||||||
const fieldType = field.fieldType;
|
|
||||||
return (fieldType.type === 'input' || fieldType.type === 'multi-input') &&
|
|
||||||
'price' in fieldType &&
|
|
||||||
!!fieldType.price;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if a field is a multi-input type
|
|
||||||
* @param fieldType The field type to check
|
|
||||||
* @returns True if the field is a multi-input type
|
|
||||||
*/
|
|
||||||
export const isMultiInputType = (fieldType: any): boolean => {
|
|
||||||
return fieldType.type === 'multi-input'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the separator for multi-input fields
|
|
||||||
* @param fieldType The field type
|
|
||||||
* @returns The separator string
|
|
||||||
*/
|
|
||||||
export const getMultiInputSeparator = (fieldType: any): string => {
|
|
||||||
if (isMultiInputType(fieldType) && fieldType.separator) {
|
|
||||||
return fieldType.separator
|
|
||||||
}
|
|
||||||
return ','
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs regex validation on a value
|
|
||||||
* @param value The value to validate
|
|
||||||
* @param regex The regex pattern
|
|
||||||
* @param flags Regex flags
|
|
||||||
* @returns True if validation passes
|
|
||||||
*/
|
|
||||||
export const validateRegex = (value: any, regex: string, flags?: string): boolean => {
|
|
||||||
if (value === undefined || value === null || value === '') return true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const regexObj = new RegExp(regex, flags)
|
|
||||||
return regexObj.test(String(value))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Invalid regex in validation:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a validation error object
|
|
||||||
* @param message Error message
|
|
||||||
* @param level Error level
|
|
||||||
* @param source Error source
|
|
||||||
* @param type Error type
|
|
||||||
* @returns Error object
|
|
||||||
*/
|
|
||||||
export const createError = (
|
|
||||||
message: string,
|
|
||||||
level: 'info' | 'warning' | 'error' = 'error',
|
|
||||||
source: ErrorSources = ErrorSources.Row,
|
|
||||||
type: ValidationErrorType = ValidationErrorType.Custom
|
|
||||||
): ErrorType => {
|
|
||||||
return {
|
|
||||||
message,
|
|
||||||
level,
|
|
||||||
source,
|
|
||||||
type
|
|
||||||
} as ErrorType
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a display value based on field type
|
|
||||||
* @param value The value to format
|
|
||||||
* @param field The field definition
|
|
||||||
* @returns Formatted display value
|
|
||||||
*/
|
|
||||||
export const getDisplayValue = (value: any, field: Field<any>): string => {
|
|
||||||
if (value === undefined || value === null) return ''
|
|
||||||
|
|
||||||
// Handle price fields
|
|
||||||
if (isPriceField(field)) {
|
|
||||||
return formatPrice(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle multi-input fields
|
|
||||||
if (isMultiInputType(field.fieldType)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.join(`${getMultiInputSeparator(field.fieldType)} `)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle boolean values
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value ? 'Yes' : 'No'
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates supplier and company fields
|
|
||||||
* @param row The data row
|
|
||||||
* @returns Object with errors for invalid fields
|
|
||||||
*/
|
|
||||||
export const validateSpecialFields = <T extends string>(row: Data<T>): Record<string, ErrorType[]> => {
|
|
||||||
const errors: Record<string, ErrorType[]> = {}
|
|
||||||
|
|
||||||
// Validate supplier field
|
|
||||||
if (!row.supplier) {
|
|
||||||
errors['supplier'] = [{
|
|
||||||
message: 'Supplier is required',
|
|
||||||
level: 'error',
|
|
||||||
source: ErrorSources.Row,
|
|
||||||
type: ValidationErrorType.Required
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate company field
|
|
||||||
if (!row.company) {
|
|
||||||
errors['company'] = [{
|
|
||||||
message: 'Company is required',
|
|
||||||
level: 'error',
|
|
||||||
source: ErrorSources.Row,
|
|
||||||
type: ValidationErrorType.Required
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges multiple error objects
|
|
||||||
* @param errors Array of error objects to merge
|
|
||||||
* @returns Merged error object
|
|
||||||
*/
|
|
||||||
export const mergeErrors = (...errors: Record<string, ErrorType[]>[]): Record<string, ErrorType[]> => {
|
|
||||||
const merged: Record<string, ErrorType[]> = {}
|
|
||||||
|
|
||||||
errors.forEach(errorObj => {
|
|
||||||
if (!errorObj) return
|
|
||||||
|
|
||||||
Object.entries(errorObj).forEach(([key, errs]) => {
|
|
||||||
if (!merged[key]) {
|
|
||||||
merged[key] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
merged[key] = [
|
|
||||||
...merged[key],
|
|
||||||
...(Array.isArray(errs) ? errs : [errs as ErrorType])
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
@@ -133,8 +133,9 @@ export function PerformanceMetrics() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function getCategoryName(_cat_id: number): import("react").ReactNode {
|
function getCategoryName(cat_id: number): import("react").ReactNode {
|
||||||
throw new Error('Function not implemented.');
|
// Simple implementation that just returns the ID as a string
|
||||||
|
return `Category ${cat_id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -217,15 +218,19 @@ export function PerformanceMetrics() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{abcConfigs.map((config) => (
|
{abcConfigs && abcConfigs.length > 0 ? abcConfigs.map((config) => (
|
||||||
<TableRow key={`${config.cat_id}-${config.vendor}`}>
|
<TableRow key={`${config.cat_id}-${config.vendor}`}>
|
||||||
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
|
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
|
||||||
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
|
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
|
||||||
<TableCell className="text-right">{config.a_threshold}%</TableCell>
|
<TableCell className="text-right">{config.a_threshold !== undefined ? `${config.a_threshold}%` : '0%'}</TableCell>
|
||||||
<TableCell className="text-right">{config.b_threshold}%</TableCell>
|
<TableCell className="text-right">{config.b_threshold !== undefined ? `${config.b_threshold}%` : '0%'}</TableCell>
|
||||||
<TableCell className="text-right">{config.classification_period_days}</TableCell>
|
<TableCell className="text-right">{config.classification_period_days || 0}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
)) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-4">No ABC configurations available</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
<Button onClick={handleUpdateABCConfig}>
|
<Button onClick={handleUpdateABCConfig}>
|
||||||
@@ -253,14 +258,26 @@ export function PerformanceMetrics() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{turnoverConfigs.map((config) => (
|
{turnoverConfigs && turnoverConfigs.length > 0 ? turnoverConfigs.map((config) => (
|
||||||
<TableRow key={`${config.cat_id}-${config.vendor}`}>
|
<TableRow key={`${config.cat_id}-${config.vendor}`}>
|
||||||
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
|
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
|
||||||
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
|
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
|
||||||
<TableCell className="text-right">{config.calculation_period_days}</TableCell>
|
<TableCell className="text-right">{config.calculation_period_days}</TableCell>
|
||||||
<TableCell className="text-right">{config.target_rate.toFixed(2)}</TableCell>
|
<TableCell className="text-right">
|
||||||
|
{config.target_rate !== undefined && config.target_rate !== null
|
||||||
|
? (typeof config.target_rate === 'number'
|
||||||
|
? config.target_rate.toFixed(2)
|
||||||
|
: (isNaN(parseFloat(String(config.target_rate)))
|
||||||
|
? '0.00'
|
||||||
|
: parseFloat(String(config.target_rate)).toFixed(2)))
|
||||||
|
: '0.00'}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
)) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-4">No turnover configurations available</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
<Button onClick={handleUpdateTurnoverConfig}>
|
<Button onClick={handleUpdateTurnoverConfig}>
|
||||||
|
|||||||
130
inventory/src/components/settings/PermissionSelector.tsx
Normal file
130
inventory/src/components/settings/PermissionSelector.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionCategory {
|
||||||
|
category: string;
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionSelectorProps {
|
||||||
|
permissionsByCategory: PermissionCategory[];
|
||||||
|
selectedPermissions: number[];
|
||||||
|
onChange: (selectedPermissions: number[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionSelector({
|
||||||
|
permissionsByCategory,
|
||||||
|
selectedPermissions,
|
||||||
|
onChange,
|
||||||
|
disabled = false
|
||||||
|
}: PermissionSelectorProps) {
|
||||||
|
// Handle permission checkbox change
|
||||||
|
const handlePermissionChange = (permissionId: number) => {
|
||||||
|
const newSelectedPermissions = selectedPermissions.includes(permissionId)
|
||||||
|
? selectedPermissions.filter(id => id !== permissionId)
|
||||||
|
: [...selectedPermissions, permissionId];
|
||||||
|
|
||||||
|
onChange(newSelectedPermissions);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle selecting/deselecting all permissions in a category
|
||||||
|
const handleSelectCategory = (category: string) => {
|
||||||
|
const categoryData = permissionsByCategory.find(c => c.category === category);
|
||||||
|
if (!categoryData) return;
|
||||||
|
|
||||||
|
const categoryPermIds = categoryData.permissions.map(p => p.id);
|
||||||
|
|
||||||
|
// Check if all permissions in category are already selected
|
||||||
|
const allSelected = categoryPermIds.every(id => selectedPermissions.includes(id));
|
||||||
|
|
||||||
|
// If all selected, deselect all; otherwise select all
|
||||||
|
const newSelectedPermissions = allSelected
|
||||||
|
? selectedPermissions.filter(id => !categoryPermIds.includes(id))
|
||||||
|
: [...new Set([...selectedPermissions, ...categoryPermIds])];
|
||||||
|
|
||||||
|
onChange(newSelectedPermissions);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if there are no permission categories
|
||||||
|
if (!permissionsByCategory || permissionsByCategory.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<p className="text-muted-foreground">No permissions available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{permissionsByCategory.map(category => (
|
||||||
|
<Card key={category.category} className="mb-4">
|
||||||
|
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-md">{category.category}</CardTitle>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSelectCategory(category.category)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{category.permissions.every(p => selectedPermissions.includes(p.id))
|
||||||
|
? 'Deselect All'
|
||||||
|
: 'Select All'}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
{category.permissions.map(permission => (
|
||||||
|
<div key={permission.id} className="flex items-center space-x-2 py-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`permission-${permission.id}`}
|
||||||
|
checked={selectedPermissions.includes(permission.id)}
|
||||||
|
onCheckedChange={() => handlePermissionChange(permission.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`permission-${permission.id}`}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex items-center"
|
||||||
|
>
|
||||||
|
{permission.name}
|
||||||
|
|
||||||
|
{permission.description && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 ml-1 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{permission.description}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Code: {permission.code}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
338
inventory/src/components/settings/UserForm.tsx
Normal file
338
inventory/src/components/settings/UserForm.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { PermissionSelector } from "./PermissionSelector";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
permissions?: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionCategory {
|
||||||
|
category: string;
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserFormProps {
|
||||||
|
user: User | null;
|
||||||
|
permissions: PermissionCategory[];
|
||||||
|
onSave: (userData: any) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation schema
|
||||||
|
const userFormSchema = z.object({
|
||||||
|
username: z.string().min(3, { message: "Username must be at least 3 characters" }),
|
||||||
|
email: z.string().email({ message: "Please enter a valid email" }).optional().or(z.literal("")),
|
||||||
|
password: z.string().min(6, { message: "Password must be at least 6 characters" }).optional().or(z.literal("")),
|
||||||
|
is_admin: z.boolean().default(false),
|
||||||
|
is_active: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof userFormSchema>;
|
||||||
|
|
||||||
|
// Helper function to get all permission IDs from all categories
|
||||||
|
const getAllPermissionIds = (permissionCategories: PermissionCategory[]): number[] => {
|
||||||
|
const allIds: number[] = [];
|
||||||
|
|
||||||
|
if (permissionCategories && permissionCategories.length > 0) {
|
||||||
|
permissionCategories.forEach(category => {
|
||||||
|
category.permissions.forEach(permission => {
|
||||||
|
allIds.push(permission.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return allIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
// User save data interface (represents the data structure for saving users)
|
||||||
|
interface UserSaveData {
|
||||||
|
id?: number;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps) {
|
||||||
|
const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Initialize the form with React Hook Form
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(userFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
username: user?.username || "",
|
||||||
|
email: user?.email || "",
|
||||||
|
password: "", // Don't pre-fill password
|
||||||
|
is_admin: user?.is_admin || false,
|
||||||
|
is_active: user?.is_active !== false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize selected permissions
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const onSubmit = (data: FormValues) => {
|
||||||
|
try {
|
||||||
|
setFormError(null);
|
||||||
|
console.log("Form submitted with permissions:", selectedPermissions);
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (!user && !data.password) {
|
||||||
|
setFormError("Password is required for new users");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the data
|
||||||
|
const userData: UserSaveData = {
|
||||||
|
...data,
|
||||||
|
id: user?.id, // Include ID if editing existing user
|
||||||
|
permissions: [] // Initialize with empty array
|
||||||
|
};
|
||||||
|
|
||||||
|
// If editing and password is empty, remove it
|
||||||
|
if (user && !userData.password) {
|
||||||
|
delete userData.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add permissions if not admin
|
||||||
|
if (!data.is_admin) {
|
||||||
|
// Find the actual permission objects from selectedPermissions IDs
|
||||||
|
const selectedPermissionObjects: Permission[] = [];
|
||||||
|
|
||||||
|
if (permissions && permissions.length > 0) {
|
||||||
|
// Loop through all available permissions to find the selected ones
|
||||||
|
permissions.forEach(category => {
|
||||||
|
category.permissions.forEach(permission => {
|
||||||
|
// If this permission's ID is in the selectedPermissions array, add it
|
||||||
|
if (selectedPermissions.includes(permission.id)) {
|
||||||
|
selectedPermissionObjects.push(permission);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
userData.permissions = selectedPermissionObjects;
|
||||||
|
} else {
|
||||||
|
// For admin users, don't send permissions as they're implied
|
||||||
|
userData.permissions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Saving user data:", userData);
|
||||||
|
onSave(userData);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "An error occurred";
|
||||||
|
setFormError(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{formError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="is_admin"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-4 border rounded-md">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Administrator</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Administrators have access to all permissions
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="is_active"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-4 border rounded-md">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Active</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Inactive users cannot log in
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{permissions && permissions.length > 0 && (
|
||||||
|
<>
|
||||||
|
{form.watch("is_admin") ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">Permissions</h3>
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Administrators have access to all permissions by default. Individual permissions cannot be edited for admin users.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<PermissionSelector
|
||||||
|
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}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{user ? "Update User" : "Create User"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
inventory/src/components/settings/UserList.tsx
Normal file
94
inventory/src/components/settings/UserList.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
last_login?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserListProps {
|
||||||
|
users: User[];
|
||||||
|
onEdit: (userId: number) => void;
|
||||||
|
onDelete: (userId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserList({ users, onEdit, onDelete }: UserListProps) {
|
||||||
|
console.log("Rendering user list with users:", users);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">No users found</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Username</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Admin</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Last Login</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.username}</TableCell>
|
||||||
|
<TableCell>{user.email || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.is_admin ? (
|
||||||
|
<Badge variant="default">Admin</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">No</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.is_active ? (
|
||||||
|
<Badge variant="default" className="bg-green-100 text-green-800 hover:bg-green-100">Active</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-slate-100">Inactive</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.last_login
|
||||||
|
? formatDistanceToNow(new Date(user.last_login), { addSuffix: true })
|
||||||
|
: 'Never'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(user.id)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(user.id)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Delete</span>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
inventory/src/components/settings/UserManagement.tsx
Normal file
353
inventory/src/components/settings/UserManagement.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import { useState, useEffect, useContext } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { UserList } from "./UserList";
|
||||||
|
import { UserForm } from "./UserForm";
|
||||||
|
import config from "@/config";
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
|
import { ShieldAlert } from "lucide-react";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
last_login?: string;
|
||||||
|
permissions?: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionCategory {
|
||||||
|
category: string;
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserManagement() {
|
||||||
|
const { token, fetchCurrentUser } = useContext(AuthContext);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
|
const [isAddingUser, setIsAddingUser] = useState(false);
|
||||||
|
const [permissions, setPermissions] = useState<PermissionCategory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch users and permissions
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (!token) {
|
||||||
|
setError("Authentication required. Please log in again.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The PermissionGuard component already handles permission checks,
|
||||||
|
// so we don't need to duplicate that logic here
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Fetch users
|
||||||
|
const usersResponse = await fetch(`${config.authUrl}/users`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!usersResponse.ok) {
|
||||||
|
if (usersResponse.status === 401) {
|
||||||
|
throw new Error('Authentication failed. Please log in again.');
|
||||||
|
} else if (usersResponse.status === 403) {
|
||||||
|
throw new Error('You don\'t have permission to access the user list.');
|
||||||
|
} else {
|
||||||
|
// Try to get more detailed error message from response
|
||||||
|
try {
|
||||||
|
const errorData = await usersResponse.json();
|
||||||
|
throw new Error(errorData.error || `Failed to fetch users (${usersResponse.status})`);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Failed to fetch users (${usersResponse.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersData = await usersResponse.json();
|
||||||
|
setUsers(usersData);
|
||||||
|
|
||||||
|
// Fetch permissions
|
||||||
|
const permissionsResponse = await fetch(`${config.authUrl}/permissions/categories`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!permissionsResponse.ok) {
|
||||||
|
if (permissionsResponse.status === 401) {
|
||||||
|
throw new Error('Authentication failed. Please log in again.');
|
||||||
|
} else if (permissionsResponse.status === 403) {
|
||||||
|
throw new Error('You don\'t have permission to access permissions.');
|
||||||
|
} else {
|
||||||
|
// Try to get more detailed error message from response
|
||||||
|
try {
|
||||||
|
const errorData = await permissionsResponse.json();
|
||||||
|
throw new Error(errorData.error || `Failed to fetch permissions (${permissionsResponse.status})`);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Failed to fetch permissions (${permissionsResponse.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionsData = await permissionsResponse.json();
|
||||||
|
setPermissions(permissionsData);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'An error occurred';
|
||||||
|
setError(errorMessage);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
// If authentication error, refresh the token
|
||||||
|
if (err instanceof Error && err.message.includes('Authentication failed')) {
|
||||||
|
fetchCurrentUser().catch(() => {
|
||||||
|
// Handle failed token refresh
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleEditUser = async (userId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${config.authUrl}/users/${userId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error('Authentication failed. Please log in again.');
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
throw new Error('You don\'t have permission to edit users.');
|
||||||
|
} else {
|
||||||
|
throw new Error(`Failed to fetch user details (${response.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await response.json();
|
||||||
|
console.log("Fetched user data for editing:", userData);
|
||||||
|
|
||||||
|
// Ensure permissions is always an array
|
||||||
|
if (!userData.permissions) {
|
||||||
|
userData.permissions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the permissions are in the right format for the form
|
||||||
|
// The server might send either an array of permission objects or just permission codes
|
||||||
|
if (userData.permissions && userData.permissions.length > 0) {
|
||||||
|
// Check if permissions are objects with id property
|
||||||
|
if (typeof userData.permissions[0] === 'string') {
|
||||||
|
// If we just have permission codes, we need to convert them to objects with ids
|
||||||
|
// by looking them up in the permissions data
|
||||||
|
const permissionObjects = [];
|
||||||
|
|
||||||
|
// Go through each permission category
|
||||||
|
for (const category of permissions) {
|
||||||
|
// For each permission in the category
|
||||||
|
for (const permission of category.permissions) {
|
||||||
|
// If this permission's code is in the user's permission codes
|
||||||
|
if (userData.permissions.includes(permission.code)) {
|
||||||
|
permissionObjects.push(permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userData.permissions = permissionObjects;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedUser(userData);
|
||||||
|
setIsAddingUser(false);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to load user details';
|
||||||
|
setError(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUser = () => {
|
||||||
|
setSelectedUser(null);
|
||||||
|
setIsAddingUser(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveUser = async (userData: any) => {
|
||||||
|
console.log("Saving user data:", userData);
|
||||||
|
|
||||||
|
// Format permissions for the API - convert from permission objects to IDs
|
||||||
|
let formattedUserData = { ...userData };
|
||||||
|
|
||||||
|
if (userData.permissions && Array.isArray(userData.permissions)) {
|
||||||
|
// Check if permissions are objects (from the form) and convert to IDs for the API
|
||||||
|
if (userData.permissions.length > 0 && typeof userData.permissions[0] === 'object') {
|
||||||
|
// The backend expects permission IDs, not just the code strings
|
||||||
|
formattedUserData.permissions = userData.permissions.map((p: { id: any; }) => p.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Formatted user data for API:", formattedUserData);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use PUT for updating, POST for creating
|
||||||
|
const method = userData.id ? 'PUT' : 'POST';
|
||||||
|
const endpoint = userData.id
|
||||||
|
? `${config.authUrl}/users/${userData.id}`
|
||||||
|
: `${config.authUrl}/users`;
|
||||||
|
|
||||||
|
console.log(`${method} request to ${endpoint}`);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formattedUserData)
|
||||||
|
});
|
||||||
|
|
||||||
|
let responseData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
responseData = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error parsing response JSON:", e);
|
||||||
|
throw new Error("Invalid response from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Error response from server:", responseData);
|
||||||
|
throw new Error(responseData.error || responseData.message || `Failed to save user (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Server response after saving user:", responseData);
|
||||||
|
|
||||||
|
// Reset the form state
|
||||||
|
setSelectedUser(null);
|
||||||
|
setIsAddingUser(false);
|
||||||
|
|
||||||
|
// Refresh the user list
|
||||||
|
fetchData();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to save user';
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId: number) => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete this user?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const response = await fetch(`${config.authUrl}/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh user list after a successful delete
|
||||||
|
await fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to delete user';
|
||||||
|
setError(errorMessage);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setSelectedUser(null);
|
||||||
|
setIsAddingUser(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && users.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-center h-40">
|
||||||
|
<p>Loading user data...</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<ShieldAlert className="h-4 w-4" />
|
||||||
|
<AlertTitle>Permission Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button onClick={fetchData}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{(selectedUser || isAddingUser) ? (
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<UserForm
|
||||||
|
user={selectedUser}
|
||||||
|
permissions={permissions}
|
||||||
|
onSave={handleSaveUser}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>User Management</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAddUser}>
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UserList
|
||||||
|
users={users}
|
||||||
|
onEdit={handleEditUser}
|
||||||
|
onDelete={handleDeleteUser}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
inventory/src/components/ui/form.tsx
Normal file
176
inventory/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ControllerProps,
|
||||||
|
FieldPath,
|
||||||
|
FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState, formState } = useFormContext()
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-sm font-medium text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
158
inventory/src/contexts/AuthContext.tsx
Normal file
158
inventory/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { createContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||||
|
import config from '@/config';
|
||||||
|
|
||||||
|
export interface Permission {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email?: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
fetchCurrentUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultContext: AuthContextType = {
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
login: async () => {},
|
||||||
|
logout: () => {},
|
||||||
|
fetchCurrentUser: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextType>(defaultContext);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'));
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchCurrentUser = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(`${config.authUrl}/me`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || 'Failed to fetch user data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await response.json();
|
||||||
|
console.log("Fetched current user data:", userData);
|
||||||
|
console.log("User permissions:", userData.permissions);
|
||||||
|
|
||||||
|
setUser(userData);
|
||||||
|
// Ensure we have the sessionStorage isLoggedIn flag set
|
||||||
|
sessionStorage.setItem('isLoggedIn', 'true');
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('Auth error:', errorMessage);
|
||||||
|
|
||||||
|
// Clear token if authentication failed
|
||||||
|
if (err instanceof Error &&
|
||||||
|
(err.message.includes('authentication') ||
|
||||||
|
err.message.includes('token') ||
|
||||||
|
err.message.includes('401'))) {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// Load token and fetch user data on init
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
fetchCurrentUser();
|
||||||
|
} else {
|
||||||
|
// Clear sessionStorage if no token exists
|
||||||
|
sessionStorage.removeItem('isLoggedIn');
|
||||||
|
}
|
||||||
|
}, [token, fetchCurrentUser]);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(`${config.authUrl}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("Login successful, received data:", data);
|
||||||
|
console.log("User permissions:", data.user?.permissions);
|
||||||
|
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
sessionStorage.setItem('isLoggedIn', 'true');
|
||||||
|
setToken(data.token);
|
||||||
|
setUser(data.user);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Login failed';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('Login error:', errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
sessionStorage.removeItem('isLoggedIn');
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
fetchCurrentUser,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { useEffect, useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { Code } from "@/components/ui/code"
|
|
||||||
import { useToast } from "@/hooks/use-toast"
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
import config from "@/config"
|
|
||||||
|
|
||||||
interface TaxonomyStats {
|
|
||||||
categories: number
|
|
||||||
themes: number
|
|
||||||
colors: number
|
|
||||||
taxCodes: number
|
|
||||||
sizeCategories: number
|
|
||||||
suppliers: number
|
|
||||||
companies: number
|
|
||||||
artists: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DebugData {
|
|
||||||
taxonomyStats: TaxonomyStats | null
|
|
||||||
basePrompt: string
|
|
||||||
sampleFullPrompt: string
|
|
||||||
promptLength: number
|
|
||||||
estimatedProcessingTime?: {
|
|
||||||
seconds: number | null
|
|
||||||
sampleCount: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AiValidationDebug() {
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [debugData, setDebugData] = useState<DebugData | null>(null)
|
|
||||||
const { toast } = useToast()
|
|
||||||
|
|
||||||
const fetchDebugData = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
// Use a sample product to avoid loading full taxonomy
|
|
||||||
const sampleProduct = {
|
|
||||||
title: "Sample Product",
|
|
||||||
description: "A sample product for testing",
|
|
||||||
SKU: "SAMPLE-001",
|
|
||||||
price: "9.99",
|
|
||||||
cost_each: "5.00",
|
|
||||||
qty_per_unit: "1",
|
|
||||||
case_qty: "12"
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${config.apiUrl}/ai-validation/debug`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ products: [sampleProduct] })
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch debug data')
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
setDebugData(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching debug data:', error)
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "Error",
|
|
||||||
description: error instanceof Error ? error.message : "Failed to fetch debug data"
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchDebugData()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto py-6 space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">AI Validation Debug</h1>
|
|
||||||
<div className="space-x-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={fetchDebugData}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Refresh Data
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{debugData && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Taxonomy Stats</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{debugData.taxonomyStats ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>Categories: {debugData.taxonomyStats.categories}</div>
|
|
||||||
<div>Themes: {debugData.taxonomyStats.themes}</div>
|
|
||||||
<div>Colors: {debugData.taxonomyStats.colors}</div>
|
|
||||||
<div>Tax Codes: {debugData.taxonomyStats.taxCodes}</div>
|
|
||||||
<div>Size Categories: {debugData.taxonomyStats.sizeCategories}</div>
|
|
||||||
<div>Suppliers: {debugData.taxonomyStats.suppliers}</div>
|
|
||||||
<div>Companies: {debugData.taxonomyStats.companies}</div>
|
|
||||||
<div>Artists: {debugData.taxonomyStats.artists}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>No taxonomy data available</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Prompt Length</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>Characters: {debugData.promptLength}</div>
|
|
||||||
<div>Tokens (est.): ~{Math.round(debugData.promptLength / 4)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="costPerMillion" className="text-sm text-muted-foreground">
|
|
||||||
Cost per million tokens ($)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="costPerMillion"
|
|
||||||
type="number"
|
|
||||||
className="w-full px-3 py-2 border rounded-md"
|
|
||||||
defaultValue="2.50"
|
|
||||||
onChange={(e) => {
|
|
||||||
const costPerMillion = parseFloat(e.target.value)
|
|
||||||
if (!isNaN(costPerMillion)) {
|
|
||||||
const tokens = Math.round(debugData.promptLength / 4)
|
|
||||||
const cost = (tokens / 1_000_000) * costPerMillion * 100 // Convert to cents
|
|
||||||
const costElement = document.getElementById('tokenCost')
|
|
||||||
if (costElement) {
|
|
||||||
costElement.textContent = cost.toFixed(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="text-sm">
|
|
||||||
Cost: <span id="tokenCost">{((Math.round(debugData.promptLength / 4) / 1_000_000) * 3 * 100).toFixed(1)}</span>¢
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{debugData.estimatedProcessingTime && (
|
|
||||||
<div className="mt-4 p-3 bg-muted rounded-md">
|
|
||||||
<h3 className="text-sm font-medium mb-2">Processing Time Estimate</h3>
|
|
||||||
{debugData.estimatedProcessingTime.seconds ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-sm">
|
|
||||||
Estimated time: {formatTime(debugData.estimatedProcessingTime.seconds)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Based on {debugData.estimatedProcessingTime.sampleCount} similar validation{debugData.estimatedProcessingTime.sampleCount !== 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted-foreground">No historical data available for this prompt size</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="col-span-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Full Sample Prompt</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ScrollArea className="h-[500px] w-full rounded-md border p-4">
|
|
||||||
<Code className="whitespace-pre-wrap">{debugData.sampleFullPrompt}</Code>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to format time in a human-readable way
|
|
||||||
function formatTime(seconds: number): string {
|
|
||||||
if (seconds < 60) {
|
|
||||||
return `${Math.round(seconds)} seconds`;
|
|
||||||
} else {
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const remainingSeconds = Math.round(seconds % 60);
|
|
||||||
return `${minutes}m ${remainingSeconds}s`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState, useContext } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import config from "../config";
|
|
||||||
import { Loader2, Box } from "lucide-react";
|
import { Loader2, Box } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "framer-motion";
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
@@ -15,59 +14,22 @@ export function Login() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const { login } = useContext(AuthContext);
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${config.authUrl}/login`;
|
await login(username, password);
|
||||||
console.log("Making login request:", {
|
|
||||||
url,
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: { username, password },
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
// Login successful, redirect to the requested page or home
|
||||||
method: "POST",
|
const redirectTo = searchParams.get("redirect") || "/";
|
||||||
headers: {
|
navigate(redirectTo);
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Login response status:", response.status);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ error: "Failed to parse error response" }));
|
|
||||||
console.error("Login failed:", data);
|
|
||||||
throw new Error(data.error || "Login failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log("Login successful:", data);
|
|
||||||
|
|
||||||
sessionStorage.setItem("token", data.token);
|
|
||||||
sessionStorage.setItem("isLoggedIn", "true");
|
|
||||||
toast.success("Successfully logged in");
|
|
||||||
|
|
||||||
// Get the redirect URL from the URL parameters, defaulting to "/"
|
|
||||||
const redirectTo = searchParams.get("redirect") || "/"
|
|
||||||
|
|
||||||
// Navigate to the redirect URL after successful login
|
|
||||||
navigate(redirectTo)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Login failed";
|
||||||
|
toast.error(message);
|
||||||
console.error("Login error:", error);
|
console.error("Login error:", error);
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,322 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from "@/components/ui/pagination";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { DateRangePicker } from "@/components/ui/date-range-picker";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { ArrowUpDown, Search } from "lucide-react";
|
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import config from '../config';
|
|
||||||
import { DateRange } from 'react-day-picker';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
interface Order {
|
|
||||||
order_number: string;
|
|
||||||
customer: string;
|
|
||||||
date: string;
|
|
||||||
status: string;
|
|
||||||
total_amount: number;
|
|
||||||
items_count: number;
|
|
||||||
payment_method: string;
|
|
||||||
shipping_method: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderFilters {
|
|
||||||
search: string;
|
|
||||||
status: string;
|
|
||||||
dateRange: DateRange;
|
|
||||||
minAmount: string;
|
|
||||||
maxAmount: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Orders() {
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [sortColumn, setSortColumn] = useState<keyof Order>('date');
|
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
|
||||||
const [filters, setFilters] = useState<OrderFilters>({
|
|
||||||
search: '',
|
|
||||||
status: 'all',
|
|
||||||
dateRange: { from: undefined, to: undefined },
|
|
||||||
minAmount: '',
|
|
||||||
maxAmount: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, isLoading, isFetching } = useQuery({
|
|
||||||
queryKey: ['orders', page, sortColumn, sortDirection, filters],
|
|
||||||
queryFn: async () => {
|
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
page: page.toString(),
|
|
||||||
limit: '50',
|
|
||||||
sortColumn: sortColumn.toString(),
|
|
||||||
sortDirection,
|
|
||||||
...filters.dateRange.from && { fromDate: filters.dateRange.from.toISOString() },
|
|
||||||
...filters.dateRange.to && { toDate: filters.dateRange.to.toISOString() },
|
|
||||||
...filters.minAmount && { minAmount: filters.minAmount },
|
|
||||||
...filters.maxAmount && { maxAmount: filters.maxAmount },
|
|
||||||
...filters.status !== 'all' && { status: filters.status },
|
|
||||||
...filters.search && { search: filters.search },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`${config.apiUrl}/orders?${searchParams}`);
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch orders');
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
staleTime: 30000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const debouncedFilterChange = useCallback(
|
|
||||||
debounce((newFilters: Partial<OrderFilters>) => {
|
|
||||||
setFilters(prev => ({ ...prev, ...newFilters }));
|
|
||||||
setPage(1);
|
|
||||||
}, 300),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSort = (column: keyof Order) => {
|
|
||||||
if (sortColumn === column) {
|
|
||||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
setSortColumn(column);
|
|
||||||
setSortDirection('asc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getOrderStatusBadge = (status: string) => {
|
|
||||||
const variants: Record<string, { variant: "default" | "secondary" | "destructive" | "outline"; label: string }> = {
|
|
||||||
pending: { variant: "outline", label: "Pending" },
|
|
||||||
processing: { variant: "secondary", label: "Processing" },
|
|
||||||
completed: { variant: "default", label: "Completed" },
|
|
||||||
cancelled: { variant: "destructive", label: "Cancelled" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusConfig = variants[status.toLowerCase()] || variants.pending;
|
|
||||||
return <Badge variant={statusConfig.variant}>{statusConfig.label}</Badge>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSortButton = (column: keyof Order, label: string) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleSort(column)}
|
|
||||||
className="w-full justify-start font-medium"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
<ArrowUpDown className={`ml-2 h-4 w-4 ${sortColumn === column && sortDirection === 'desc' ? 'rotate-180' : ''}`} />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div layout className="p-8 space-y-8">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-bold">Orders</h1>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{data?.pagination.total.toLocaleString() ?? '...'} orders
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{data?.stats.totalOrders ?? '...'}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
+{data?.stats.orderGrowth ?? 0}% from last month
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
${(data?.stats.totalRevenue ?? 0).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
+{data?.stats.revenueGrowth ?? 0}% from last month
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Average Order Value</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
${(data?.stats.averageOrderValue ?? 0).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
+{data?.stats.aovGrowth ?? 0}% from last month
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Conversion Rate</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{(data?.stats.conversionRate ?? 0).toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
+{data?.stats.conversionGrowth ?? 0}% from last month
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<Search className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search orders..."
|
|
||||||
value={filters.search}
|
|
||||||
onChange={(e) => debouncedFilterChange({ search: e.target.value })}
|
|
||||||
className="h-8 w-[300px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Select
|
|
||||||
value={filters.status}
|
|
||||||
onValueChange={(value) => debouncedFilterChange({ status: value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[180px]">
|
|
||||||
<SelectValue placeholder="Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
|
||||||
<SelectItem value="processing">Processing</SelectItem>
|
|
||||||
<SelectItem value="completed">Completed</SelectItem>
|
|
||||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<DateRangePicker
|
|
||||||
value={filters.dateRange}
|
|
||||||
onChange={(range: DateRange | undefined) => debouncedFilterChange({ dateRange: range })}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Min $"
|
|
||||||
value={filters.minAmount}
|
|
||||||
onChange={(e) => debouncedFilterChange({ minAmount: e.target.value })}
|
|
||||||
className="h-8 w-[100px]"
|
|
||||||
/>
|
|
||||||
<span>-</span>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Max $"
|
|
||||||
value={filters.maxAmount}
|
|
||||||
onChange={(e) => debouncedFilterChange({ maxAmount: e.target.value })}
|
|
||||||
className="h-8 w-[100px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{renderSortButton('order_number', 'Order')}</TableHead>
|
|
||||||
<TableHead>{renderSortButton('customer', 'Customer')}</TableHead>
|
|
||||||
<TableHead>{renderSortButton('date', 'Date')}</TableHead>
|
|
||||||
<TableHead>{renderSortButton('status', 'Status')}</TableHead>
|
|
||||||
<TableHead className="text-right">{renderSortButton('total_amount', 'Total')}</TableHead>
|
|
||||||
<TableHead className="text-center">{renderSortButton('items_count', 'Items')}</TableHead>
|
|
||||||
<TableHead>{renderSortButton('payment_method', 'Payment')}</TableHead>
|
|
||||||
<TableHead>{renderSortButton('shipping_method', 'Shipping')}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{isLoading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} className="text-center py-8">
|
|
||||||
Loading orders...
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : data?.orders.map((order: Order) => (
|
|
||||||
<TableRow key={order.order_number}>
|
|
||||||
<TableCell className="font-medium">#{order.order_number}</TableCell>
|
|
||||||
<TableCell>{order.customer}</TableCell>
|
|
||||||
<TableCell>{format(new Date(order.date), 'MMM d, yyyy')}</TableCell>
|
|
||||||
<TableCell>{getOrderStatusBadge(order.status)}</TableCell>
|
|
||||||
<TableCell className="text-right">${order.total_amount.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-center">{order.items_count}</TableCell>
|
|
||||||
<TableCell>{order.payment_method}</TableCell>
|
|
||||||
<TableCell>{order.shipping_method}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{!isLoading && !data?.orders.length && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
|
||||||
No orders found
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data?.pagination.pages > 1 && (
|
|
||||||
<Pagination>
|
|
||||||
<PaginationContent>
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationPrevious
|
|
||||||
aria-disabled={page === 1 || isFetching}
|
|
||||||
className={page === 1 || isFetching ? 'pointer-events-none opacity-50' : ''}
|
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
|
||||||
{Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => (
|
|
||||||
<PaginationItem key={p}>
|
|
||||||
<PaginationLink
|
|
||||||
isActive={p === page}
|
|
||||||
aria-disabled={isFetching}
|
|
||||||
className={isFetching ? 'pointer-events-none opacity-50' : ''}
|
|
||||||
onClick={() => setPage(p)}
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
))}
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationNext
|
|
||||||
aria-disabled={page === data.pagination.pages || isFetching}
|
|
||||||
className={page === data.pagination.pages || isFetching ? 'pointer-events-none opacity-50' : ''}
|
|
||||||
onClick={() => setPage(p => Math.min(data.pagination.pages, p + 1))}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,49 +4,233 @@ import { StockManagement } from "@/components/settings/StockManagement";
|
|||||||
import { PerformanceMetrics } from "@/components/settings/PerformanceMetrics";
|
import { PerformanceMetrics } from "@/components/settings/PerformanceMetrics";
|
||||||
import { CalculationSettings } from "@/components/settings/CalculationSettings";
|
import { CalculationSettings } from "@/components/settings/CalculationSettings";
|
||||||
import { TemplateManagement } from "@/components/settings/TemplateManagement";
|
import { TemplateManagement } from "@/components/settings/TemplateManagement";
|
||||||
import { motion } from 'motion/react';
|
import { UserManagement } from "@/components/settings/UserManagement";
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Protected } from "@/components/auth/Protected";
|
||||||
|
import { useContext, useMemo } from "react";
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
// Define types for settings structure
|
||||||
|
interface SettingsTab {
|
||||||
|
id: string;
|
||||||
|
permission: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsGroup {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
tabs: SettingsTab[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define available settings tabs with their permission requirements and groups
|
||||||
|
const SETTINGS_GROUPS: SettingsGroup[] = [
|
||||||
|
{
|
||||||
|
id: "inventory",
|
||||||
|
label: "Inventory Settings",
|
||||||
|
tabs: [
|
||||||
|
{ id: "stock-management", permission: "settings:stock_management", label: "Stock Management" },
|
||||||
|
{ id: "performance-metrics", permission: "settings:performance_metrics", label: "Performance Metrics" },
|
||||||
|
{ id: "calculation-settings", permission: "settings:calculation_settings", label: "Calculation Settings" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "content",
|
||||||
|
label: "Content Management",
|
||||||
|
tabs: [
|
||||||
|
{ id: "templates", permission: "settings:templates", label: "Template Management" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system",
|
||||||
|
label: "System",
|
||||||
|
tabs: [
|
||||||
|
{ id: "user-management", permission: "settings:user_management", label: "User Management" },
|
||||||
|
{ id: "data-management", permission: "settings:data_management", label: "Data Management" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Flatten tabs for easier access
|
||||||
|
const SETTINGS_TABS = SETTINGS_GROUPS.flatMap(group => group.tabs);
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
|
|
||||||
|
// Determine the first tab the user has access to
|
||||||
|
const defaultTab = useMemo(() => {
|
||||||
|
// Admin users have access to all tabs
|
||||||
|
if (user?.is_admin) {
|
||||||
|
return SETTINGS_TABS[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first tab the user has permission to access
|
||||||
|
const firstAccessibleTab = SETTINGS_TABS.find(tab =>
|
||||||
|
user?.permissions?.includes(tab.permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the ID of the first accessible tab, or first tab as fallback
|
||||||
|
return firstAccessibleTab?.id || SETTINGS_TABS[0].id;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Check if user has access to any tab
|
||||||
|
const hasAccessToAnyTab = useMemo(() => {
|
||||||
|
if (user?.is_admin) return true;
|
||||||
|
return SETTINGS_TABS.some(tab => user?.permissions?.includes(tab.permission));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// If user doesn't have access to any tabs, show a helpful message
|
||||||
|
if (!hasAccessToAnyTab) {
|
||||||
|
return (
|
||||||
|
<motion.div layout className="container mx-auto py-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Settings</h1>
|
||||||
|
</div>
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access any settings. Please contact an administrator for assistance.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to check if the user has access to any tab in a group
|
||||||
|
const hasAccessToGroup = (group: SettingsGroup): boolean => {
|
||||||
|
if (user?.is_admin) return true;
|
||||||
|
return group.tabs.some(tab => user?.permissions?.includes(tab.permission));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div layout className="container mx-auto py-6">
|
<motion.div layout className="container mx-auto py-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-3xl font-bold">Settings</h1>
|
<h1 className="text-3xl font-bold">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="data-management" className="space-y-4">
|
<Tabs defaultValue={defaultTab} orientation="vertical" className="flex flex-row min-h-[500px]">
|
||||||
<TabsList>
|
<div className="w-60 border-r pr-8">
|
||||||
<TabsTrigger value="data-management">Data Management</TabsTrigger>
|
<TabsList className="flex flex-col h-auto justify-start items-stretch p-0 bg-transparent">
|
||||||
<TabsTrigger value="stock-management">Stock Management</TabsTrigger>
|
{SETTINGS_GROUPS.map((group) => (
|
||||||
<TabsTrigger value="performance-metrics">
|
hasAccessToGroup(group) && (
|
||||||
Performance Metrics
|
<div key={group.id} className="">
|
||||||
</TabsTrigger>
|
<h3 className="font-semibold text-sm px-3 py-2 bg-muted border text-foreground rounded-md mb-2">
|
||||||
<TabsTrigger value="calculation-settings">
|
{group.label}
|
||||||
Calculation Settings
|
</h3>
|
||||||
</TabsTrigger>
|
<div className="space-y-1 pl-1">
|
||||||
<TabsTrigger value="templates">
|
{group.tabs.map((tab) => (
|
||||||
Template Management
|
<Protected key={tab.id} permission={tab.permission}>
|
||||||
</TabsTrigger>
|
<TabsTrigger
|
||||||
</TabsList>
|
value={tab.id}
|
||||||
|
className="w-full justify-start px-3 py-2 text-sm font-normal text-muted-foreground data-[state=active]:font-medium data-[state=active]:text-accent-foreground data-[state=active]:shadow-none rounded-md data-[state=active]:underline"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
</Protected>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Only add separator if not the last group */}
|
||||||
|
{group.id !== SETTINGS_GROUPS[SETTINGS_GROUPS.length - 1].id && (
|
||||||
|
<Separator className="mt-4 mb-4 opacity-70" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TabsContent value="data-management">
|
<div className="pl-8 w-full">
|
||||||
<DataManagement />
|
<TabsContent value="data-management" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
</TabsContent>
|
<Protected
|
||||||
|
permission="settings:data_management"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access Data Management.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DataManagement />
|
||||||
|
</Protected>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="stock-management">
|
<TabsContent value="stock-management" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
<StockManagement />
|
<Protected
|
||||||
</TabsContent>
|
permission="settings:stock_management"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access Stock Management.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StockManagement />
|
||||||
|
</Protected>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="performance-metrics">
|
<TabsContent value="performance-metrics" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
<PerformanceMetrics />
|
<Protected
|
||||||
</TabsContent>
|
permission="settings:performance_metrics"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access Performance Metrics.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PerformanceMetrics />
|
||||||
|
</Protected>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="calculation-settings">
|
<TabsContent value="calculation-settings" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
<CalculationSettings />
|
<Protected
|
||||||
</TabsContent>
|
permission="settings:calculation_settings"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access Calculation Settings.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CalculationSettings />
|
||||||
|
</Protected>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="templates">
|
<TabsContent value="templates" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
<TemplateManagement />
|
<Protected
|
||||||
</TabsContent>
|
permission="settings:templates"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access Template Management.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TemplateManagement />
|
||||||
|
</Protected>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="user-management" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
|
<Protected
|
||||||
|
permission="settings:user_management"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access User Management.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UserManagement />
|
||||||
|
</Protected>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user