Compare commits
24 Commits
add-produc
...
00a02aa788
| Author | SHA1 | Date | |
|---|---|---|---|
| 00a02aa788 | |||
| 114018080a | |||
| 228ae8b2a9 | |||
| dd4b3f7145 | |||
| 7eb4077224 | |||
| d60a8cbc6e | |||
| 1fcbf54989 | |||
| ce75496770 | |||
| 7eae4a0b29 | |||
| f421154c1d | |||
| 03dc119a15 | |||
| 1963bee00c | |||
| 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' });
|
||||||
|
|||||||
@@ -23,6 +23,32 @@ CREATE TABLE IF NOT EXISTS templates (
|
|||||||
UNIQUE(company, product_type)
|
UNIQUE(company, product_type)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- AI Prompts table for storing validation prompts
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_prompts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
prompt_text TEXT NOT NULL,
|
||||||
|
prompt_type TEXT NOT NULL CHECK (prompt_type IN ('general', 'company_specific', 'system')),
|
||||||
|
company TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_company_prompt UNIQUE (company),
|
||||||
|
CONSTRAINT company_required_for_specific CHECK (
|
||||||
|
(prompt_type = 'general' AND company IS NULL) OR
|
||||||
|
(prompt_type = 'system' AND company IS NULL) OR
|
||||||
|
(prompt_type = 'company_specific' AND company IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create a unique partial index to ensure only one general prompt
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_general_prompt
|
||||||
|
ON ai_prompts (prompt_type)
|
||||||
|
WHERE prompt_type = 'general';
|
||||||
|
|
||||||
|
-- Create a unique partial index to ensure only one system prompt
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_system_prompt
|
||||||
|
ON ai_prompts (prompt_type)
|
||||||
|
WHERE prompt_type = 'system';
|
||||||
|
|
||||||
-- AI Validation Performance Tracking
|
-- AI Validation Performance Tracking
|
||||||
CREATE TABLE IF NOT EXISTS ai_validation_performance (
|
CREATE TABLE IF NOT EXISTS ai_validation_performance (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -51,3 +77,9 @@ CREATE TRIGGER update_templates_updated_at
|
|||||||
BEFORE UPDATE ON templates
|
BEFORE UPDATE ON templates
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION update_updated_at_column();
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Trigger to automatically update the updated_at column for ai_prompts
|
||||||
|
CREATE TRIGGER update_ai_prompts_updated_at
|
||||||
|
BEFORE UPDATE ON ai_prompts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
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,226 +0,0 @@
|
|||||||
I will provide a JSON array with product data. Process the array by combining all products from validData and invalidData arrays into a single array, excluding any fields starting with “__”, such as “__index” or “__errors”. Process each product according to the reference guidelines below. If a field is not included in the data, do not include it in your response (e.g. do not include its key or any value) unless the specific field guidelines below say otherwise. If a product appears to be from an empty or entirely invalid line, do not include it in your response.
|
|
||||||
|
|
||||||
Your response should be a JSON object with the following structure:
|
|
||||||
{
|
|
||||||
"correctedData": [], // Array of corrected products
|
|
||||||
"changes": [], // Array of strings describing each change made
|
|
||||||
"warnings": [] // Array of strings with warnings or suggestions for manual review (see below for details)
|
|
||||||
}
|
|
||||||
|
|
||||||
IMPORTANT: For all fields that use IDs (categories, supplier, company, line, subline, ship_restrictions, tax_cat, artist, themes, etc.), you MUST return the ID values, not the display names. The system will handle converting IDs to display names.
|
|
||||||
|
|
||||||
Using the provided guidelines, focus on:
|
|
||||||
1. Correcting typos and any incorrect spelling or grammar
|
|
||||||
2. Standardizing product names
|
|
||||||
3. Correcting and enhancing descriptions by adding details, keywords, and SEO-friendly language
|
|
||||||
4. Fixing any obvious errors or inconsistencies between similar products in measurements, prices, or quantities
|
|
||||||
5. Adding correct categories, themes, and colors
|
|
||||||
|
|
||||||
Use only the provided data and your own knowledge to make changes. Do not make assumptions or make up information that you're not sure about. If you're unable to make a change you're confident about, leave the field as is. All data passed in should be validated, corrected, and returned. All values returned should be strings, not numbers. Do not leave out any fields that were present in the original data.
|
|
||||||
|
|
||||||
Possible reasons for including a warning in the warnings array:
|
|
||||||
- If you're unable to make a change you're confident about but you believe one needs to be made
|
|
||||||
- If there are inconsistencies in the data that could be valid but need to be reviewed
|
|
||||||
- If not enough information is provided to make a change that you believe is needed
|
|
||||||
- If you infer a value for a required field based on context
|
|
||||||
|
|
||||||
|
|
||||||
----------PRODUCT FIELD GUIDELINES----------
|
|
||||||
|
|
||||||
Fields: supplier, private_notes, company, line, subline, artist
|
|
||||||
Changes: Not allowed
|
|
||||||
Required: Return if present in the original data. Do not return if not present.
|
|
||||||
Instructions: If present, return these fields exactly as provided with no changes
|
|
||||||
|
|
||||||
Fields: upc, supplier_no, notions_no, item_number
|
|
||||||
Changes: Formatting only
|
|
||||||
Required: Return if present in the original data. Do not return if not present.
|
|
||||||
Instructions: If present, trim outside white space and return these fields exactly as provided with no other changes
|
|
||||||
|
|
||||||
Fields: hts_code
|
|
||||||
Changes: Minimal, you can correct formatting, obvious errors or inconsistencies
|
|
||||||
Required: Return if present in the original data. Do not return if not present.
|
|
||||||
Instructions: If present, trim white space and any non-numeric characters, then return as a string. Do not validate in any other way.
|
|
||||||
|
|
||||||
Fields: image_url
|
|
||||||
Changes: Formatting only
|
|
||||||
Required: Return if present in the original data. Do not return if not present.
|
|
||||||
Instructions: If present, convert all comma-separated values to valid https:// URLs and return
|
|
||||||
|
|
||||||
Fields: msrp, cost_each
|
|
||||||
Changes: Minimal, you can correct formatting, obvious errors or inconsistencies
|
|
||||||
Required: Return if present in the original data. Do not return if not present.
|
|
||||||
Instructions: If present, strip any currency symbols and return as a string with exactly two decimal places, even if the last place is a 0.
|
|
||||||
|
|
||||||
Fields: qty_per_unit, case_qty
|
|
||||||
Changes: Minimal, you can correct formatting, obvious errors or inconsistencies
|
|
||||||
Required: Return if present in the original data. Do not return if not present.
|
|
||||||
Instructions: If present, strip non-numeric characters and return
|
|
||||||
|
|
||||||
Fields: ship_restrictions
|
|
||||||
Changes: Only add a value if it's not already present
|
|
||||||
Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return 0.
|
|
||||||
Instructions: Always return a value exactly as provided, or return 0 if no value is provided.
|
|
||||||
|
|
||||||
Fields: eta
|
|
||||||
Changes: Minimal, you can correct formatting, obvious errors or inconsistencies
|
|
||||||
Required: Return if present in the original data. Do not return if not present.
|
|
||||||
Instructions: If present, return a full month name, day is optional, no year ever (e.g. “January” or “March 3”). This value is not required if not provided.
|
|
||||||
|
|
||||||
Fields: name
|
|
||||||
Changes: Allowed to conform to guidelines, to fix typos or formatting
|
|
||||||
Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return the most reasonable value possible based on the naming guidelines and the other information you have.
|
|
||||||
Instructions: Always return a value that is corrected and enhanced per additional guidelines below
|
|
||||||
|
|
||||||
Fields: description
|
|
||||||
Changes: Full creative control allowed within guidelines
|
|
||||||
Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return the most accurate description possible based on the description guidelines and the other information you have.
|
|
||||||
Instructions: Always return a value that is corrected and enhanced per additional guidelines below
|
|
||||||
|
|
||||||
Fields: weight, length, width, height
|
|
||||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
|
||||||
Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return your best guess based on the other information you have or the dimensions for similar products.
|
|
||||||
Instructions: Always return a reasonable value (weights in ounces and dimensions in inches) that is validated against similar provided products and your knowledge of general object measurements (e.g. a sheet of paper is not going to be 3 inches thick, a pack of stickers is not going to be 250 ounces, this sheet of paper is very likely going to be the same size as that other sheet of paper from the same line). If a value is unusual or unreasonable, even wildly so, change it to match similar products or to be more reasonable. When correcting unreasonable weights or dimensions, prioritize comparisons to products from the same company and product line first, then broader category matches or common knowledge if necessary.Do not return 0 or null for any of these fields.
|
|
||||||
|
|
||||||
Fields: coo
|
|
||||||
Changes: Formatting only
|
|
||||||
Required: Return if present in the original data. Do not return if not present.
|
|
||||||
Instructions: If present, convert all country names and abbreviations to the official ISO 3166-1 alpha-2 two-character country code. Convert any value with more than two characters to two characters only (e.g. "United States" or "USA" should both return "US").
|
|
||||||
|
|
||||||
Fields: tax_cat
|
|
||||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
|
||||||
Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return 0.
|
|
||||||
Instructions: Always return a valid numerical tax code ID from the Available Tax Codes array below. Give preference to the value provided, but correct it if another value is more accurate. You must return a value for this field. 0 should be the default value in most cases.
|
|
||||||
|
|
||||||
Fields: size_cat
|
|
||||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
|
||||||
Required: Return if present in the original data or if not present and applicable. Do not return if not applicable (e.g. if no size categories apply based on what you know about the product).
|
|
||||||
Instructions: If present or if applicable, return one valid numerical size category ID from the Available Size Categories array below. Give preference to the value provided, but correct it if another value is more accurate. If the product name contains a match for one of the size categories (such as 12x12, 6x6, 2oz, etc) you MUST return that size category with the results. A value is not required if none of the size categories apply.
|
|
||||||
|
|
||||||
Fields: themes
|
|
||||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
|
||||||
Required: Return if present in the original data or if not present and applicable. Do not return any value if not applicable (e.g. if no themes apply based on what you know about the product).
|
|
||||||
Instructions: If present, confirm that each provided theme matches what you understand to be a theme of the product. Remove any themes that do not match and add any themes that are missing. Most products will have zero or one theme. Return a comma-separated list of numerical theme IDs from the Available Themes array below. If you choose a sub-theme, you do not need to include its parent theme in the list.
|
|
||||||
|
|
||||||
Fields: colors
|
|
||||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
|
||||||
Required: Return if present in the original data or if not present and applicable. Do not return any value if not applicable (e.g. if no colors apply based on what you know about the product).
|
|
||||||
Instructions: If present or if applicable, return a comma-separated list of numerical color IDs from the Available Colors array below, using the product name as the primary guide (e.g. if the name contains Blue or a blue variant, you should return the blue color ID). A value is not required if none of the colors apply. Most products will have zero colors.
|
|
||||||
|
|
||||||
Fields: categories
|
|
||||||
Changes: Allowed to correct obvious errors or inconsistencies or to add missing values
|
|
||||||
Required: You must always return at least one value for this field, even if it's not provided in the original data. If no value is provided, return the most appropriate category or categories based on the other information you have.
|
|
||||||
Instructions: Always return a comma-separated list of one or more valid numerical category IDs from the Available Categories array below. Give preference to the values provided, particularly if the other information isn't enough to determine a category, but correct them or add new categories if another value is more accurate. Do not return categories in the Deals or Black Friday categories, and strip these from the list if present. If you choose a subcategory at any level, you do not need to include its parent categories in the list. You must return at least one category and you can return multiple categories if applicable. All categories have equal value so their order is not important. Always try to return the most specific categories possible (e.g. one in the third level of the category hierarchy is better than one in the second level).
|
|
||||||
|
|
||||||
----------PRODUCT NAMING GUIDELINES----------
|
|
||||||
If there's only one of this type of product in a line: [Line Name] [Product Name] - [Company]
|
|
||||||
Example: "Cosmos Infinity Chipboard - Stamperia"
|
|
||||||
Example: "Serene Petals 6x6 Paper Pad - Prima"
|
|
||||||
|
|
||||||
Multiple similar products in a line: [Differentiator] [Product Type] - [Line Name] - [Company]
|
|
||||||
Example: "Ice & Shells Stencil - Arctic Antarctic - Stamperia"
|
|
||||||
Example: "Astronomy Paper - Cosmos Infinity - Stamperia"
|
|
||||||
|
|
||||||
Standalone products: [Product Name] - [Company]
|
|
||||||
Example: "Hedwig Puffy Stickers - Paper House Productions"
|
|
||||||
Example: "Heart Tree Dies - Lawn Fawn"
|
|
||||||
|
|
||||||
Color-based products: [Color] [Product Name] - [Company]
|
|
||||||
Example: "Green Valley Enamel Dots - Altenew"
|
|
||||||
Example: "Magenta Aqua Pigment - Brutus Monroe"
|
|
||||||
|
|
||||||
Complex products: [Differentiator] [Line] [Product Type] - [Company]
|
|
||||||
Example: "Size 6 Round Black Velvet Watercolor Brush - Silver Brush Limited" (Size 6 Round is the differentiator, Black Velvet is the line, Watercolor Brush is the product type)
|
|
||||||
|
|
||||||
These should not be included in the name, unless there are multiple products that are otherwise identical:
|
|
||||||
- Product size
|
|
||||||
- Product weight
|
|
||||||
- Number of pages
|
|
||||||
- How many are in the package
|
|
||||||
|
|
||||||
Naming Conventions:
|
|
||||||
- Paper sizes: Use "12x12", "8x8", "6x6" (no spaces or units of measure)
|
|
||||||
- Company names must match backend exactly
|
|
||||||
- Always capitalize every word in the name, including short articles like "The" and "An"
|
|
||||||
- Use "Idea-ology" (not "idea-ology" or "Ideaology")
|
|
||||||
- All stamps are "Stamp Set" (not "Clear Stamps" or "Rubber Stamps")
|
|
||||||
- All dies are "Dies" or "Die" (not "Die Set")
|
|
||||||
- Brands with their own naming conventions should be respected, such as "Doodle Cuts" for dies from Doodlebug
|
|
||||||
|
|
||||||
Special Brand Rules - Ranger:
|
|
||||||
Format: [Product Name] - [Designer Line] - Ranger
|
|
||||||
Possible Designers: Dylusions, Dina Wakley MEdia, Simon Hurley create., Wendy Vecchi
|
|
||||||
Example: "Stacked Stencil - Dina Wakley MEdia - Ranger"
|
|
||||||
|
|
||||||
Special Brand Rules - Tim Holtz products from Ranger:
|
|
||||||
Format: [Color] [Product Name/Type] - Tim Holtz Distress - Ranger
|
|
||||||
Example: "Mermaid Lagoon Tim Holtz Distress Oxide Ink Pad - Ranger"
|
|
||||||
|
|
||||||
Special Brand Rules - Tim Holtz products from Sizzix or Stampers Anonymous:
|
|
||||||
Format: [Product Name] [Product Type] by Tim Holtz - [Company]
|
|
||||||
Example: "Leaf Fragments Thinlits Dies by Tim Holtz - Sizzix"
|
|
||||||
|
|
||||||
Special Brand Rules - Tim Holtz products from Advantus/Idea-ology:
|
|
||||||
Format: [Product Name] - Tim Holtz Idea-ology
|
|
||||||
Example: "Tiny Vials - Tim Holtz Idea-ology"
|
|
||||||
|
|
||||||
Special Brand Rules - Dies from Sizzix:
|
|
||||||
Include die type plus "Dies" or "Die"
|
|
||||||
Examples:
|
|
||||||
"Art Nouveau 3-D Textured Impressions Embossing Folder - Sizzix"
|
|
||||||
"Pocket Pals Thinlits Dies - Sizzix"
|
|
||||||
"Butterfly Wishes Framelits Dies & Stamps - Sizzix"
|
|
||||||
|
|
||||||
Important Notes
|
|
||||||
- Ensure that product names are consistent across all products of the same type
|
|
||||||
- Use the minimum amount of information needed to uniquely identify the product
|
|
||||||
- Put detailed specifications in the product description, not its name
|
|
||||||
|
|
||||||
Edge Cases
|
|
||||||
- If the product is missing a company name, infer one from the other products included in the data
|
|
||||||
- If the product is missing a clear differentiator and needs one to be unique, infer and add one from the other data provided (e.g. the description, existing size categories, etc.)
|
|
||||||
|
|
||||||
Incorrect example: MVP Rugby - Collection Pack - Photoplay
|
|
||||||
Notes: there should be no dash between the line and the product
|
|
||||||
|
|
||||||
Incorrect Example: A2 Easel Cards - Black - Photoplay
|
|
||||||
Notes: the differentiating factor should come first: “Black A2 Easel Cards - Photoplay”. Size is ok to include here because this is the name printed on the package.
|
|
||||||
|
|
||||||
Incorrect Example: 6” - Scriber Needle Modeling Tool
|
|
||||||
Notes: this product only comes in one size, so 6” isn’t needed. The company name should also be included.
|
|
||||||
|
|
||||||
Incorrect Example: Slick - White - Tulip Dimensional Fabric Paint 4oz
|
|
||||||
Notes: color should be first, then type, then product, then company, so “White Slick Dimensional Fabric Paint - Tulip”. It appears there’s only one size available so no need to differentiate in the name.
|
|
||||||
|
|
||||||
Incorrect Example: Silhouette Adhesive Cork Sheets 5”X7” 8/Pkg
|
|
||||||
Notes: should be “Adhesive Cork Sheets - Silhouette”
|
|
||||||
|
|
||||||
Incorrect Example: Galaxy - Opaque - American Crafts Color Pour Resin Dyes
|
|
||||||
Notes: “Galaxy Opaque Dye Set - Color Pour Resin - American Crafts”
|
|
||||||
|
|
||||||
Incorrect Example: Slate - Lion Brand Truboo Yarn
|
|
||||||
Notes: [Differentiator] [Line] [Product Type] - [Company] : “Slate Truboo Yarn - Lion Brand”
|
|
||||||
|
|
||||||
Incorrect Example: Rose Quartz Dylusions Shimmer Paint
|
|
||||||
Notes: “Rose Quartz Shimmer Paint - Dylusions - Ranger”
|
|
||||||
|
|
||||||
|
|
||||||
----------PRODUCT DESCRIPTION GUIDELINES----------
|
|
||||||
Product descriptions are an extremely important part of the listing and are the most important part of your response. Care should be taken to ensure they are correct, helpful, and SEO-friendly.
|
|
||||||
|
|
||||||
If a description is provided in the data, use it as a starting point. Correct any spelling errors, typos, poor grammar, or awkward phrasing. If necessary and you have the information, add more details, describe how the customer could use it, etc. Use complete sentences and keep SEO in mind.
|
|
||||||
|
|
||||||
If no description is provided, make one up using the product name, the information you have, and the other provided guidelines. At minimum, a description should be one complete sentence that starts with a capital letter and ends with a period. Unless the product is extremely complex, 2-4 sentences is usually sufficient if you have enough information.
|
|
||||||
|
|
||||||
Important Notes:
|
|
||||||
- Every description should state exactly what's included in the product (e.g. "Includes one 12x12 sheet of patterned cardstock." or "Includes one 6x12 sheet with 27 unique stickers." or "Includes 55 pieces." or "Package includes machine, power cord, 12 sheets of cardstock, 3 dies, and project instructions.")
|
|
||||||
- Do not use the word "our" in the description (this usually shows up when we copy a description from the manufacturer). Instead use "these" or "[Company name] [product]" or similar. (e.g. don't use "Our journals are hand-made in the USA", instead use "These journals are hand made..." or "Archer & Olive journals are handmade...")
|
|
||||||
- Don't include statements that add no value like “this is perfect for all your paper crafts”. If the product helps to solve a unique problem or has a unique feature, by all means describe it, but if it’s just a normal sheet of paper or pack of stickers, you don’t have to pretend like it’s the best thing ever. At the same time, ensure that you add enough copy to ensure good SEO.
|
|
||||||
- State as many facts as you can about the product, considering the viewpoint of the customer and what they would want to know when looking at it. They probably want to know dimensions, what products it’s compatible with, how thick the paper is, how many sheets are included, whether the sheets are double-sided or not, which items are in the kit, etc. Say as much as you possibly can with the information that you have.
|
|
||||||
- !!DO NOT make up information if you aren't sure about it. A minimal correct description is better than a long incorrect one!!
|
|
||||||
|
|
||||||
Avoid/remove:
|
|
||||||
- The word "Imported"
|
|
||||||
- Any warnings about Prop 65, choking hazards, etc
|
|
||||||
- The manufacturer's name if it's included as the very first thing in the description
|
|
||||||
- Any statement similar to "comes in a variety of colors, each sold separately"
|
|
||||||
335
inventory-server/src/routes/ai-prompts.js
Normal file
335
inventory-server/src/routes/ai-prompts.js
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get all AI prompts
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
ORDER BY prompt_type ASC, company ASC
|
||||||
|
`);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching AI prompts:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch AI prompts',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get prompt by ID
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'AI prompt not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching AI prompt:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch AI prompt',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get prompt by company
|
||||||
|
router.get('/company/:companyId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { companyId } = req.params;
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE company = $1
|
||||||
|
`, [companyId]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'AI prompt not found for this company' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching AI prompt by company:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch AI prompt by company',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get general prompt
|
||||||
|
router.get('/type/general', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'general'
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'General AI prompt not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching general AI prompt:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch general AI prompt',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get system prompt
|
||||||
|
router.get('/type/system', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'system'
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'System AI prompt not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching system AI prompt:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch system AI prompt',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new AI prompt
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
prompt_text,
|
||||||
|
prompt_type,
|
||||||
|
company
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!prompt_text || !prompt_type) {
|
||||||
|
return res.status(400).json({ error: 'Prompt text and type are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate prompt type
|
||||||
|
if (!['general', 'company_specific', 'system'].includes(prompt_type)) {
|
||||||
|
return res.status(400).json({ error: 'Prompt type must be either "general", "company_specific", or "system"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate company is provided for company-specific prompts
|
||||||
|
if (prompt_type === 'company_specific' && !company) {
|
||||||
|
return res.status(400).json({ error: 'Company is required for company-specific prompts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate company is not provided for general or system prompts
|
||||||
|
if ((prompt_type === 'general' || prompt_type === 'system') && company) {
|
||||||
|
return res.status(400).json({ error: 'Company should not be provided for general or system prompts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
INSERT INTO ai_prompts (
|
||||||
|
prompt_text,
|
||||||
|
prompt_type,
|
||||||
|
company
|
||||||
|
) VALUES ($1, $2, $3)
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
prompt_text,
|
||||||
|
prompt_type,
|
||||||
|
company
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating AI prompt:', error);
|
||||||
|
|
||||||
|
// Check for unique constraint violations
|
||||||
|
if (error instanceof Error && error.message.includes('unique constraint')) {
|
||||||
|
if (error.message.includes('unique_company_prompt')) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'A prompt already exists for this company',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
} else if (error.message.includes('idx_unique_general_prompt')) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'A general prompt already exists',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
} else if (error.message.includes('idx_unique_system_prompt')) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'A system prompt already exists',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to create AI prompt',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update AI prompt
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const {
|
||||||
|
prompt_text,
|
||||||
|
prompt_type,
|
||||||
|
company
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!prompt_text || !prompt_type) {
|
||||||
|
return res.status(400).json({ error: 'Prompt text and type are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate prompt type
|
||||||
|
if (!['general', 'company_specific', 'system'].includes(prompt_type)) {
|
||||||
|
return res.status(400).json({ error: 'Prompt type must be either "general", "company_specific", or "system"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate company is provided for company-specific prompts
|
||||||
|
if (prompt_type === 'company_specific' && !company) {
|
||||||
|
return res.status(400).json({ error: 'Company is required for company-specific prompts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate company is not provided for general or system prompts
|
||||||
|
if ((prompt_type === 'general' || prompt_type === 'system') && company) {
|
||||||
|
return res.status(400).json({ error: 'Company should not be provided for general or system prompts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the prompt exists
|
||||||
|
const checkResult = await pool.query('SELECT * FROM ai_prompts WHERE id = $1', [id]);
|
||||||
|
if (checkResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'AI prompt not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
UPDATE ai_prompts
|
||||||
|
SET
|
||||||
|
prompt_text = $1,
|
||||||
|
prompt_type = $2,
|
||||||
|
company = $3
|
||||||
|
WHERE id = $4
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
prompt_text,
|
||||||
|
prompt_type,
|
||||||
|
company,
|
||||||
|
id
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating AI prompt:', error);
|
||||||
|
|
||||||
|
// Check for unique constraint violations
|
||||||
|
if (error instanceof Error && error.message.includes('unique constraint')) {
|
||||||
|
if (error.message.includes('unique_company_prompt')) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'A prompt already exists for this company',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
} else if (error.message.includes('idx_unique_general_prompt')) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'A general prompt already exists',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
} else if (error.message.includes('idx_unique_system_prompt')) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'A system prompt already exists',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to update AI prompt',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete AI prompt
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query('DELETE FROM ai_prompts WHERE id = $1 RETURNING *', [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'AI prompt not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'AI prompt deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting AI prompt:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to delete AI prompt',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
router.use((err, req, res, next) => {
|
||||||
|
console.error('AI prompts route error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: err.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -289,8 +289,108 @@ async function generateDebugResponse(productsToUse, res) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prompt = await loadPrompt(promptConnection, productsToUse);
|
// Get the local PostgreSQL pool to fetch prompts
|
||||||
const fullPrompt = prompt + "\n" + JSON.stringify(productsToUse);
|
const pool = res.app.locals.pool;
|
||||||
|
if (!pool) {
|
||||||
|
console.warn("⚠️ Local database pool not available for prompts");
|
||||||
|
throw new Error("Database connection not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, fetch the system prompt
|
||||||
|
const systemPromptResult = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'system'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get system prompt or use default
|
||||||
|
let systemPrompt = null;
|
||||||
|
if (systemPromptResult.rows.length > 0) {
|
||||||
|
systemPrompt = systemPromptResult.rows[0];
|
||||||
|
console.log("📝 Loaded system prompt from database, ID:", systemPrompt.id);
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ No system prompt found in database, will use default");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then, fetch the general prompt
|
||||||
|
const generalPromptResult = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'general'
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (generalPromptResult.rows.length === 0) {
|
||||||
|
console.warn("⚠️ No general prompt found in database");
|
||||||
|
throw new Error("No general prompt found in database");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the general prompt text and info
|
||||||
|
const generalPrompt = generalPromptResult.rows[0];
|
||||||
|
console.log("📝 Loaded general prompt from database, ID:", generalPrompt.id);
|
||||||
|
|
||||||
|
// Fetch company-specific prompts if we have products to validate
|
||||||
|
let companyPrompts = [];
|
||||||
|
if (productsToUse && Array.isArray(productsToUse)) {
|
||||||
|
// Extract unique company IDs from products
|
||||||
|
const companyIds = new Set();
|
||||||
|
productsToUse.forEach(product => {
|
||||||
|
if (product.company) {
|
||||||
|
companyIds.add(String(product.company));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (companyIds.size > 0) {
|
||||||
|
console.log(`🔍 Found ${companyIds.size} unique companies in products:`, Array.from(companyIds));
|
||||||
|
|
||||||
|
// Fetch company-specific prompts
|
||||||
|
const companyPromptsResult = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'company_specific'
|
||||||
|
AND company = ANY($1)
|
||||||
|
`, [Array.from(companyIds)]);
|
||||||
|
|
||||||
|
companyPrompts = companyPromptsResult.rows;
|
||||||
|
console.log(`📝 Loaded ${companyPrompts.length} company-specific prompts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find company names from taxonomy for the validation endpoint
|
||||||
|
const companyPromptsWithNames = companyPrompts.map(prompt => {
|
||||||
|
let companyName = "Unknown Company";
|
||||||
|
if (taxonomy.companies && Array.isArray(taxonomy.companies)) {
|
||||||
|
const companyData = taxonomy.companies.find(company =>
|
||||||
|
String(company[0]) === String(prompt.company)
|
||||||
|
);
|
||||||
|
if (companyData && companyData[1]) {
|
||||||
|
companyName = companyData[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: prompt.id,
|
||||||
|
company: prompt.company,
|
||||||
|
companyName: companyName,
|
||||||
|
prompt_text: prompt.prompt_text
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now use loadPrompt to get the actual combined prompt
|
||||||
|
const promptData = await loadPrompt(promptConnection, productsToUse, res.app.locals.pool);
|
||||||
|
const fullUserPrompt = promptData.userContent + "\n" + JSON.stringify(productsToUse);
|
||||||
|
const promptLength = promptData.systemInstructions.length + fullUserPrompt.length; // Store prompt length for performance metrics
|
||||||
|
console.log("📝 Generated prompt length:", promptLength);
|
||||||
|
console.log("📝 System instructions length:", promptData.systemInstructions.length);
|
||||||
|
console.log("📝 User content length:", fullUserPrompt.length);
|
||||||
|
|
||||||
|
// Format the messages as they would be sent to the API
|
||||||
|
const apiMessages = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: promptData.systemInstructions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: fullUserPrompt
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
// Create the response with taxonomy stats
|
// Create the response with taxonomy stats
|
||||||
let categoriesCount = 0;
|
let categoriesCount = 0;
|
||||||
@@ -330,9 +430,28 @@ async function generateDebugResponse(productsToUse, res) {
|
|||||||
: null,
|
: null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
basePrompt: prompt,
|
basePrompt: systemPrompt ? systemPrompt.prompt_text + "\n\n" + generalPrompt.prompt_text : generalPrompt.prompt_text,
|
||||||
sampleFullPrompt: fullPrompt,
|
sampleFullPrompt: fullUserPrompt,
|
||||||
promptLength: fullPrompt.length,
|
promptLength: promptLength,
|
||||||
|
apiFormat: apiMessages,
|
||||||
|
promptSources: {
|
||||||
|
...(systemPrompt ? {
|
||||||
|
systemPrompt: {
|
||||||
|
id: systemPrompt.id,
|
||||||
|
prompt_text: systemPrompt.prompt_text
|
||||||
|
}
|
||||||
|
} : {
|
||||||
|
systemPrompt: {
|
||||||
|
id: 0,
|
||||||
|
prompt_text: `You are a specialized e-commerce product data processor for a crafting supplies website tasked with providing complete, correct, appealing, and SEO-friendly product listings. You should write professionally, but in a friendly and engaging tone. You have meticulous attention to detail and are a master at your craft.`
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
generalPrompt: {
|
||||||
|
id: generalPrompt.id,
|
||||||
|
prompt_text: generalPrompt.prompt_text
|
||||||
|
},
|
||||||
|
companyPrompts: companyPromptsWithNames
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Sending response with taxonomy stats:", response.taxonomyStats);
|
console.log("Sending response with taxonomy stats:", response.taxonomyStats);
|
||||||
@@ -513,22 +632,101 @@ SELECT t.cat_id,t.name,null as master_cat_id,1 AS level_order FROM product_categ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the prompt from file and inject taxonomy data
|
// Load prompts from database and inject taxonomy data
|
||||||
async function loadPrompt(connection, productsToValidate = null) {
|
async function loadPrompt(connection, productsToValidate = null, appPool = null) {
|
||||||
try {
|
try {
|
||||||
const promptPath = path.join(
|
|
||||||
__dirname,
|
|
||||||
"..",
|
|
||||||
"prompts",
|
|
||||||
"product-validation.txt"
|
|
||||||
);
|
|
||||||
const basePrompt = await fs.readFile(promptPath, "utf8");
|
|
||||||
|
|
||||||
// Get taxonomy data using the provided MySQL connection
|
// Get taxonomy data using the provided MySQL connection
|
||||||
const taxonomy = await getTaxonomyData(connection);
|
const taxonomy = await getTaxonomyData(connection);
|
||||||
|
|
||||||
// Add system instructions to the prompt
|
// Use the provided pool parameter instead of global.app
|
||||||
const systemInstructions = `You are a specialized e-commerce product data processor for a crafting supplies website tasked with providing complete, correct, appealing, and SEO-friendly product listings. You should write professionally, but in a friendly and engaging tone. You have meticulous attention to detail and are a master at your craft.`;
|
const pool = appPool;
|
||||||
|
if (!pool) {
|
||||||
|
console.warn("⚠️ Local database pool not available for prompts");
|
||||||
|
throw new Error("Database connection not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the system prompt
|
||||||
|
const systemPromptResult = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'system'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Default system instructions in case the system prompt is not found
|
||||||
|
let systemInstructions = `You are a specialized e-commerce product data processor for a crafting supplies website tasked with providing complete, correct, appealing, and SEO-friendly product listings. You should write professionally, but in a friendly and engaging tone. You have meticulous attention to detail and are a master at your craft.`;
|
||||||
|
|
||||||
|
// If system prompt exists in the database, use it
|
||||||
|
if (systemPromptResult.rows.length > 0) {
|
||||||
|
systemInstructions = systemPromptResult.rows[0].prompt_text;
|
||||||
|
console.log("📝 Loaded system prompt from database");
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ No system prompt found in database, using default");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the general prompt
|
||||||
|
const generalPromptResult = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'general'
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (generalPromptResult.rows.length === 0) {
|
||||||
|
console.warn("⚠️ No general prompt found in database");
|
||||||
|
throw new Error("No general prompt found in database");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the general prompt text
|
||||||
|
const basePrompt = generalPromptResult.rows[0].prompt_text;
|
||||||
|
console.log("📝 Loaded general prompt from database");
|
||||||
|
|
||||||
|
// Fetch company-specific prompts if we have products to validate
|
||||||
|
let companyPrompts = [];
|
||||||
|
if (productsToValidate && Array.isArray(productsToValidate)) {
|
||||||
|
// Extract unique company IDs from products
|
||||||
|
const companyIds = new Set();
|
||||||
|
productsToValidate.forEach(product => {
|
||||||
|
if (product.company) {
|
||||||
|
companyIds.add(String(product.company));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (companyIds.size > 0) {
|
||||||
|
console.log(`🔍 Found ${companyIds.size} unique companies in products:`, Array.from(companyIds));
|
||||||
|
|
||||||
|
// Fetch company-specific prompts
|
||||||
|
const companyPromptsResult = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'company_specific'
|
||||||
|
AND company = ANY($1)
|
||||||
|
`, [Array.from(companyIds)]);
|
||||||
|
|
||||||
|
companyPrompts = companyPromptsResult.rows;
|
||||||
|
console.log(`📝 Loaded ${companyPrompts.length} company-specific prompts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine prompts - start with the general prompt
|
||||||
|
let combinedPrompt = basePrompt;
|
||||||
|
|
||||||
|
// Add any company-specific prompts with annotations
|
||||||
|
if (companyPrompts.length > 0) {
|
||||||
|
combinedPrompt += "\n\n--- COMPANY-SPECIFIC INSTRUCTIONS ---\n";
|
||||||
|
|
||||||
|
for (const prompt of companyPrompts) {
|
||||||
|
// Find company name from taxonomy
|
||||||
|
let companyName = "Unknown Company";
|
||||||
|
if (taxonomy.companies && Array.isArray(taxonomy.companies)) {
|
||||||
|
const companyData = taxonomy.companies.find(company =>
|
||||||
|
String(company[0]) === String(prompt.company)
|
||||||
|
);
|
||||||
|
if (companyData && companyData[1]) {
|
||||||
|
companyName = companyData[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedPrompt += `\n[SPECIFIC TO COMPANY: ${companyName} (ID: ${prompt.company})]:\n${prompt.prompt_text}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedPrompt += "\n--- END COMPANY-SPECIFIC INSTRUCTIONS ---\n";
|
||||||
|
}
|
||||||
|
|
||||||
// If we have products to validate, create a filtered prompt
|
// If we have products to validate, create a filtered prompt
|
||||||
if (productsToValidate) {
|
if (productsToValidate) {
|
||||||
@@ -655,11 +853,14 @@ ${JSON.stringify(mixedTaxonomy.sizeCategories)}${
|
|||||||
|
|
||||||
----------Here is the product data to validate----------`;
|
----------Here is the product data to validate----------`;
|
||||||
|
|
||||||
// Return the filtered prompt
|
// Return both system instructions and user content separately
|
||||||
return systemInstructions + basePrompt + "\n" + taxonomySection;
|
return {
|
||||||
|
systemInstructions,
|
||||||
|
userContent: combinedPrompt + "\n" + taxonomySection
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the full unfiltered prompt
|
// Generate the full unfiltered prompt for taxonomy section
|
||||||
const taxonomySection = `
|
const taxonomySection = `
|
||||||
Available Categories:
|
Available Categories:
|
||||||
${JSON.stringify(taxonomy.categories)}
|
${JSON.stringify(taxonomy.categories)}
|
||||||
@@ -687,7 +888,11 @@ ${JSON.stringify(taxonomy.artists)}
|
|||||||
|
|
||||||
Here is the product data to validate:`;
|
Here is the product data to validate:`;
|
||||||
|
|
||||||
return systemInstructions + basePrompt + "\n" + taxonomySection;
|
// Return both system instructions and user content separately
|
||||||
|
return {
|
||||||
|
systemInstructions,
|
||||||
|
userContent: combinedPrompt + "\n" + taxonomySection
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading prompt:", error);
|
console.error("Error loading prompt:", error);
|
||||||
throw error; // Re-throw to be handled by the calling function
|
throw error; // Re-throw to be handled by the calling function
|
||||||
@@ -735,18 +940,24 @@ router.post("/validate", async (req, res) => {
|
|||||||
|
|
||||||
// Load the prompt with the products data to filter taxonomy
|
// Load the prompt with the products data to filter taxonomy
|
||||||
console.log("🔄 Loading prompt with filtered taxonomy...");
|
console.log("🔄 Loading prompt with filtered taxonomy...");
|
||||||
const prompt = await loadPrompt(connection, products);
|
const promptData = await loadPrompt(connection, products, req.app.locals.pool);
|
||||||
const fullPrompt = prompt + "\n" + JSON.stringify(products);
|
const fullUserPrompt = promptData.userContent + "\n" + JSON.stringify(products);
|
||||||
promptLength = fullPrompt.length; // Store prompt length for performance metrics
|
const promptLength = promptData.systemInstructions.length + fullUserPrompt.length; // Store prompt length for performance metrics
|
||||||
console.log("📝 Generated prompt length:", promptLength);
|
console.log("📝 Generated prompt length:", promptLength);
|
||||||
|
console.log("📝 System instructions length:", promptData.systemInstructions.length);
|
||||||
|
console.log("📝 User content length:", fullUserPrompt.length);
|
||||||
|
|
||||||
console.log("🤖 Sending request to OpenAI...");
|
console.log("🤖 Sending request to OpenAI...");
|
||||||
const completion = await openai.chat.completions.create({
|
const completion = await openai.chat.completions.create({
|
||||||
model: "o3-mini",
|
model: "gpt-4o",
|
||||||
messages: [
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: promptData.systemInstructions,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: fullPrompt,
|
content: fullUserPrompt,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
@@ -884,7 +1095,94 @@ router.post("/validate", async (req, res) => {
|
|||||||
console.error("⚠️ Failed to record performance metrics:", metricError);
|
console.error("⚠️ Failed to record performance metrics:", metricError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include performance metrics in the response
|
// Get sources of the prompts for tracking
|
||||||
|
let promptSources = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get system prompt
|
||||||
|
const systemPromptResult = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts WHERE prompt_type = 'system'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get general prompt
|
||||||
|
const generalPromptResult = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts WHERE prompt_type = 'general'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Extract unique company IDs from products
|
||||||
|
const companyIds = new Set();
|
||||||
|
products.forEach(product => {
|
||||||
|
if (product.company) {
|
||||||
|
companyIds.add(String(product.company));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let companyPrompts = [];
|
||||||
|
if (companyIds.size > 0) {
|
||||||
|
// Fetch company-specific prompts
|
||||||
|
const companyPromptsResult = await pool.query(`
|
||||||
|
SELECT * FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'company_specific'
|
||||||
|
AND company = ANY($1)
|
||||||
|
`, [Array.from(companyIds)]);
|
||||||
|
|
||||||
|
companyPrompts = companyPromptsResult.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find company names from taxonomy for the validation endpoint
|
||||||
|
const companyPromptsWithNames = companyPrompts.map(prompt => {
|
||||||
|
let companyName = "Unknown Company";
|
||||||
|
if (taxonomy.companies && Array.isArray(taxonomy.companies)) {
|
||||||
|
const companyData = taxonomy.companies.find(company =>
|
||||||
|
String(company[0]) === String(prompt.company)
|
||||||
|
);
|
||||||
|
if (companyData && companyData[1]) {
|
||||||
|
companyName = companyData[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: prompt.id,
|
||||||
|
company: prompt.company,
|
||||||
|
companyName: companyName,
|
||||||
|
prompt_text: prompt.prompt_text
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set prompt sources
|
||||||
|
if (generalPromptResult.rows.length > 0) {
|
||||||
|
const generalPrompt = generalPromptResult.rows[0];
|
||||||
|
let systemPrompt = null;
|
||||||
|
|
||||||
|
if (systemPromptResult.rows.length > 0) {
|
||||||
|
systemPrompt = systemPromptResult.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
promptSources = {
|
||||||
|
...(systemPrompt ? {
|
||||||
|
systemPrompt: {
|
||||||
|
id: systemPrompt.id,
|
||||||
|
prompt_text: systemPrompt.prompt_text
|
||||||
|
}
|
||||||
|
} : {
|
||||||
|
systemPrompt: {
|
||||||
|
id: 0,
|
||||||
|
prompt_text: `You are a specialized e-commerce product data processor for a crafting supplies website tasked with providing complete, correct, appealing, and SEO-friendly product listings. You should write professionally, but in a friendly and engaging tone. You have meticulous attention to detail and are a master at your craft.`
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
generalPrompt: {
|
||||||
|
id: generalPrompt.id,
|
||||||
|
prompt_text: generalPrompt.prompt_text
|
||||||
|
},
|
||||||
|
companyPrompts: companyPromptsWithNames
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (promptSourceError) {
|
||||||
|
console.error("⚠️ Error getting prompt sources:", promptSourceError);
|
||||||
|
// Don't fail the entire validation if just prompt sources retrieval fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include prompt sources in the response
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
changeDetails: changeDetails,
|
changeDetails: changeDetails,
|
||||||
@@ -895,6 +1193,7 @@ router.post("/validate", async (req, res) => {
|
|||||||
isEstimate: true,
|
isEstimate: true,
|
||||||
productCount: products.length
|
productCount: products.length
|
||||||
},
|
},
|
||||||
|
promptSources: promptSources,
|
||||||
...aiResponse,
|
...aiResponse,
|
||||||
});
|
});
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const categoriesRouter = require('./routes/categories');
|
|||||||
const importRouter = require('./routes/import');
|
const importRouter = require('./routes/import');
|
||||||
const aiValidationRouter = require('./routes/ai-validation');
|
const aiValidationRouter = require('./routes/ai-validation');
|
||||||
const templatesRouter = require('./routes/templates');
|
const templatesRouter = require('./routes/templates');
|
||||||
|
const aiPromptsRouter = require('./routes/ai-prompts');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
const envPath = '/var/www/html/inventory/.env';
|
const envPath = '/var/www/html/inventory/.env';
|
||||||
@@ -103,6 +104,7 @@ async function startServer() {
|
|||||||
app.use('/api/import', importRouter);
|
app.use('/api/import', importRouter);
|
||||||
app.use('/api/ai-validation', aiValidationRouter);
|
app.use('/api/ai-validation', aiValidationRouter);
|
||||||
app.use('/api/templates', templatesRouter);
|
app.use('/api/templates', templatesRouter);
|
||||||
|
app.use('/api/ai-prompts', aiPromptsRouter);
|
||||||
|
|
||||||
// Basic health check route
|
// Basic health check route
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
|||||||
@@ -1,47 +1,10 @@
|
|||||||
const { Pool, Client } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const { Client: SSHClient } = require('ssh2');
|
|
||||||
|
|
||||||
let pool;
|
let pool;
|
||||||
|
|
||||||
function initPool(config) {
|
function initPool(config) {
|
||||||
// Log config without sensitive data
|
pool = new Pool(config);
|
||||||
const safeConfig = {
|
|
||||||
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;
|
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",
|
||||||
|
|||||||
@@ -15,6 +15,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",
|
||||||
@@ -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,42 +15,61 @@ 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}>
|
||||||
|
<AuthProvider>
|
||||||
<Toaster richColors position="top-center" />
|
<Toaster richColors position="top-center" />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
@@ -60,20 +78,60 @@ function App() {
|
|||||||
<MainLayout />
|
<MainLayout />
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}>
|
}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route index element={
|
||||||
<Route path="/products" element={<Products />} />
|
<Protected page="dashboard" fallback={<FirstAccessiblePage />}>
|
||||||
<Route path="/import" element={<Import />} />
|
<Dashboard />
|
||||||
<Route path="/categories" element={<Categories />} />
|
</Protected>
|
||||||
<Route path="/vendors" element={<Vendors />} />
|
} />
|
||||||
<Route path="/orders" element={<Orders />} />
|
<Route path="/" element={
|
||||||
<Route path="/purchase-orders" element={<PurchaseOrders />} />
|
<Protected page="dashboard">
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Dashboard />
|
||||||
<Route path="/settings" element={<Settings />} />
|
</Protected>
|
||||||
<Route path="/forecasting" element={<Forecasting />} />
|
} />
|
||||||
<Route path="/ai-validation/debug" element={<AiValidationDebug />} />
|
<Route path="/products" element={
|
||||||
|
<Protected page="products">
|
||||||
|
<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 path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</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,7 +107,12 @@ 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
|
||||||
|
key={item.title}
|
||||||
|
permission={item.permission}
|
||||||
|
fallback={null}
|
||||||
|
>
|
||||||
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
tooltip={item.title}
|
tooltip={item.title}
|
||||||
@@ -112,6 +126,7 @@ export function AppSidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
</Protected>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -122,6 +137,10 @@ export function AppSidebar() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
|
<Protected
|
||||||
|
permission="access:settings"
|
||||||
|
fallback={null}
|
||||||
|
>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
@@ -136,10 +155,12 @@ export function AppSidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</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 (
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { AiValidationDialogs } from './components/AiValidationDialogs';
|
||||||
|
import { Product } from '../../../../types/products';
|
||||||
|
import {
|
||||||
|
AiValidationProgress,
|
||||||
|
AiValidationDetails,
|
||||||
|
CurrentPrompt as AiValidationCurrentPrompt
|
||||||
|
} from './hooks/useAiValidation';
|
||||||
|
|
||||||
|
const ValidationStepNew: React.FC = () => {
|
||||||
|
const [aiValidationProgress, setAiValidationProgress] = useState<AiValidationProgress>({
|
||||||
|
isOpen: false,
|
||||||
|
status: 'idle',
|
||||||
|
step: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const [aiValidationDetails, setAiValidationDetails] = useState<AiValidationDetails>({
|
||||||
|
changes: [],
|
||||||
|
warnings: [],
|
||||||
|
changeDetails: [],
|
||||||
|
isOpen: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentPrompt, setCurrentPrompt] = useState<AiValidationCurrentPrompt>({
|
||||||
|
isOpen: false,
|
||||||
|
prompt: '',
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track reversion state (for internal use)
|
||||||
|
const [reversionState, setReversionState] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const [fieldData] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
|
||||||
|
const revertAiChange = (productIndex: number, fieldKey: string) => {
|
||||||
|
const key = `${productIndex}-${fieldKey}`;
|
||||||
|
setReversionState(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: true
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isChangeReverted = (productIndex: number, fieldKey: string): boolean => {
|
||||||
|
const key = `${productIndex}-${fieldKey}`;
|
||||||
|
return !!reversionState[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldDisplayValueWithHighlight = (
|
||||||
|
_fieldKey: string,
|
||||||
|
originalValue: any,
|
||||||
|
correctedValue: any
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
originalHtml: String(originalValue),
|
||||||
|
correctedHtml: String(correctedValue)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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,87 @@
|
|||||||
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, XIcon } 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";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
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;
|
||||||
|
apiFormat?: Array<{
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}>;
|
||||||
|
promptSources?: {
|
||||||
|
systemPrompt?: { id: number; prompt_text: string };
|
||||||
|
generalPrompt?: { id: number; prompt_text: string };
|
||||||
|
companyPrompts?: Array<{
|
||||||
|
id: number;
|
||||||
|
company: string;
|
||||||
|
companyName?: string;
|
||||||
|
prompt_text: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
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 +94,548 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
revertAiChange,
|
revertAiChange,
|
||||||
isChangeReverted,
|
isChangeReverted,
|
||||||
getFieldDisplayValueWithHighlight,
|
getFieldDisplayValueWithHighlight,
|
||||||
fields
|
fields,
|
||||||
|
debugData,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [costPerMillionTokens, setCostPerMillionTokens] = useState(2.5); // Default cost
|
||||||
|
const hasCompanyPrompts =
|
||||||
|
currentPrompt.debugData?.promptSources?.companyPrompts &&
|
||||||
|
currentPrompt.debugData.promptSources.companyPrompts.length > 0;
|
||||||
|
|
||||||
|
// Create our own state to track changes
|
||||||
|
const [localReversionState, setLocalReversionState] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
// Initialize local state from the isChangeReverted function when component mounts
|
||||||
|
// or when aiValidationDetails changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (
|
||||||
|
aiValidationDetails.changeDetails &&
|
||||||
|
aiValidationDetails.changeDetails.length > 0
|
||||||
|
) {
|
||||||
|
const initialState: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
aiValidationDetails.changeDetails.forEach((product) => {
|
||||||
|
product.changes.forEach((change) => {
|
||||||
|
const key = `${product.productIndex}-${change.field}`;
|
||||||
|
initialState[key] = isChangeReverted(
|
||||||
|
product.productIndex,
|
||||||
|
change.field
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalReversionState(initialState);
|
||||||
|
}
|
||||||
|
}, [aiValidationDetails.changeDetails, isChangeReverted]);
|
||||||
|
|
||||||
|
// This function will toggle the local state for a given change
|
||||||
|
const toggleChangeAcceptance = (productIndex: number, fieldKey: string) => {
|
||||||
|
const key = `${productIndex}-${fieldKey}`;
|
||||||
|
const currentlyRejected = !!localReversionState[key];
|
||||||
|
|
||||||
|
// Toggle the local state
|
||||||
|
setLocalReversionState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: !prev[key],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Only call revertAiChange when toggling to rejected state
|
||||||
|
// Since revertAiChange is specifically for rejecting changes
|
||||||
|
if (!currentlyRejected) {
|
||||||
|
revertAiChange(productIndex, fieldKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to check local reversion state
|
||||||
|
const isChangeLocallyReverted = (
|
||||||
|
productIndex: number,
|
||||||
|
fieldKey: string
|
||||||
|
): boolean => {
|
||||||
|
const key = `${productIndex}-${fieldKey}`;
|
||||||
|
return !!localReversionState[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use "full" as the default tab
|
||||||
|
const defaultTab = "full";
|
||||||
|
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||||
|
|
||||||
|
// Update activeTab when the dialog is opened with new data
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (currentPrompt.isOpen) {
|
||||||
|
setActiveTab("full");
|
||||||
|
}
|
||||||
|
}, [currentPrompt.isOpen]);
|
||||||
|
|
||||||
|
// 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">
|
|
||||||
|
<div className="flex flex-col h-[calc(90vh-120px)] overflow-hidden">
|
||||||
|
{/* Debug Information Section - Fixed at the top */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{currentPrompt.isLoading ? (
|
||||||
|
<div className="flex justify-center items-center h-[100px]"></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<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">
|
||||||
|
{currentPrompt.debugData?.estimatedProcessingTime ? (
|
||||||
|
currentPrompt.debugData.estimatedProcessingTime
|
||||||
|
.seconds ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Estimated time:
|
||||||
|
</span>{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{formatTime(
|
||||||
|
currentPrompt.debugData
|
||||||
|
.estimatedProcessingTime.seconds
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Based on{" "}
|
||||||
|
{
|
||||||
|
currentPrompt.debugData
|
||||||
|
.estimatedProcessingTime.sampleCount
|
||||||
|
}{" "}
|
||||||
|
similar validation
|
||||||
|
{currentPrompt.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 - Scrollable content */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
{currentPrompt.isLoading ? (
|
{currentPrompt.isLoading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<Loader2 className="h-8 w-8 animate-spin" />
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Code className="whitespace-pre-wrap p-4">{currentPrompt.prompt}</Code>
|
<>
|
||||||
|
{currentPrompt.debugData?.apiFormat ? (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Prompt Sources Card - Fixed at the top of the content area */}
|
||||||
|
<Card className="py-2 mb-4 flex-shrink-0">
|
||||||
|
<CardHeader className="py-2">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Prompt Sources
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="py-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-purple-100 hover:bg-purple-200 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
document
|
||||||
|
.getElementById("system-message")
|
||||||
|
?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
System
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-green-100 hover:bg-green-200 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
document
|
||||||
|
.getElementById("general-section")
|
||||||
|
?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
General
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{currentPrompt.debugData.promptSources?.companyPrompts?.map(
|
||||||
|
(company, idx) => (
|
||||||
|
<Badge
|
||||||
|
key={idx}
|
||||||
|
variant="outline"
|
||||||
|
className="bg-blue-100 hover:bg-blue-200 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
document
|
||||||
|
.getElementById("company-section")
|
||||||
|
?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{company.companyName ||
|
||||||
|
`Company ${company.company}`}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-amber-100 hover:bg-amber-200 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
document
|
||||||
|
.getElementById("taxonomy-section")
|
||||||
|
?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Taxonomy
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-pink-100 hover:bg-pink-200 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
document
|
||||||
|
.getElementById("product-section")
|
||||||
|
?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Products
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 w-full overflow-y-auto">
|
||||||
|
{currentPrompt.debugData.apiFormat.map(
|
||||||
|
(message, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="border rounded-md p-2 mb-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id={
|
||||||
|
message.role === "system"
|
||||||
|
? "system-message"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
className={`p-2 mb-2 rounded-sm font-medium ${
|
||||||
|
message.role === "system"
|
||||||
|
? "bg-purple-50 text-purple-800"
|
||||||
|
: "bg-green-50 text-green-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Role: {message.role}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Code
|
||||||
|
className={`whitespace-pre-wrap p-4 break-normal max-w-full ${
|
||||||
|
message.role === "system"
|
||||||
|
? "bg-purple-50/30"
|
||||||
|
: "bg-green-50/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.role === "user" ? (
|
||||||
|
<div className="text-wrapper">
|
||||||
|
{(() => {
|
||||||
|
const content = message.content;
|
||||||
|
|
||||||
|
// Find section boundaries by looking for specific markers
|
||||||
|
const companySpecificStartIndex =
|
||||||
|
content.indexOf(
|
||||||
|
"--- COMPANY-SPECIFIC INSTRUCTIONS ---"
|
||||||
|
);
|
||||||
|
const companySpecificEndIndex =
|
||||||
|
content.indexOf(
|
||||||
|
"--- END COMPANY-SPECIFIC INSTRUCTIONS ---"
|
||||||
|
);
|
||||||
|
|
||||||
|
const taxonomyStartIndex =
|
||||||
|
content.indexOf(
|
||||||
|
"All Available Categories:"
|
||||||
|
);
|
||||||
|
const taxonomyFallbackStartIndex =
|
||||||
|
content.indexOf(
|
||||||
|
"Available Categories:"
|
||||||
|
);
|
||||||
|
const actualTaxonomyStartIndex =
|
||||||
|
taxonomyStartIndex >= 0
|
||||||
|
? taxonomyStartIndex
|
||||||
|
: taxonomyFallbackStartIndex;
|
||||||
|
|
||||||
|
const productDataStartIndex =
|
||||||
|
content.indexOf(
|
||||||
|
"----------Here is the product data to validate----------"
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we can't find any markers, just return the content as-is
|
||||||
|
if (
|
||||||
|
actualTaxonomyStartIndex < 0 &&
|
||||||
|
productDataStartIndex < 0 &&
|
||||||
|
companySpecificStartIndex < 0
|
||||||
|
) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine section indices
|
||||||
|
let generalEndIndex = content.length;
|
||||||
|
|
||||||
|
if (companySpecificStartIndex >= 0) {
|
||||||
|
generalEndIndex =
|
||||||
|
companySpecificStartIndex;
|
||||||
|
} else if (
|
||||||
|
actualTaxonomyStartIndex >= 0
|
||||||
|
) {
|
||||||
|
generalEndIndex =
|
||||||
|
actualTaxonomyStartIndex;
|
||||||
|
} else if (productDataStartIndex >= 0) {
|
||||||
|
generalEndIndex = productDataStartIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine where taxonomy starts
|
||||||
|
let taxonomyEndIndex = content.length;
|
||||||
|
if (productDataStartIndex >= 0) {
|
||||||
|
taxonomyEndIndex =
|
||||||
|
productDataStartIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segments to render with appropriate styling
|
||||||
|
const segments = [];
|
||||||
|
|
||||||
|
// General section (beginning to company/taxonomy/product)
|
||||||
|
if (generalEndIndex > 0) {
|
||||||
|
segments.push(
|
||||||
|
<div
|
||||||
|
id="general-section"
|
||||||
|
key="general"
|
||||||
|
className="border-l-4 border-green-500 pl-4 py-0 my-1"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold text-green-700 mb-2">
|
||||||
|
General Prompt
|
||||||
|
</div>
|
||||||
|
<pre className="whitespace-pre-wrap">
|
||||||
|
{content.substring(
|
||||||
|
0,
|
||||||
|
generalEndIndex
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company-specific section if present
|
||||||
|
if (
|
||||||
|
companySpecificStartIndex >= 0 &&
|
||||||
|
companySpecificEndIndex >= 0
|
||||||
|
) {
|
||||||
|
segments.push(
|
||||||
|
<div
|
||||||
|
id="company-section"
|
||||||
|
key="company"
|
||||||
|
className="border-l-4 border-blue-500 pl-4 py-0 my-1"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold text-blue-700 mb-2">
|
||||||
|
Company-Specific Instructions
|
||||||
|
</div>
|
||||||
|
<pre className="whitespace-pre-wrap">
|
||||||
|
{content.substring(
|
||||||
|
companySpecificStartIndex,
|
||||||
|
companySpecificEndIndex +
|
||||||
|
"--- END COMPANY-SPECIFIC INSTRUCTIONS ---"
|
||||||
|
.length
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taxonomy section
|
||||||
|
if (actualTaxonomyStartIndex >= 0) {
|
||||||
|
const taxEnd = taxonomyEndIndex;
|
||||||
|
segments.push(
|
||||||
|
<div
|
||||||
|
id="taxonomy-section"
|
||||||
|
key="taxonomy"
|
||||||
|
className="border-l-4 border-amber-500 pl-4 py-0 my-1"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold text-amber-700 mb-2">
|
||||||
|
Taxonomy Data
|
||||||
|
</div>
|
||||||
|
<pre className="whitespace-pre-wrap">
|
||||||
|
{content.substring(
|
||||||
|
actualTaxonomyStartIndex,
|
||||||
|
taxEnd
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product data section
|
||||||
|
if (productDataStartIndex >= 0) {
|
||||||
|
segments.push(
|
||||||
|
<div
|
||||||
|
id="product-section"
|
||||||
|
key="product"
|
||||||
|
className="border-l-4 border-pink-500 pl-4 py-0 my-1"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold text-pink-700 mb-2">
|
||||||
|
Product Data
|
||||||
|
</div>
|
||||||
|
<pre className="whitespace-pre-wrap">
|
||||||
|
{content.substring(
|
||||||
|
productDataStartIndex
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{segments}</>;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap">
|
||||||
|
{message.content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</Code>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-full w-full">
|
||||||
|
<Code className="whitespace-pre-wrap p-4 break-normal max-w-full">
|
||||||
|
{currentPrompt.prompt}
|
||||||
|
</Code>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
@@ -64,7 +645,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 +660,27 @@ 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 !== undefined
|
||||||
|
? Math.round(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 !== undefined
|
||||||
|
? Math.round(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,20 +688,28 @@ 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(
|
||||||
|
remainingSeconds
|
||||||
|
)} seconds remaining`;
|
||||||
} else {
|
} else {
|
||||||
const minutes = Math.floor(remainingSeconds / 60);
|
const minutes = Math.floor(remainingSeconds / 60);
|
||||||
const seconds = Math.round(remainingSeconds % 60);
|
const seconds = Math.round(remainingSeconds % 60);
|
||||||
@@ -116,10 +718,13 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
})()}
|
})()}
|
||||||
{aiValidationProgress.promptLength && (
|
{aiValidationProgress.promptLength && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Prompt length: {aiValidationProgress.promptLength.toLocaleString()} characters
|
Prompt length:{" "}
|
||||||
|
{aiValidationProgress.promptLength.toLocaleString()}{" "}
|
||||||
|
characters
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
@@ -129,23 +734,30 @@ 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-6xl w-[90vw]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>AI Validation Results</DialogTitle>
|
<DialogTitle>AI Validation Results</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Review the changes and warnings suggested by the AI
|
Review the changes and warnings suggested by the AI
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<ScrollArea className="max-h-[60vh]">
|
<ScrollArea className="max-h-[70vh]">
|
||||||
{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">
|
||||||
@@ -155,20 +767,34 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[180px]">Field</TableHead>
|
<TableHead className="">Field</TableHead>
|
||||||
<TableHead>Original Value</TableHead>
|
<TableHead className="w-[35%]">
|
||||||
<TableHead>Corrected Value</TableHead>
|
Original Value
|
||||||
<TableHead className="text-right">Action</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="w-[35%]">
|
||||||
|
Corrected Value
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
Accept Changes?
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</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 = isChangeLocallyReverted(
|
||||||
|
product.productIndex,
|
||||||
|
change.field
|
||||||
|
);
|
||||||
|
|
||||||
// Get highlighted differences
|
// Get highlighted differences
|
||||||
const { originalHtml, correctedHtml } = getFieldDisplayValueWithHighlight(
|
const { originalHtml, correctedHtml } =
|
||||||
|
getFieldDisplayValueWithHighlight(
|
||||||
change.field,
|
change.field,
|
||||||
change.original,
|
change.original,
|
||||||
change.corrected
|
change.corrected
|
||||||
@@ -176,43 +802,61 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
|
|||||||
|
|
||||||
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={{
|
||||||
className={isReverted ? "font-medium" : ""}
|
__html: originalHtml,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div
|
<div
|
||||||
dangerouslySetInnerHTML={{ __html: correctedHtml }}
|
dangerouslySetInnerHTML={{
|
||||||
className={!isReverted ? "font-medium" : ""}
|
__html: correctedHtml,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right align-top">
|
||||||
<div className="mt-2">
|
<div className="flex justify-end gap-2">
|
||||||
{isReverted ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-green-600 bg-green-50 hover:bg-green-100 hover:text-green-700"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<CheckIcon className="w-4 h-4 mr-1" />
|
|
||||||
Reverted
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Call the revert function directly
|
// Toggle to Accepted state if currently rejected
|
||||||
revertAiChange(product.productIndex, change.field);
|
toggleChangeAcceptance(
|
||||||
|
product.productIndex,
|
||||||
|
change.field
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
|
className={
|
||||||
|
!isReverted
|
||||||
|
? "bg-green-100 text-green-600 border-green-300 flex items-center"
|
||||||
|
: "border-gray-200 text-gray-600 hover:bg-green-50 hover:text-green-600 hover:border-green-200 flex items-center"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Revert Change
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
// Toggle to Rejected state if currently accepted
|
||||||
|
toggleChangeAcceptance(
|
||||||
|
product.productIndex,
|
||||||
|
change.field
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
isReverted
|
||||||
|
? "bg-red-100 text-red-600 border-red-300 flex items-center"
|
||||||
|
: "border-gray-200 text-gray-600 hover:bg-red-50 hover:text-red-600 hover:border-red-200 flex items-center"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -226,12 +870,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>
|
||||||
|
|||||||
@@ -18,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
|
||||||
*
|
*
|
||||||
@@ -1149,6 +1150,7 @@ 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 */}
|
||||||
|
<Protected permission="admin:debug">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={aiValidation.showCurrentPrompt}
|
onClick={aiValidation.showCurrentPrompt}
|
||||||
@@ -1158,6 +1160,7 @@ const ValidationContainer = <T extends string>({
|
|||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
Show Prompt
|
Show Prompt
|
||||||
</Button>
|
</Button>
|
||||||
|
</Protected>
|
||||||
|
|
||||||
{/* AI Validate Button */}
|
{/* AI Validate Button */}
|
||||||
<Button
|
<Button
|
||||||
@@ -1200,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 */}
|
||||||
|
|||||||
@@ -42,6 +42,39 @@ 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;
|
||||||
|
apiFormat?: Array<{
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}>;
|
||||||
|
promptSources?: {
|
||||||
|
systemPrompt?: { id: number; prompt_text: string };
|
||||||
|
generalPrompt?: { id: number; prompt_text: string };
|
||||||
|
companyPrompts?: Array<{
|
||||||
|
id: number;
|
||||||
|
company: string;
|
||||||
|
companyName: string;
|
||||||
|
prompt_text: string
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
estimatedProcessingTime?: {
|
||||||
|
seconds: number | null;
|
||||||
|
sampleCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Declare global interface for the timer
|
// Declare global interface for the timer
|
||||||
@@ -250,7 +283,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 +309,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 +331,16 @@ 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),
|
||||||
|
promptSources: result.promptSources,
|
||||||
|
estimatedProcessingTime: result.estimatedProcessingTime,
|
||||||
|
apiFormat: result.apiFormat
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No prompt returned from server');
|
throw new Error('No prompt returned from server');
|
||||||
@@ -460,6 +506,27 @@ export const useAiValidation = <T extends string>(
|
|||||||
throw new Error(result.error || 'AI validation failed');
|
throw new Error(result.error || 'AI validation failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the prompt sources if they exist
|
||||||
|
if (result.promptSources) {
|
||||||
|
setCurrentPrompt(prev => {
|
||||||
|
// Create debugData if it doesn't exist
|
||||||
|
const prevDebugData = prev.debugData || {
|
||||||
|
taxonomyStats: null,
|
||||||
|
basePrompt: '',
|
||||||
|
sampleFullPrompt: '',
|
||||||
|
promptLength: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
debugData: {
|
||||||
|
...prevDebugData,
|
||||||
|
promptSources: result.promptSources
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update progress with actual processing time if available
|
// Update progress with actual processing time if available
|
||||||
if (result.performanceMetrics) {
|
if (result.performanceMetrics) {
|
||||||
console.log('Performance metrics:', result.performanceMetrics);
|
console.log('Performance metrics:', result.performanceMetrics);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
584
inventory/src/components/settings/PromptManagement.tsx
Normal file
584
inventory/src/components/settings/PromptManagement.tsx
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ArrowUpDown, Pencil, Trash2, PlusCircle } from "lucide-react";
|
||||||
|
import config from "@/config";
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
SortingState,
|
||||||
|
flexRender,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface FieldOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PromptFormData {
|
||||||
|
id?: number;
|
||||||
|
prompt_text: string;
|
||||||
|
prompt_type: 'general' | 'company_specific' | 'system';
|
||||||
|
company: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiPrompt {
|
||||||
|
id: number;
|
||||||
|
prompt_text: string;
|
||||||
|
prompt_type: 'general' | 'company_specific' | 'system';
|
||||||
|
company: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldOptions {
|
||||||
|
companies: FieldOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptManagement() {
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
|
const [promptToDelete, setPromptToDelete] = useState<AiPrompt | null>(null);
|
||||||
|
const [editingPrompt, setEditingPrompt] = useState<AiPrompt | null>(null);
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: "prompt_type", desc: true },
|
||||||
|
{ id: "company", desc: false }
|
||||||
|
]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [formData, setFormData] = useState<PromptFormData>({
|
||||||
|
prompt_text: "",
|
||||||
|
prompt_type: "general",
|
||||||
|
company: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: prompts, isLoading } = useQuery<AiPrompt[]>({
|
||||||
|
queryKey: ["ai-prompts"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/ai-prompts`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch AI prompts");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: fieldOptions } = useQuery<FieldOptions>({
|
||||||
|
queryKey: ["fieldOptions"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/import/field-options`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch field options");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if general and system prompts already exist
|
||||||
|
const generalPromptExists = useMemo(() => {
|
||||||
|
return prompts?.some(prompt => prompt.prompt_type === 'general');
|
||||||
|
}, [prompts]);
|
||||||
|
|
||||||
|
const systemPromptExists = useMemo(() => {
|
||||||
|
return prompts?.some(prompt => prompt.prompt_type === 'system');
|
||||||
|
}, [prompts]);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async (data: PromptFormData) => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/ai-prompts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || error.error || "Failed to create prompt");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ai-prompts"] });
|
||||||
|
toast.success("Prompt created successfully");
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to create prompt");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async (data: PromptFormData) => {
|
||||||
|
if (!data.id) throw new Error("Prompt ID is required for update");
|
||||||
|
|
||||||
|
const response = await fetch(`${config.apiUrl}/ai-prompts/${data.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || error.error || "Failed to update prompt");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ai-prompts"] });
|
||||||
|
toast.success("Prompt updated successfully");
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to update prompt");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
const response = await fetch(`${config.apiUrl}/ai-prompts/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to delete prompt");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ai-prompts"] });
|
||||||
|
toast.success("Prompt deleted successfully");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to delete prompt");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (prompt: AiPrompt) => {
|
||||||
|
setEditingPrompt(prompt);
|
||||||
|
setFormData({
|
||||||
|
id: prompt.id,
|
||||||
|
prompt_text: prompt.prompt_text,
|
||||||
|
prompt_type: prompt.prompt_type,
|
||||||
|
company: prompt.company,
|
||||||
|
});
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (prompt: AiPrompt) => {
|
||||||
|
setPromptToDelete(prompt);
|
||||||
|
setIsDeleteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (promptToDelete) {
|
||||||
|
deleteMutation.mutate(promptToDelete.id);
|
||||||
|
setIsDeleteOpen(false);
|
||||||
|
setPromptToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// If prompt_type is general or system, ensure company is null
|
||||||
|
const submitData = {
|
||||||
|
...formData,
|
||||||
|
company: formData.prompt_type === 'company_specific' ? formData.company : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingPrompt) {
|
||||||
|
updateMutation.mutate(submitData);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(submitData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
prompt_text: "",
|
||||||
|
prompt_type: "general",
|
||||||
|
company: null,
|
||||||
|
});
|
||||||
|
setEditingPrompt(null);
|
||||||
|
setIsFormOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateClick = () => {
|
||||||
|
resetForm();
|
||||||
|
|
||||||
|
// If general prompt and system prompt exist, default to company-specific
|
||||||
|
if (generalPromptExists && systemPromptExists) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
prompt_type: 'company_specific'
|
||||||
|
}));
|
||||||
|
} else if (generalPromptExists && !systemPromptExists) {
|
||||||
|
// If general exists but system doesn't, suggest system prompt
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
prompt_type: 'system'
|
||||||
|
}));
|
||||||
|
} else if (!generalPromptExists) {
|
||||||
|
// If no general prompt, suggest that first
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
prompt_type: 'general'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<AiPrompt>[]>(() => [
|
||||||
|
{
|
||||||
|
accessorKey: "prompt_type",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const type = row.getValue("prompt_type") as string;
|
||||||
|
if (type === 'general') return 'General';
|
||||||
|
if (type === 'system') return 'System';
|
||||||
|
return 'Company Specific';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => row.prompt_text.length,
|
||||||
|
id: "length",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Length
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const length = getValue() as number;
|
||||||
|
return <span>{length.toLocaleString()}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "company",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Company
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const companyId = row.getValue("company");
|
||||||
|
if (!companyId) return 'N/A';
|
||||||
|
return fieldOptions?.companies.find(c => c.value === companyId)?.label || companyId;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "updated_at",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Last Updated
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => new Date(row.getValue("updated_at")).toLocaleDateString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2 justify-end pr-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleEdit(row.original)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => handleDeleteClick(row.original)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [fieldOptions]);
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!prompts) return [];
|
||||||
|
return prompts.filter((prompt) => {
|
||||||
|
const searchString = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
prompt.prompt_type.toLowerCase().includes(searchString) ||
|
||||||
|
(prompt.company && prompt.company.toLowerCase().includes(searchString))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [prompts, searchQuery]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold">AI Validation Prompts</h2>
|
||||||
|
<Button onClick={handleCreateClick}>
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
|
Create New Prompt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Search prompts..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div>Loading prompts...</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id} className="hover:bg-gray-100">
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id} className="pl-6">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="text-center">
|
||||||
|
No prompts found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prompt Form Dialog */}
|
||||||
|
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingPrompt ? "Edit Prompt" : "Create New Prompt"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingPrompt
|
||||||
|
? "Update this AI validation prompt."
|
||||||
|
: "Create a new AI validation prompt that will be used during product validation."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="prompt_type">Prompt Type</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.prompt_type}
|
||||||
|
onValueChange={(value: 'general' | 'company_specific' | 'system') =>
|
||||||
|
setFormData({ ...formData, prompt_type: value })
|
||||||
|
}
|
||||||
|
disabled={(generalPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id) ||
|
||||||
|
(systemPromptExists && formData.prompt_type !== 'system' && !editingPrompt?.id)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select prompt type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
value="general"
|
||||||
|
disabled={generalPromptExists && !editingPrompt?.prompt_type?.includes('general')}
|
||||||
|
>
|
||||||
|
General
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="system"
|
||||||
|
disabled={systemPromptExists && !editingPrompt?.prompt_type?.includes('system')}
|
||||||
|
>
|
||||||
|
System
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="company_specific">Company Specific</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{generalPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id && systemPromptExists && formData.prompt_type !== 'system' && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
General and system prompts already exist. You can only create company-specific prompts.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{generalPromptExists && !systemPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A general prompt already exists. You can create a system prompt or company-specific prompts.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{systemPromptExists && !generalPromptExists && formData.prompt_type !== 'system' && !editingPrompt?.id && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A system prompt already exists. You can create a general prompt or company-specific prompts.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.prompt_type === 'company_specific' && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="company">Company</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.company || ''}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, company: value })}
|
||||||
|
required={formData.prompt_type === 'company_specific'}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select company" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fieldOptions?.companies.map((company) => (
|
||||||
|
<SelectItem key={company.value} value={company.value}>
|
||||||
|
{company.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="prompt_text">Prompt Text</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prompt_text"
|
||||||
|
value={formData.prompt_text}
|
||||||
|
onChange={(e) => setFormData({ ...formData, prompt_text: e.target.value })}
|
||||||
|
placeholder={`Enter your ${formData.prompt_type === 'system' ? 'system instructions' : 'validation prompt'} text...`}
|
||||||
|
className="h-80 font-mono text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{formData.prompt_type === 'system' && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
System prompts provide the initial instructions to the AI. This sets the tone and approach for all validations.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
setIsFormOpen(false);
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{editingPrompt ? "Update" : "Create"} Prompt
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Prompt</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete this prompt? This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => {
|
||||||
|
setIsDeleteOpen(false);
|
||||||
|
setPromptToDelete(null);
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDeleteConfirm}>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</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,250 @@ 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 { PromptManagement } from "@/components/settings/PromptManagement";
|
||||||
|
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: "ai-prompts", permission: "settings:templates", label: "AI Prompts" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
|
||||||
|
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>
|
</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>
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TabsContent value="data-management">
|
<div className="pl-8 w-full">
|
||||||
|
<TabsContent value="data-management" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
|
<Protected
|
||||||
|
permission="settings:data_management"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access Data Management.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
<DataManagement />
|
<DataManagement />
|
||||||
|
</Protected>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="stock-management">
|
<TabsContent value="stock-management" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
|
<Protected
|
||||||
|
permission="settings:stock_management"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access Stock Management.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
<StockManagement />
|
<StockManagement />
|
||||||
|
</Protected>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="performance-metrics">
|
<TabsContent value="performance-metrics" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
|
<Protected
|
||||||
|
permission="settings:performance_metrics"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access Performance Metrics.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
<PerformanceMetrics />
|
<PerformanceMetrics />
|
||||||
|
</Protected>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="calculation-settings">
|
<TabsContent value="calculation-settings" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
|
<Protected
|
||||||
|
permission="settings:calculation_settings"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access Calculation Settings.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
<CalculationSettings />
|
<CalculationSettings />
|
||||||
|
</Protected>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="templates">
|
<TabsContent value="templates" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
|
<Protected
|
||||||
|
permission="settings:templates"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access Template Management.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
<TemplateManagement />
|
<TemplateManagement />
|
||||||
|
</Protected>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="ai-prompts" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
|
||||||
|
<Protected
|
||||||
|
permission="settings:templates"
|
||||||
|
fallback={
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You don't have permission to access AI Prompts.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PromptManagement />
|
||||||
|
</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
File diff suppressed because one or more lines are too long
@@ -166,6 +166,12 @@ export default defineConfig(function (_a) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/uploads": {
|
||||||
|
target: "https://inventory.kent.pw",
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
rewrite: function (path) { return path; },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
Reference in New Issue
Block a user