mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 14:55:24 +02:00
Compare commits
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(git checkout *)",
|
||||||
|
"Bash(git merge *)",
|
||||||
|
"Bash(npx *)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(git show *)",
|
||||||
|
"Bash(diff *)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"$version": 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ npm run clean # Remove dist folder
|
|||||||
npm run lint # TypeScript type check only (tsc --noEmit)
|
npm run lint # TypeScript type check only (tsc --noEmit)
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: This project does not have a separate test framework configured. To add tests, consider installing Vitest or Jest.
|
Note: No test framework configured. To add tests, install Vitest or Jest.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,10 +27,8 @@ Note: This project does not have a separate test framework configured. To add te
|
|||||||
- Keep components focused and modular
|
- Keep components focused and modular
|
||||||
- Use meaningful variable and function names
|
- Use meaningful variable and function names
|
||||||
|
|
||||||
### Imports
|
### Imports (order top to bottom)
|
||||||
|
1. React (`react`)
|
||||||
**Order (top to bottom):**
|
|
||||||
1. React imports (`react`)
|
|
||||||
2. External libraries (`lucide-react`, `xlsx`, etc.)
|
2. External libraries (`lucide-react`, `xlsx`, etc.)
|
||||||
3. Internal components (`./components/...`)
|
3. Internal components (`./components/...`)
|
||||||
4. Internal lib/utils (`./lib/...`)
|
4. Internal lib/utils (`./lib/...`)
|
||||||
@@ -44,19 +42,16 @@ import { cn } from '../lib/utils';
|
|||||||
```
|
```
|
||||||
|
|
||||||
### TypeScript Conventions
|
### TypeScript Conventions
|
||||||
|
|
||||||
- Use explicit types for props and function parameters
|
- Use explicit types for props and function parameters
|
||||||
- Use `any` sparingly; prefer union types or interfaces
|
- Use `any` sparingly; prefer union types or interfaces
|
||||||
- Define column indices in a centralized `COLUMNS` object (see `src/types.ts`)
|
- Define column indices in a centralized `COLUMNS` object (see `src/types.ts`)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Good
|
|
||||||
interface ProductDescriptionsProps {
|
interface ProductDescriptionsProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Good - centralized constants
|
|
||||||
export const COLUMNS = {
|
export const COLUMNS = {
|
||||||
ARTICLE_NO: 0,
|
ARTICLE_NO: 0,
|
||||||
ARTICLE_NAME: 2,
|
ARTICLE_NAME: 2,
|
||||||
@@ -76,119 +71,58 @@ export const COLUMNS = {
|
|||||||
| Types | PascalCase | `TabType`, `SortDirection` |
|
| Types | PascalCase | `TabType`, `SortDirection` |
|
||||||
|
|
||||||
### React Patterns
|
### React Patterns
|
||||||
|
|
||||||
- Destructure props in function signature
|
- Destructure props in function signature
|
||||||
- Use `useMemo` for expensive computations
|
- Use `useMemo` for expensive computations
|
||||||
- Use `useCallback` for event handlers passed to child components
|
- Use `useCallback` for event handlers passed to child components
|
||||||
- Keep `useState` calls at the top of component
|
- Keep `useState` calls at the top of component
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
// expensive computation
|
|
||||||
}, [data, activeTab, search]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
- Use TypeScript's type system for runtime safety
|
- Use TypeScript's type system for runtime safety
|
||||||
- Use optional chaining (`?.`) and nullish coalescing (`??`)
|
- Use optional chaining (`?.`) and nullish coalescing (`??`)
|
||||||
- Validate file uploads with proper type checks
|
- Validate file uploads with proper type checks
|
||||||
|
|
||||||
```typescript
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// Validate Excel data
|
|
||||||
if (data.length > 0) {
|
|
||||||
const rawHeaders = data[0];
|
|
||||||
const rawRows = data.slice(1);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### UI/Styling
|
### UI/Styling
|
||||||
|
|
||||||
- Use Tailwind CSS for all styling
|
- Use Tailwind CSS for all styling
|
||||||
- Use `cn()` utility from `lib/utils` for conditional classes
|
- Use `cn()` utility from `lib/utils` for conditional classes
|
||||||
- Follow existing color scheme (slate, blue, green, red for status)
|
- Follow existing color scheme (slate, blue, green, red for status)
|
||||||
- Use `lucide-react` for icons
|
- Use `lucide-react` for icons
|
||||||
- Keep responsive design in mind
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { cn } from '../lib/utils';
|
<button className={cn("px-4 py-2 rounded-md", isActive ? "bg-blue-600" : "bg-slate-800")}>
|
||||||
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-2 rounded-md text-sm font-medium",
|
|
||||||
isActive
|
|
||||||
? "bg-blue-600 text-white"
|
|
||||||
: "bg-slate-800 text-slate-400"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### File Organization
|
### File Organization
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── components/ # React components
|
├── components/ # React components
|
||||||
│ ├── Sidebar.tsx
|
├── lib/ # Utilities (utils.ts, auth.ts, supabase.ts)
|
||||||
│ ├── MatrixView.tsx
|
├── services/ # External API integrations (gemini.ts, anthropic.ts)
|
||||||
│ └── ...
|
|
||||||
├── lib/ # Utilities and helpers
|
|
||||||
│ ├── utils.ts # cn(), helpers
|
|
||||||
│ ├── auth.ts # Authentication
|
|
||||||
│ └── supabase.ts # Database operations
|
|
||||||
├── services/ # External API integrations
|
|
||||||
│ ├── gemini.ts
|
|
||||||
│ └── anthropic.ts
|
|
||||||
├── types.ts # TypeScript types and constants
|
├── types.ts # TypeScript types and constants
|
||||||
├── App.tsx # Main application
|
├── App.tsx # Main application
|
||||||
└── main.tsx # Entry point
|
└── main.tsx # Entry point
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Processing
|
### Data Processing
|
||||||
|
- Handle both string and number types when processing Excel data
|
||||||
- When processing Excel data, handle both string and number types
|
|
||||||
- Use centralized column index constants
|
- Use centralized column index constants
|
||||||
- Format numbers consistently (2 decimal places for prices/weights)
|
- Format numbers consistently (2 decimal places for prices/weights)
|
||||||
- Handle Excel date serial numbers properly (convert to readable dates)
|
- Handle Excel date serial numbers properly
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Handle date columns from Excel
|
// Handle date columns from Excel
|
||||||
if (header.includes('date') || header.includes('launch')) {
|
|
||||||
if (typeof val === 'number' && val >= 25569 && val <= 60000) {
|
if (typeof val === 'number' && val >= 25569 && val <= 60000) {
|
||||||
const excelEpoch = new Date(1899, 11, 30);
|
const excelEpoch = new Date(1899, 11, 30);
|
||||||
const date = new Date(excelEpoch.getTime() + val * 86400000);
|
return new Date(excelEpoch.getTime() + val * 86400000).toLocaleDateString('en-GB');
|
||||||
return date.toLocaleDateString('en-GB');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Git Workflow
|
### Git Workflow
|
||||||
|
|
||||||
- Make small, focused commits
|
- Make small, focused commits
|
||||||
- Write clear commit messages describing what changed
|
- Write clear commit messages describing what changed
|
||||||
- Push to main to trigger Vercel deployment automatically
|
- Push to main to trigger Vercel deployment automatically
|
||||||
|
|
||||||
### Running Single Components
|
|
||||||
|
|
||||||
When testing or developing specific features:
|
|
||||||
```bash
|
|
||||||
npm run dev # Start dev server - access at http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
- Use `.env` file for local development
|
- Use `.env` file for local development
|
||||||
- Never commit secrets - use Vercel dashboard for production env vars
|
- Never commit secrets - use Vercel dashboard for production env vars
|
||||||
|
|
||||||
Required environment variables (production):
|
Required: `VITE_SUPABASE_URL`, `VITE_SUPABASE_ANON_KEY`, `VITE_GEMINI_API_KEY`, `VITE_ANTHROPIC_API_KEY`
|
||||||
- `VITE_SUPABASE_URL`
|
|
||||||
- `VITE_SUPABASE_ANON_KEY`
|
|
||||||
- `VITE_GEMINI_API_KEY`
|
|
||||||
- `VITE_ANTHROPIC_API_KEY`
|
|
||||||
|
|||||||
+59
-7
@@ -1,16 +1,67 @@
|
|||||||
// Vercel serverless function — proxies the Dropbox file server-side (no CORS)
|
const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
|
||||||
|
const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET;
|
||||||
|
const DROPBOX_REFRESH_TOKEN = process.env.DROPBOX_REFRESH_TOKEN;
|
||||||
|
|
||||||
|
async function getAccessToken() {
|
||||||
|
const response = await fetch('https://api.dropboxapi.com/oauth2/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: DROPBOX_REFRESH_TOKEN,
|
||||||
|
client_id: DROPBOX_APP_KEY,
|
||||||
|
client_secret: DROPBOX_APP_SECRET,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.access_token) {
|
||||||
|
throw new Error('Failed to get access token: ' + JSON.stringify(data));
|
||||||
|
}
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
const fileUrl =
|
let accessToken;
|
||||||
'https://dl.dropboxusercontent.com/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx' +
|
try {
|
||||||
'?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1';
|
accessToken = await getAccessToken();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Token refresh error:', err);
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.query.info === '1') {
|
||||||
|
try {
|
||||||
|
const fileInfo = await fetch('https://api.dropboxapi.com/2/files/get_metadata', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ path: '/CRAZE GmbH/Sales Reports/Data Matrix.xlsx' })
|
||||||
|
});
|
||||||
|
const data = await fileInfo.json();
|
||||||
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
res.setHeader('Expires', '0');
|
||||||
|
return res.json({ rev: data.rev, size: data.size, server_modified: data.server_modified });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(fileUrl, {
|
const upstream = await fetch('https://content.dropboxapi.com/2/files/download', {
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Dropbox-API-Arg': JSON.stringify({ path: '/CRAZE GmbH/Sales Reports/Data Matrix.xlsx' })
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
return res.status(upstream.status).send(`Dropbox error: ${upstream.status}`);
|
const errText = await upstream.text();
|
||||||
|
console.error('Dropbox API error:', upstream.status, errText);
|
||||||
|
return res.status(upstream.status).send('Dropbox error: ' + errText);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await upstream.arrayBuffer();
|
const buffer = await upstream.arrayBuffer();
|
||||||
@@ -18,6 +69,7 @@ export default async function handler(req, res) {
|
|||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
res.send(Buffer.from(buffer));
|
res.send(Buffer.from(buffer));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('Dropbox proxy error:', err);
|
||||||
res.status(500).send('Proxy error: ' + err.message);
|
res.status(500).send('Proxy error: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||||
|
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||||
|
|
||||||
|
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
try {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, fileMeta } = req.body;
|
||||||
|
|
||||||
|
if (!rows || !Array.isArray(rows)) {
|
||||||
|
return res.status(400).json({ error: 'Missing rows data' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleNoIdx = 0;
|
||||||
|
|
||||||
|
const productsToUpsert = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const productId = String(row[articleNoIdx]);
|
||||||
|
if (productId && productId.trim() !== '') {
|
||||||
|
productsToUpsert.push({
|
||||||
|
product_id: productId,
|
||||||
|
data: row,
|
||||||
|
status: 'synced',
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Upserting', productsToUpsert.length, 'products to Supabase...');
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('products')
|
||||||
|
.upsert(productsToUpsert, {
|
||||||
|
onConflict: 'product_id',
|
||||||
|
ignoreDuplicates: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Supabase upsert error:', error);
|
||||||
|
return res.status(500).json({ error: error.message, detail: 'Failed to upsert products' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
syncedCount: productsToUpsert.length
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Handler error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+254
@@ -9,10 +9,13 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
|
"@supabase/supabase-js": "^2.103.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"buzz": "^2.0.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"dropbox": "^10.34.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
@@ -1175,6 +1178,92 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/phoenix": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/phoenix": "^0.4.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.103.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.0.tgz",
|
||||||
|
"integrity": "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.103.0",
|
||||||
|
"@supabase/functions-js": "2.103.0",
|
||||||
|
"@supabase/postgrest-js": "2.103.0",
|
||||||
|
"@supabase/realtime-js": "2.103.0",
|
||||||
|
"@supabase/storage-js": "2.103.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||||
@@ -1549,6 +1638,17 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node-fetch": {
|
||||||
|
"version": "2.6.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||||
|
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"form-data": "^4.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -1602,6 +1702,15 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||||
@@ -1659,6 +1768,12 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.27",
|
"version": "10.4.27",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||||
@@ -1816,6 +1931,12 @@
|
|||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/buzz": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/buzz/-/buzz-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-eYeTETPJp7hWUX7j3o8iJNR8VLaaWRuOYYPWTSQqA5pIxZ2g3ZJUdyjfNZnGvr85IDhfr4ONQQflGp8+MC034A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -1905,6 +2026,18 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -1985,6 +2118,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -2025,6 +2167,41 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dropbox": {
|
||||||
|
"version": "10.34.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dropbox/-/dropbox-10.34.0.tgz",
|
||||||
|
"integrity": "sha512-5jb5/XzU0fSnq36/hEpwT5/QIep7MgqKuxghEG44xCu7HruOAjPdOb3x0geXv5O/hd0nHpQpWO+r5MjYTpMvJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.6.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node-fetch": "^2.5.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dropbox/node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -2112,6 +2289,21 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||||
@@ -2318,6 +2510,22 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/formdata-polyfill": {
|
"node_modules/formdata-polyfill": {
|
||||||
"version": "4.0.10",
|
"version": "4.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
@@ -2564,6 +2772,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -2609,6 +2832,15 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
@@ -3709,6 +3941,12 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -4367,6 +4605,22 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wmf": {
|
"node_modules/wmf": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
|||||||
@@ -12,10 +12,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
|
"@supabase/supabase-js": "^2.103.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"buzz": "^2.0.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"dropbox": "^10.34.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
|
|||||||
+301
-71
@@ -6,87 +6,155 @@ import { TopBar } from './components/TopBar';
|
|||||||
import { ProductDescriptions } from './components/ProductDescriptions';
|
import { ProductDescriptions } from './components/ProductDescriptions';
|
||||||
import { MatrixView } from './components/MatrixView';
|
import { MatrixView } from './components/MatrixView';
|
||||||
import { EditPanel } from './components/EditPanel';
|
import { EditPanel } from './components/EditPanel';
|
||||||
import { getAllSyncedRows, saveRowToSupabase } from './lib/supabase';
|
import { getAllSyncedRows, saveRowToSupabase, resetAllPendingRows, saveHistoryEntry, deleteHistoryEntry } from './lib/supabase';
|
||||||
import { getStoredSession, signOut, type AuthSession } from './lib/auth';
|
import { getStoredSession, signOut, type AuthSession } from './lib/auth';
|
||||||
import { LoginPage } from './components/LoginPage';
|
import { LoginPage } from './components/LoginPage';
|
||||||
import { DimensionsView } from './components/DimensionsView';
|
import { DimensionsView } from './components/DimensionsView';
|
||||||
import { PricingView } from './components/PricingView';
|
import { PricingView } from './components/PricingView';
|
||||||
import { ArticleDetails } from './components/ArticleDetails';
|
import { ArticleDetails } from './components/ArticleDetails';
|
||||||
|
import { HistoryView } from './components/HistoryView';
|
||||||
import { UndoToast } from './components/UndoToast';
|
import { UndoToast } from './components/UndoToast';
|
||||||
|
import { PendingValidationView } from './components/PendingValidationView';
|
||||||
|
import { MissingDataView } from './components/MissingDataView';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
|
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
|
||||||
|
|
||||||
const handleSignOut = () => {
|
|
||||||
signOut();
|
|
||||||
setSession(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return <LoginPage onLogin={() => setSession(getStoredSession())} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [appState, setAppState] = useState<AppState>({
|
const [appState, setAppState] = useState<AppState>({
|
||||||
headers: [],
|
headers: [],
|
||||||
data: [],
|
data: [],
|
||||||
fileName: '',
|
fileName: '',
|
||||||
fileDate: null,
|
fileDate: null,
|
||||||
hasUnsavedChanges: false
|
hasUnsavedChanges: false,
|
||||||
|
asinColumnIndex: null
|
||||||
});
|
});
|
||||||
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing'>('descriptions');
|
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data'>('descriptions');
|
||||||
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
|
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
|
||||||
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
||||||
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
|
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
|
||||||
const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null);
|
const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null);
|
||||||
|
const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({});
|
||||||
|
const [pendingRows, setPendingRows] = useState<Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>>({});
|
||||||
|
const [isSavingAll, setIsSavingAll] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[App] session changed:', session ? 'logged in' : 'logged out');
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const handleSignOut = () => {
|
||||||
|
signOut();
|
||||||
|
setSession(null);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDefaultData = async () => {
|
const loadDefaultData = async () => {
|
||||||
// In dev: Vite proxy handles /dropbox-file (see vite.config.ts)
|
|
||||||
// In prod: Vercel serverless function at /api/dropbox-proxy handles it
|
|
||||||
const fileUrl = import.meta.env.DEV
|
|
||||||
? '/dropbox-file/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1'
|
|
||||||
: '/api/dropbox-proxy';
|
|
||||||
|
|
||||||
setIsLoadingDefault(true);
|
setIsLoadingDefault(true);
|
||||||
setDefaultLoadError(null);
|
setDefaultLoadError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Fetching Data-Matrix.xlsx via Vite proxy...');
|
const isDev = import.meta.env.DEV;
|
||||||
|
|
||||||
|
let rows: any[][];
|
||||||
|
let allData: any[][];
|
||||||
|
let fileMeta = { rev: '', size: 0 };
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
const fileUrl = '/dropbox-file/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1';
|
||||||
|
console.log('Fetching Data-Matrix.xlsx from Dropbox...');
|
||||||
const response = await fetch(fileUrl);
|
const response = await fetch(fileUrl);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
if (arrayBuffer.byteLength < 100) {
|
if (arrayBuffer.byteLength < 100) throw new Error('File too small');
|
||||||
throw new Error('File too small — possibly empty or error response');
|
|
||||||
}
|
|
||||||
|
|
||||||
const wb = XLSX.read(arrayBuffer, { type: 'array' });
|
const wb = XLSX.read(arrayBuffer, { type: 'array' });
|
||||||
const wsname = wb.SheetNames[0];
|
const wsname = wb.SheetNames[0];
|
||||||
const ws = wb.Sheets[wsname];
|
const ws = wb.Sheets[wsname];
|
||||||
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
|
allData = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
|
||||||
|
rows = allData.slice(1);
|
||||||
|
fileMeta = { rev: 'dev', size: arrayBuffer.byteLength };
|
||||||
|
} else {
|
||||||
|
console.log('Fetching file info from Dropbox...');
|
||||||
|
const infoRes = await fetch('/api/dropbox-proxy?info=1');
|
||||||
|
if (infoRes.ok) {
|
||||||
|
fileMeta = await infoRes.json();
|
||||||
|
console.log('File meta:', fileMeta);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.length > 0) {
|
console.log('Fetching Data-Matrix.xlsx from proxy...');
|
||||||
const rawHeaders = data[0];
|
const response = await fetch('/api/dropbox-proxy');
|
||||||
const rawRows = data.slice(1);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
if (arrayBuffer.byteLength < 100) throw new Error('File too small');
|
||||||
|
|
||||||
|
const wb = XLSX.read(arrayBuffer, { type: 'array' });
|
||||||
|
const wsname = wb.SheetNames[0];
|
||||||
|
const ws = wb.Sheets[wsname];
|
||||||
|
allData = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
|
||||||
|
rows = allData.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const headers = allData.slice(0, 1)[0];
|
||||||
|
|
||||||
|
console.log('Syncing Excel data to Supabase...');
|
||||||
|
const syncRes = await fetch('/api/dropbox-sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ rows, fileMeta })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (syncRes.ok) {
|
||||||
|
const syncResult = await syncRes.json();
|
||||||
|
console.log('Supabase sync result:', syncResult);
|
||||||
|
} else {
|
||||||
|
const errorText = await syncRes.text();
|
||||||
|
console.warn('Supabase sync failed:', syncRes.status, errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching synced data from Supabase...');
|
||||||
|
const syncedData = await getAllSyncedRows(session?.access_token);
|
||||||
|
|
||||||
|
const editableColumns = new Set([
|
||||||
|
COLUMNS.CLASSIFICATION,
|
||||||
|
COLUMNS.LONG_DE, COLUMNS.LONG_EN,
|
||||||
|
COLUMNS.SHORT_DE, COLUMNS.SHORT_EN,
|
||||||
|
COLUMNS.DETAILS_DE, COLUMNS.DETAILS_EN,
|
||||||
|
COLUMNS.INNER_L, COLUMNS.INNER_W, COLUMNS.INNER_H,
|
||||||
|
COLUMNS.OUTER_L, COLUMNS.OUTER_W, COLUMNS.OUTER_H,
|
||||||
|
COLUMNS.UNITS_OUTER, COLUMNS.MOQ,
|
||||||
|
COLUMNS.VERIFIED_DIMS
|
||||||
|
]);
|
||||||
|
|
||||||
|
headers.forEach((h: any, i: number) => {
|
||||||
|
const hl = String(h || '').toLowerCase();
|
||||||
|
if (hl.includes('srp') || hl.includes('uvp') || hl.includes('40') || hl.includes('price')) {
|
||||||
|
editableColumns.add(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
console.log('Applying Supabase overrides...');
|
|
||||||
const syncedData = await getAllSyncedRows();
|
|
||||||
const articleNoIdx = COLUMNS.ARTICLE_NO;
|
const articleNoIdx = COLUMNS.ARTICLE_NO;
|
||||||
|
const processedRows = rows.map(row => {
|
||||||
const processedRows = rawRows.map(row => {
|
|
||||||
const articleNo = String(row[articleNoIdx]);
|
const articleNo = String(row[articleNoIdx]);
|
||||||
const finalRow = syncedData[articleNo] || row;
|
const synced = syncedData[articleNo];
|
||||||
|
|
||||||
// Format numeric/price fields to 2 decimal places
|
const finalRow = [...row];
|
||||||
return finalRow.map((val, idx) => {
|
if (synced) {
|
||||||
|
// Smart Merge: Only overlay fields we logically "own" via this app's editors
|
||||||
|
editableColumns.forEach(idx => {
|
||||||
|
if (synced.data[idx] !== undefined && synced.data[idx] !== null) {
|
||||||
|
finalRow[idx] = synced.data[idx];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (synced.status === 'pending') {
|
||||||
|
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalRow.map((val: any, idx: number) => {
|
||||||
if (val === undefined || val === null || val === '') return val;
|
if (val === undefined || val === null || val === '') return val;
|
||||||
const header = (rawHeaders[idx] || '').toLowerCase();
|
const header = (headers[idx] || '').toLowerCase();
|
||||||
|
|
||||||
// Skip Article No, Barcodes, and other code-like fields
|
|
||||||
// But allow if it's a weight/measure column (e.g. Article NW (kg))
|
|
||||||
if ((header.includes('id') || header.includes('no') || header.includes('code') ||
|
if ((header.includes('id') || header.includes('no') || header.includes('code') ||
|
||||||
header.includes('art.') || header.includes('barcode') || header.includes('article')) &&
|
header.includes('art.') || header.includes('barcode') || header.includes('article')) &&
|
||||||
!(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) {
|
!(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) {
|
||||||
@@ -111,12 +179,17 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const asinIdx = (headers as string[]).findIndex((h: string) =>
|
||||||
|
String(h).toLowerCase().trim() === 'asin'
|
||||||
|
);
|
||||||
|
|
||||||
setAppState({
|
setAppState({
|
||||||
headers: rawHeaders,
|
headers: headers,
|
||||||
data: processedRows,
|
data: processedRows,
|
||||||
fileName: 'Data-Matrix.xlsx (Cloud Sync)',
|
fileName: 'Data-Matrix.xlsx (Cloud Sync)',
|
||||||
fileDate: new Date(),
|
fileDate: new Date(),
|
||||||
hasUnsavedChanges: false
|
hasUnsavedChanges: false,
|
||||||
|
asinColumnIndex: asinIdx !== -1 ? asinIdx : null
|
||||||
});
|
});
|
||||||
setActiveModule('descriptions');
|
setActiveModule('descriptions');
|
||||||
}
|
}
|
||||||
@@ -128,8 +201,8 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadDefaultData();
|
if (session) loadDefaultData();
|
||||||
}, []);
|
}, [session]);
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -151,13 +224,13 @@ export default function App() {
|
|||||||
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
|
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
|
||||||
|
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
const rawHeaders = data[0];
|
const headers = data[0];
|
||||||
const rawRows = data.slice(1);
|
const rawRows = data.slice(1);
|
||||||
|
|
||||||
const processedRows = rawRows.map(row => {
|
const processedRows = rawRows.map(row => {
|
||||||
return row.map((val, idx) => {
|
return row.map((val, idx) => {
|
||||||
if (val === undefined || val === null || val === '') return val;
|
if (val === undefined || val === null || val === '') return val;
|
||||||
const header = (rawHeaders[idx] || '').toLowerCase();
|
const header = (headers[idx] || '').toLowerCase();
|
||||||
|
|
||||||
if (header.includes('id') || header.includes('no') || header.includes('code') ||
|
if (header.includes('id') || header.includes('no') || header.includes('code') ||
|
||||||
header.includes('art.') || header.includes('barcode') || header.includes('article')) {
|
header.includes('art.') || header.includes('barcode') || header.includes('article')) {
|
||||||
@@ -196,11 +269,12 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setAppState({
|
setAppState({
|
||||||
headers: rawHeaders,
|
headers: headers,
|
||||||
data: processedRows,
|
data: processedRows,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileDate: new Date(),
|
fileDate: new Date(),
|
||||||
hasUnsavedChanges: false
|
hasUnsavedChanges: false,
|
||||||
|
asinColumnIndex: null
|
||||||
});
|
});
|
||||||
setActiveModule('descriptions');
|
setActiveModule('descriptions');
|
||||||
}
|
}
|
||||||
@@ -211,7 +285,14 @@ export default function App() {
|
|||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
if (appState.data.length === 0) return;
|
if (appState.data.length === 0) return;
|
||||||
|
|
||||||
const wsData = [appState.headers, ...appState.data];
|
const wsData = [
|
||||||
|
appState.headers,
|
||||||
|
...appState.data.map(row => {
|
||||||
|
const copy = [...row];
|
||||||
|
delete copy[COLUMNS.VERIFIED_DIMS];
|
||||||
|
return copy.slice(0, appState.headers.length);
|
||||||
|
})
|
||||||
|
];
|
||||||
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||||||
const wb = XLSX.utils.book_new();
|
const wb = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(wb, ws, 'Products');
|
XLSX.utils.book_append_sheet(wb, ws, 'Products');
|
||||||
@@ -219,33 +300,106 @@ export default function App() {
|
|||||||
const dateStr = new Date().toISOString().split('T')[0];
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
XLSX.writeFile(wb, `CRAZE_Products_Updated_${dateStr}.xlsx`);
|
XLSX.writeFile(wb, `CRAZE_Products_Updated_${dateStr}.xlsx`);
|
||||||
|
|
||||||
|
// 3. Post-export: Reset pending statuses in Supabase
|
||||||
|
console.log('Resetting pending statuses in Supabase...');
|
||||||
|
resetAllPendingRows(session?.access_token).then(success => {
|
||||||
|
if (success) {
|
||||||
|
console.log('Successfully reset all pending statuses');
|
||||||
|
setRowStatuses({}); // Clear local statuses
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
|
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveRow = async (rowIndex: number, updatedRow: ExcelRow) => {
|
const handleSaveRow = (rowIndex: number, updatedRow: ExcelRow) => {
|
||||||
// 1. Update UI state
|
const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]);
|
||||||
|
const originalData = appState.data[rowIndex]; // Capture before update
|
||||||
setAppState(prev => {
|
setAppState(prev => {
|
||||||
const newData = [...prev.data];
|
const newData = [...prev.data];
|
||||||
newData[rowIndex] = updatedRow;
|
newData[rowIndex] = updatedRow;
|
||||||
return {
|
return { ...prev, data: newData, hasUnsavedChanges: true };
|
||||||
...prev,
|
|
||||||
data: newData,
|
|
||||||
hasUnsavedChanges: true
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
setPendingRows(prev => ({
|
||||||
|
...prev,
|
||||||
|
[articleNo]: {
|
||||||
|
rowIndex,
|
||||||
|
// Keep the very first originalData if already pending (re-edit case)
|
||||||
|
originalData: prev[articleNo]?.originalData ?? originalData,
|
||||||
|
newData: updatedRow,
|
||||||
|
articleName: String(updatedRow[COLUMNS.ARTICLE_NAME] || articleNo),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
|
||||||
setEditingRowIndex(null);
|
setEditingRowIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
// 2. Persist to Supabase
|
const handleRevertRow = (articleNo: string) => {
|
||||||
const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]);
|
const pending = pendingRows[articleNo];
|
||||||
console.log(`Saving article ${articleNo} to Supabase...`);
|
if (!pending) return;
|
||||||
const success = await saveRowToSupabase(articleNo, updatedRow);
|
setAppState(prev => {
|
||||||
|
const newData = [...prev.data];
|
||||||
|
newData[pending.rowIndex] = pending.originalData;
|
||||||
|
const stillPending = Object.keys(pendingRows).length > 0;
|
||||||
|
return { ...prev, data: newData, hasUnsavedChanges: stillPending };
|
||||||
|
});
|
||||||
|
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
|
||||||
|
setRowStatuses(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
|
||||||
|
};
|
||||||
|
|
||||||
if (success) {
|
const handleSaveAll = async () => {
|
||||||
console.log(`Successfully saved ${articleNo}`);
|
console.log('[handleSaveAll] Starting save, pendingRows:', pendingRows);
|
||||||
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
|
const entries = Object.entries(pendingRows) as [string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }][];
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.log('[handleSaveAll] No entries to save, returning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingAll(true);
|
||||||
|
let failedArticles: string[] = [];
|
||||||
|
let jwtExpired = false;
|
||||||
|
const token = session?.access_token;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [articleNo, { newData, originalData, articleName }] of entries) {
|
||||||
|
console.log('[handleSaveAll] Saving article:', articleNo);
|
||||||
|
const result = await saveRowToSupabase(articleNo, newData, token);
|
||||||
|
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Also save to history
|
||||||
|
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown', token);
|
||||||
|
|
||||||
|
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
|
||||||
|
setPendingRows(prev => {
|
||||||
|
const n = { ...prev };
|
||||||
|
delete n[articleNo];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error(`Failed to save ${articleNo} to Supabase`);
|
setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' }));
|
||||||
alert("Error saving to database. Local changes will be lost on refresh if not saved.");
|
failedArticles.push(`${articleNo} [${result.error || 'Unknown error'}]`);
|
||||||
|
if (result.error?.includes('401') || result.error?.includes('JWT expired')) {
|
||||||
|
jwtExpired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[handleSaveAll] Finished loop. Failed:', failedArticles.length);
|
||||||
|
|
||||||
|
if (jwtExpired) {
|
||||||
|
alert("Tu sesión ha caducado (JWT expired). Por favor, haz clic en el botón de Cerrar Sesión (Sign out) arriba a la derecha y vuelve a iniciar sesión para guardar tus cambios.");
|
||||||
|
} else if (failedArticles.length > 0) {
|
||||||
|
alert(`Failed to save items:\n\n${failedArticles.join('\n')}\n\nPlease try again.`);
|
||||||
|
} else {
|
||||||
|
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[handleSaveAll] Critical error:', err);
|
||||||
|
alert(`A critical error occurred while saving: ${err.message || err}\nCheck console for details.`);
|
||||||
|
} finally {
|
||||||
|
setIsSavingAll(false);
|
||||||
|
console.log('[handleSaveAll] isSavingAll set to false');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -255,8 +409,8 @@ export default function App() {
|
|||||||
data: JSON.parse(JSON.stringify(appState.data)), // Deep copy
|
data: JSON.parse(JSON.stringify(appState.data)), // Deep copy
|
||||||
message
|
message
|
||||||
};
|
};
|
||||||
// Keep only last 5 steps
|
// Keep last 50 steps
|
||||||
const newHistory = [newState, ...prev].slice(0, 5);
|
const newHistory = [newState, ...prev].slice(0, 50);
|
||||||
return newHistory;
|
return newHistory;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -305,6 +459,14 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [appState.data]);
|
}, [appState.data]);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return <LoginPage onLogin={() => {
|
||||||
|
const stored = getStoredSession();
|
||||||
|
console.log('[App] onLogin, stored session:', stored ? 'found' : 'null');
|
||||||
|
setSession(stored);
|
||||||
|
}} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-[#040d1a] text-slate-200 flex flex-col font-sans overflow-hidden">
|
<div className="h-screen bg-[#040d1a] text-slate-200 flex flex-col font-sans overflow-hidden">
|
||||||
<TopBar
|
<TopBar
|
||||||
@@ -318,9 +480,14 @@ export default function App() {
|
|||||||
onUndo={handleUndo}
|
onUndo={handleUndo}
|
||||||
undoMessage={undoHistory[0]?.message}
|
undoMessage={undoHistory[0]?.message}
|
||||||
undoSteps={undoHistory.length}
|
undoSteps={undoHistory.length}
|
||||||
|
pendingCount={Object.keys(pendingRows).length}
|
||||||
|
pendingChanges={Object.fromEntries(Object.entries(pendingRows).map(([k, v]) => [k, { articleName: (v as any).articleName }]))}
|
||||||
|
onSaveAll={handleSaveAll}
|
||||||
|
onRevertRow={handleRevertRow}
|
||||||
|
isSavingAll={isSavingAll}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<Sidebar activeModule={activeModule} setActiveModule={setActiveModule} />
|
<Sidebar activeModule={activeModule} setActiveModule={setActiveModule} userEmail={session.user.email} />
|
||||||
<main className="flex-1 overflow-auto relative p-6 bg-[#041021]">
|
<main className="flex-1 overflow-auto relative p-6 bg-[#041021]">
|
||||||
{isLoadingDefault ? (
|
{isLoadingDefault ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-slate-400">
|
<div className="flex flex-col items-center justify-center h-full text-slate-400">
|
||||||
@@ -348,11 +515,14 @@ export default function App() {
|
|||||||
{activeModule === 'descriptions' && (
|
{activeModule === 'descriptions' && (
|
||||||
<ProductDescriptions
|
<ProductDescriptions
|
||||||
data={appState.data}
|
data={appState.data}
|
||||||
|
headers={appState.headers}
|
||||||
|
asinColumnIndex={appState.asinColumnIndex}
|
||||||
onEdit={(index) => setEditingRowIndex(index)}
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
|
rowStatuses={rowStatuses}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeModule === 'matrix' && (
|
{activeModule === 'matrix' && (
|
||||||
<MatrixView data={appState.data} headers={appState.headers} />
|
<MatrixView data={appState.data} headers={appState.headers} rowStatuses={rowStatuses} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeModule === 'dimensions' && (
|
{activeModule === 'dimensions' && (
|
||||||
@@ -362,6 +532,8 @@ export default function App() {
|
|||||||
onEdit={(index) => setEditingRowIndex(index)}
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
onSaveRow={handleSaveRow}
|
onSaveRow={handleSaveRow}
|
||||||
onCaptureState={captureState}
|
onCaptureState={captureState}
|
||||||
|
rowStatuses={rowStatuses}
|
||||||
|
onRevertRow={handleRevertRow}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -372,12 +544,65 @@ export default function App() {
|
|||||||
onSaveRow={handleSaveRow}
|
onSaveRow={handleSaveRow}
|
||||||
onCaptureState={captureState}
|
onCaptureState={captureState}
|
||||||
onEdit={(index) => setEditingRowIndex(index)}
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
|
rowStatuses={rowStatuses}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeModule === 'article_details' && (
|
{activeModule === 'article_details' && (
|
||||||
<ArticleDetails
|
<ArticleDetails
|
||||||
data={appState.data}
|
data={appState.data}
|
||||||
onEdit={(index) => setEditingRowIndex(index)}
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
|
rowStatuses={rowStatuses}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeModule === 'pending_validation' && (
|
||||||
|
<PendingValidationView
|
||||||
|
data={appState.data}
|
||||||
|
pendingRows={pendingRows}
|
||||||
|
rowStatuses={rowStatuses}
|
||||||
|
onRevertRow={handleRevertRow}
|
||||||
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeModule === 'missing_data' && (
|
||||||
|
<MissingDataView
|
||||||
|
data={appState.data}
|
||||||
|
headers={appState.headers}
|
||||||
|
onSaveRow={handleSaveRow}
|
||||||
|
onCaptureState={captureState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeModule === 'history' && (
|
||||||
|
<HistoryView
|
||||||
|
headers={appState.headers}
|
||||||
|
data={appState.data}
|
||||||
|
sessionToken={session?.access_token}
|
||||||
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
|
onRevert={async (articleNo, revertedData, historyId) => {
|
||||||
|
// Find the row in appState.data and update it
|
||||||
|
const rowIndex = appState.data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === articleNo);
|
||||||
|
if (rowIndex !== -1) {
|
||||||
|
setAppState(prev => {
|
||||||
|
const newData = [...prev.data];
|
||||||
|
newData[rowIndex] = revertedData;
|
||||||
|
return { ...prev, data: newData, hasUnsavedChanges: true };
|
||||||
|
});
|
||||||
|
// Mark as pending for sync
|
||||||
|
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
|
||||||
|
setPendingRows(prev => ({
|
||||||
|
...prev,
|
||||||
|
[articleNo]: {
|
||||||
|
rowIndex,
|
||||||
|
originalData: appState.data[rowIndex],
|
||||||
|
newData: revertedData,
|
||||||
|
articleName: String(revertedData[COLUMNS.ARTICLE_NAME] || articleNo),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
// Delete the history entry after revert
|
||||||
|
if (historyId) {
|
||||||
|
await deleteHistoryEntry(String(historyId), session?.access_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -388,7 +613,12 @@ export default function App() {
|
|||||||
<UndoToast
|
<UndoToast
|
||||||
undoState={undoHistory[0] || null}
|
undoState={undoHistory[0] || null}
|
||||||
onUndo={handleUndo}
|
onUndo={handleUndo}
|
||||||
onClose={() => setUndoHistory([])}
|
onClose={() => {
|
||||||
|
// Instead of clearing history, we can just hide the toast
|
||||||
|
// But since UndoToast is driven by undoHistory[0],
|
||||||
|
// we might want a way to "acknowledge" the current top of history
|
||||||
|
// For now, let's just not clear the history.
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{editingRowIndex !== null && (
|
{editingRowIndex !== null && (
|
||||||
|
|||||||
@@ -1,26 +1,35 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
import { ExcelRow, COLUMNS } from '../types';
|
||||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X as XIcon } from 'lucide-react';
|
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
|
|
||||||
interface ArticleDetailsProps {
|
interface ArticleDetailsProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
|
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
|
||||||
|
|
||||||
export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProps) {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [lineFilter, setLineFilter] = useState('');
|
const [lineFilter, setLineFilter] = useState('');
|
||||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||||
const [sortDesc, setSortDesc] = useState(false);
|
const [sortDesc, setSortDesc] = useState(false);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(100);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||||
|
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
||||||
|
[COLUMNS.ARTICLE_NO]: 100,
|
||||||
|
[COLUMNS.ARTICLE_NAME]: 200,
|
||||||
|
[COLUMNS.CLASSIFICATION]: 80,
|
||||||
|
[COLUMNS.ITEM_AVAILABLE]: 80,
|
||||||
|
[COLUMNS.DETAILS_DE]: 150,
|
||||||
|
[COLUMNS.DETAILS_EN]: 150,
|
||||||
|
});
|
||||||
|
|
||||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||||
|
|
||||||
@@ -70,7 +79,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [data, activeTab, search, lineFilter, sortCol, sortDesc]);
|
}, [data, activeTab, search, lineFilter, columnFilters, sortCol, sortDesc]);
|
||||||
|
|
||||||
const paginatedData = useMemo(() => {
|
const paginatedData = useMemo(() => {
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize;
|
||||||
@@ -88,6 +97,25 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResize = (colIndex: number, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const startX = e.pageX;
|
||||||
|
const startWidth = columnWidths[colIndex] || 100;
|
||||||
|
|
||||||
|
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
|
||||||
|
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
const getUniqueValues = (col: number) => {
|
const getUniqueValues = (col: number) => {
|
||||||
const values = data.map(r => String(r[col] || ''));
|
const values = data.map(r => String(r[col] || ''));
|
||||||
return Array.from(new Set(values)).sort();
|
return Array.from(new Set(values)).sort();
|
||||||
@@ -157,8 +185,16 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
placeholder="Search SKU or Name..."
|
placeholder="Search SKU or Name..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-9 pr-4 py-2 bg-slate-900/50 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
className="w-full pl-9 pr-10 py-2 bg-slate-900/50 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||||
/>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearch(''); setPage(1); }}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||||
<button
|
<button
|
||||||
@@ -168,7 +204,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
||||||
>
|
>
|
||||||
<XIcon className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
Clear All Column Filters
|
Clear All Column Filters
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -184,7 +220,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
|
|
||||||
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||||
<div className="overflow-x-auto flex-1">
|
<div className="overflow-x-auto flex-1">
|
||||||
<table className="w-full text-left text-xs">
|
<table className="w-full text-left text-xs" style={{ tableLayout: 'fixed' }}>
|
||||||
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
|
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
{[
|
{[
|
||||||
@@ -197,29 +233,36 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
].map(({ col, label }) => (
|
].map(({ col, label }) => (
|
||||||
<th
|
<th
|
||||||
key={col}
|
key={col}
|
||||||
className="px-3 py-3 font-medium transition-colors select-none group relative"
|
className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
|
||||||
|
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center overflow-hidden">
|
||||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}>
|
<span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
||||||
{label}
|
{label}
|
||||||
{sortCol === col && (
|
{sortCol === col && (
|
||||||
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1 rounded hover:bg-slate-700 transition-colors",
|
"p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
|
||||||
(columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
(columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Filter className="w-3.5 h-3.5" />
|
<Filter className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resizer handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={(e) => handleResize(col, e)}
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
|
||||||
|
/>
|
||||||
|
|
||||||
{openFilterCol === col && (
|
{openFilterCol === col && (
|
||||||
<ColumnFilterPopover
|
<ColumnFilterPopover
|
||||||
uniqueValues={getUniqueValues(col)}
|
uniqueValues={getUniqueValues(col)}
|
||||||
@@ -239,17 +282,26 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
<th className="px-3 py-3 font-medium text-right">Edit</th>
|
<th className="px-3 py-3 font-medium text-right" style={{ width: 80 }}>Edit</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-700/30">
|
<tbody className="divide-y divide-slate-700/30">
|
||||||
{paginatedData.map(({ row, index }) => (
|
{paginatedData.map(({ row, index }) => {
|
||||||
<tr key={index} className="hover:bg-slate-700/20 transition-colors">
|
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||||
<td className="px-3 py-2 font-mono text-indigo-400">{row[COLUMNS.ARTICLE_NO]}</td>
|
return (
|
||||||
<td className="px-3 py-2 font-medium text-slate-200 max-w-[150px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-slate-700/20 transition-colors",
|
||||||
|
saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
|
||||||
|
saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
||||||
|
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>
|
||||||
{row[COLUMNS.ARTICLE_NAME]}
|
{row[COLUMNS.ARTICLE_NAME]}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
|
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
|
||||||
String(row[COLUMNS.CLASSIFICATION]).includes('OOC') ? "bg-amber-500/10 text-amber-500 border-amber-500/20" : "bg-slate-700/50 text-slate-400 border-slate-600/50"
|
String(row[COLUMNS.CLASSIFICATION]).includes('OOC') ? "bg-amber-500/10 text-amber-500 border-amber-500/20" : "bg-slate-700/50 text-slate-400 border-slate-600/50"
|
||||||
@@ -257,7 +309,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.ITEM_AVAILABLE] }}>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"font-mono font-bold",
|
"font-mono font-bold",
|
||||||
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
|
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
|
||||||
@@ -265,14 +317,14 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
{row[COLUMNS.ITEM_AVAILABLE] || 0}
|
{row[COLUMNS.ITEM_AVAILABLE] || 0}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_DE] }}>
|
||||||
{row[COLUMNS.DETAILS_DE] ? (
|
{row[COLUMNS.DETAILS_DE] ? (
|
||||||
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
|
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
|
||||||
) : getBadge(null)}
|
) : getBadge(null)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_EN] }}>
|
||||||
{row[COLUMNS.DETAILS_EN] ? (
|
{row[COLUMNS.DETAILS_EN] ? (
|
||||||
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
|
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
|
||||||
) : getBadge(null)}
|
) : getBadge(null)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
@@ -284,7 +336,8 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{paginatedData.length === 0 && (
|
{paginatedData.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
|
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
|
||||||
|
|||||||
@@ -34,10 +34,13 @@ export function ColumnFilterPopover({
|
|||||||
const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0;
|
const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
|
className={cn(
|
||||||
"absolute top-full left-0 mt-1 w-64 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100",
|
"absolute top-full left-0 mt-1 w-64 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100",
|
||||||
className
|
className
|
||||||
)}>
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
|
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -47,28 +50,38 @@ export function ColumnFilterPopover({
|
|||||||
placeholder="Filter values..."
|
placeholder="Filter values..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 text-xs text-white focus:outline-none focus:border-blue-500"
|
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 pr-7 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar">
|
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar">
|
||||||
{filteredValues.map(val => (
|
{filteredValues.map(val => (
|
||||||
<label key={val} className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group">
|
<div
|
||||||
|
key={val}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={selectedValues.includes(val)}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onToggle(val)}
|
||||||
|
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); } }}
|
||||||
|
className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group"
|
||||||
|
>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
|
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
|
||||||
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
|
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
|
||||||
)}>
|
)}>
|
||||||
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
|
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="hidden"
|
|
||||||
checked={selectedValues.includes(val)}
|
|
||||||
onChange={() => onToggle(val)}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
|
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
|
||||||
</label>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filteredValues.length === 0 && (
|
{filteredValues.length === 0 && (
|
||||||
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
|
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
import { ExcelRow, COLUMNS } from '../types';
|
||||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X as XIcon } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X, Undo2 } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
@@ -11,6 +11,8 @@ interface DimensionsViewProps {
|
|||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
|
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
|
||||||
onCaptureState: (message: string) => void;
|
onCaptureState: (message: string) => void;
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
|
onRevertRow: (articleNo: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DimensionGroup {
|
interface DimensionGroup {
|
||||||
@@ -32,7 +34,7 @@ interface NearDuplicateCluster {
|
|||||||
maxDiffPct: number;
|
maxDiffPct: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState }: DimensionsViewProps) {
|
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState, rowStatuses, onRevertRow }: DimensionsViewProps) {
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
|
const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
|
||||||
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
|
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
|
||||||
@@ -45,7 +47,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
|
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
|
||||||
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
|
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
|
||||||
const [pendingNearDupSync, setPendingNearDupSync] = useState<{
|
const [pendingNearDupSync, setPendingNearDupSync] = useState<{
|
||||||
clusterIndex: number;
|
clusterKey: string;
|
||||||
targetGroupKey: string;
|
targetGroupKey: string;
|
||||||
selectedIndices: number[];
|
selectedIndices: number[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -100,12 +102,14 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
const parts = key.split('x').map(Number);
|
const parts = key.split('x').map(Number);
|
||||||
const volume = parts[0] * parts[1] * parts[2];
|
const volume = parts[0] * parts[1] * parts[2];
|
||||||
|
|
||||||
|
const isVerified = rows.some(({ row }) => row[COLUMNS.VERIFIED_DIMS] === true);
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
key,
|
key,
|
||||||
innerDims: key,
|
innerDims: key,
|
||||||
rows,
|
rows,
|
||||||
volume,
|
volume,
|
||||||
isInconsistent: !outerMatch || !unitsMatch || !moqMatch,
|
isInconsistent: (!outerMatch || !unitsMatch || !moqMatch) && !isVerified,
|
||||||
discrepancies: {
|
discrepancies: {
|
||||||
outer: !outerMatch,
|
outer: !outerMatch,
|
||||||
units: !unitsMatch,
|
units: !unitsMatch,
|
||||||
@@ -119,7 +123,12 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
|
|
||||||
const filteredGroups = useMemo(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
let result = groups;
|
let result = groups;
|
||||||
if (showOnlyInconsistent) result = result.filter(g => g.isInconsistent);
|
if (showOnlyInconsistent) {
|
||||||
|
result = result.filter(g =>
|
||||||
|
g.isInconsistent ||
|
||||||
|
g.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (search || lineFilter.length > 0 || classFilter.length > 0) {
|
if (search || lineFilter.length > 0 || classFilter.length > 0) {
|
||||||
const s = search.toLowerCase();
|
const s = search.toLowerCase();
|
||||||
@@ -136,7 +145,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter]);
|
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter, rowStatuses]);
|
||||||
|
|
||||||
const uniqueLines = useMemo(() =>
|
const uniqueLines = useMemo(() =>
|
||||||
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
|
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
|
||||||
@@ -214,11 +223,22 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
setPendingAction({ group, sourceRow, fieldType: 'all' });
|
setPendingAction({ group, sourceRow, fieldType: 'all' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVerifyGroup = (e: React.MouseEvent, group: DimensionGroup) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
group.rows.forEach(({ row, index }) => {
|
||||||
|
const newRow = [...row];
|
||||||
|
newRow[COLUMNS.VERIFIED_DIMS] = true;
|
||||||
|
onSaveRow(index, newRow);
|
||||||
|
});
|
||||||
|
onCaptureState(`Verified dimensions for ${group.rows.length} products`);
|
||||||
|
};
|
||||||
|
|
||||||
const executeNearDupSync = async () => {
|
const executeNearDupSync = async () => {
|
||||||
if (!pendingNearDupSync) return;
|
if (!pendingNearDupSync) return;
|
||||||
const { clusterIndex, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
||||||
|
|
||||||
const cluster = nearDuplicateClusters[clusterIndex];
|
const cluster = nearDuplicateClusters.find(c => c.groups[0].key === clusterKey);
|
||||||
|
if (!cluster) return;
|
||||||
const targetGroup = cluster.groups.find(g => g.key === targetGroupKey);
|
const targetGroup = cluster.groups.find(g => g.key === targetGroupKey);
|
||||||
if (!targetGroup) return;
|
if (!targetGroup) return;
|
||||||
|
|
||||||
@@ -241,7 +261,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
|
|
||||||
setClusterSelections(prev => {
|
setClusterSelections(prev => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[clusterIndex];
|
delete next[pendingNearDupSync.clusterKey];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -299,8 +319,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
placeholder="Search SKU or Name in groups..."
|
placeholder="Search SKU or Name in groups..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500"
|
className="w-full pl-9 pr-10 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -360,7 +388,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
className="p-2 text-red-400 hover:text-red-300 transition-colors"
|
className="p-2 text-red-400 hover:text-red-300 transition-colors"
|
||||||
title="Clear all filters"
|
title="Clear all filters"
|
||||||
>
|
>
|
||||||
<XIcon className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -394,11 +422,12 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
</div>
|
</div>
|
||||||
{nearDuplicateClusters.map((cluster, ci) => {
|
{nearDuplicateClusters.map((cluster, ci) => {
|
||||||
const allClusterRows = cluster.groups.flatMap(g => g.rows);
|
const allClusterRows = cluster.groups.flatMap(g => g.rows);
|
||||||
const selection = clusterSelections[ci] ?? new Set<number>();
|
const clusterKey = cluster.groups[0].key;
|
||||||
const targetKey = clusterSyncTargets[ci] ?? cluster.groups[0].key;
|
const selection = clusterSelections[clusterKey] ?? new Set<number>();
|
||||||
|
const targetKey = clusterSyncTargets[clusterKey] ?? cluster.groups[0].key;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
<div key={clusterKey} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = new Set(expandedNearDuplicates);
|
const next = new Set(expandedNearDuplicates);
|
||||||
@@ -422,12 +451,11 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
|
|
||||||
{expandedNearDuplicates.has(ci) && (
|
{expandedNearDuplicates.has(ci) && (
|
||||||
<div className="border-t border-violet-500/20">
|
<div className="border-t border-violet-500/20">
|
||||||
{/* Sync toolbar */}
|
|
||||||
<div className="flex items-center gap-3 px-4 py-2.5 bg-violet-500/5 border-b border-violet-500/10 flex-wrap">
|
<div className="flex items-center gap-3 px-4 py-2.5 bg-violet-500/5 border-b border-violet-500/10 flex-wrap">
|
||||||
<span className="text-xs text-slate-400">Sync selected to:</span>
|
<span className="text-xs text-slate-400">Sync selected to:</span>
|
||||||
<select
|
<select
|
||||||
value={targetKey}
|
value={targetKey}
|
||||||
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [ci]: e.target.value }))}
|
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [clusterKey]: e.target.value }))}
|
||||||
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-violet-500"
|
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-violet-500"
|
||||||
>
|
>
|
||||||
{cluster.groups.map(g => {
|
{cluster.groups.map(g => {
|
||||||
@@ -441,21 +469,21 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
disabled={selection.size === 0}
|
disabled={selection.size === 0}
|
||||||
onClick={() => setPendingNearDupSync({ clusterIndex: ci, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
|
onClick={() => setPendingNearDupSync({ clusterKey, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
|
||||||
className="flex items-center gap-1.5 px-3 py-1 bg-violet-600/20 text-violet-400 hover:bg-violet-600 hover:text-white rounded text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex items-center gap-1.5 px-3 py-1 bg-violet-600/20 text-violet-400 hover:bg-violet-600 hover:text-white rounded text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Layers className="w-3 h-3" />
|
<Layers className="w-3 h-3" />
|
||||||
Sync {selection.size} selected
|
Sync {selection.size} selected
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set(allClusterRows.map(r => r.index)) }))}
|
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set(allClusterRows.map(r => r.index)) }))}
|
||||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
>
|
>
|
||||||
Select all
|
Select all
|
||||||
</button>
|
</button>
|
||||||
{selection.size > 0 && (
|
{selection.size > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set() }))}
|
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set() }))}
|
||||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
@@ -463,7 +491,6 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Flat product list */}
|
|
||||||
<div className="divide-y divide-violet-500/10">
|
<div className="divide-y divide-violet-500/10">
|
||||||
{allClusterRows.map(({ row, index }) => {
|
{allClusterRows.map(({ row, index }) => {
|
||||||
const isSelected = selection.has(index);
|
const isSelected = selection.has(index);
|
||||||
@@ -475,17 +502,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
isSelected && "bg-violet-500/10"
|
isSelected && "bg-violet-500/10"
|
||||||
)}
|
)}
|
||||||
onClick={() => setClusterSelections(prev => {
|
onClick={() => setClusterSelections(prev => {
|
||||||
const current = new Set(prev[ci] ?? []);
|
const current = new Set(prev[clusterKey] ?? []);
|
||||||
if (current.has(index)) current.delete(index); else current.add(index);
|
if (current.has(index)) current.delete(index); else current.add(index);
|
||||||
return { ...prev, [ci]: current };
|
return { ...prev, [clusterKey]: current };
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={() => {}}
|
readOnly
|
||||||
onClick={e => e.stopPropagation()}
|
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
|
||||||
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0"
|
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -509,9 +535,12 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filteredGroups.map(group => (
|
{filteredGroups.map(group => {
|
||||||
|
const hasPending = group.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending');
|
||||||
|
return (
|
||||||
<div key={group.key} className={cn(
|
<div key={group.key} className={cn(
|
||||||
"border rounded-lg overflow-hidden transition-all",
|
"border rounded-lg overflow-hidden transition-all",
|
||||||
|
hasPending ? "border-yellow-500/50 bg-yellow-500/5 ring-1 ring-yellow-500/20" :
|
||||||
group.isInconsistent ? "border-amber-500/30 bg-amber-500/5" : "border-slate-700 bg-slate-800/30"
|
group.isInconsistent ? "border-amber-500/30 bg-amber-500/5" : "border-slate-700 bg-slate-800/30"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center justify-between bg-slate-800/20 pr-4">
|
<div className="flex items-center justify-between bg-slate-800/20 pr-4">
|
||||||
@@ -525,17 +554,22 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
<span className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-0.5 rounded">
|
<span className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-0.5 rounded">
|
||||||
Inner: {group.innerDims} cm
|
Inner: {group.innerDims} cm
|
||||||
</span>
|
</span>
|
||||||
|
{hasPending && (
|
||||||
|
<span className="flex items-center gap-1 text-xs font-medium text-yellow-500 bg-yellow-500/10 px-2 py-0.5 rounded ring-1 ring-yellow-500/20">
|
||||||
|
Pending Validation
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{group.isInconsistent ? (
|
{group.isInconsistent ? (
|
||||||
<span className="flex items-center gap-1 text-xs font-medium text-amber-500 bg-amber-500/10 px-2 py-0.5 rounded ring-1 ring-amber-500/20">
|
<span className="flex items-center gap-1 text-xs font-medium text-amber-500 bg-amber-500/10 px-2 py-0.5 rounded ring-1 ring-amber-500/20">
|
||||||
<AlertTriangle className="w-3 h-3" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
Inconsistent
|
Inconsistent
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : !hasPending ? (
|
||||||
<span className="flex items-center gap-1 text-xs font-medium text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded ring-1 ring-emerald-500/20">
|
<span className="flex items-center gap-1 text-xs font-medium text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded ring-1 ring-emerald-500/20">
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
Consistent
|
Consistent
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 mt-1">
|
<div className="text-xs text-slate-500 mt-1">
|
||||||
{group.rows.length} product{group.rows.length !== 1 ? 's' : ''} in this dimension group
|
{group.rows.length} product{group.rows.length !== 1 ? 's' : ''} in this dimension group
|
||||||
@@ -562,6 +596,17 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{group.isInconsistent && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleVerifyGroup(e, group)}
|
||||||
|
className="px-2 py-1 bg-emerald-600/10 hover:bg-emerald-600 hover:text-white text-emerald-500 rounded border border-emerald-600/30 text-[10px] font-bold transition-colors flex items-center gap-1"
|
||||||
|
title="Mark as correct to hide from inconsistent list"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
Mark Correct
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{group.isInconsistent && !expandedGroups.has(group.key) && (
|
{group.isInconsistent && !expandedGroups.has(group.key) && (
|
||||||
<div className="text-[10px] font-bold text-blue-400 bg-blue-400/5 px-2 py-1 rounded border border-blue-400/20">
|
<div className="text-[10px] font-bold text-blue-400 bg-blue-400/5 px-2 py-1 rounded border border-blue-400/20">
|
||||||
OPEN TO SYNC FIELDS
|
OPEN TO SYNC FIELDS
|
||||||
@@ -584,14 +629,22 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-700/50">
|
<tbody className="divide-y divide-slate-700/50">
|
||||||
{group.rows.map(({ row, index }) => (
|
{group.rows.map(({ row, index }) => {
|
||||||
<tr key={index} className="hover:bg-slate-700/20 group transition-colors">
|
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-slate-700/20 group transition-colors",
|
||||||
|
isPending ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="font-medium text-slate-200">{row[COLUMNS.ARTICLE_NO]}</div>
|
<div className="font-medium text-slate-200">{row[COLUMNS.ARTICLE_NO]}</div>
|
||||||
<div className="text-[10px] text-slate-500 truncate max-w-[200px]">{row[COLUMNS.ARTICLE_NAME]}</div>
|
<div className="text-[10px] text-slate-500 truncate max-w-[200px]">{row[COLUMNS.ARTICLE_NAME]}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-400 font-mono">
|
<td className="px-4 py-3 text-slate-400 font-mono">
|
||||||
{row[COLUMNS.INNER_L] || '-'} × {row[COLUMNS.INNER_W] || '-'} × {row[COLUMNS.INNER_H] || '-'}
|
{row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? row[COLUMNS.INNER_L] : '-'} × {row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? row[COLUMNS.INNER_W] : '-'} × {row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? row[COLUMNS.INNER_H] : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className={cn(
|
<td className={cn(
|
||||||
"px-4 py-3 font-mono",
|
"px-4 py-3 font-mono",
|
||||||
@@ -606,7 +659,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
>
|
>
|
||||||
{syncing?.key === group.key && syncing?.field === 'outer' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
|
{syncing?.key === group.key && syncing?.field === 'outer' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
|
||||||
</button>
|
</button>
|
||||||
<span>{row[COLUMNS.OUTER_L] || '-'} × {row[COLUMNS.OUTER_W] || '-'} × {row[COLUMNS.OUTER_H] || '-'}</span>
|
<span>{row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? row[COLUMNS.OUTER_L] : '-'} × {row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? row[COLUMNS.OUTER_W] : '-'} × {row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? row[COLUMNS.OUTER_H] : '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={cn(
|
<td className={cn(
|
||||||
@@ -622,7 +675,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
>
|
>
|
||||||
{syncing?.key === group.key && syncing?.field === 'units' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
|
{syncing?.key === group.key && syncing?.field === 'units' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
|
||||||
</button>
|
</button>
|
||||||
<span>{row[COLUMNS.UNITS_OUTER] || '-'}</span>
|
<span>{row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? row[COLUMNS.UNITS_OUTER] : '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={cn(
|
<td className={cn(
|
||||||
@@ -638,36 +691,45 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
>
|
>
|
||||||
{syncing?.key === group.key && syncing?.field === 'moq' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
|
{syncing?.key === group.key && syncing?.field === 'moq' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
|
||||||
</button>
|
</button>
|
||||||
<span>{row[COLUMNS.MOQ] || '-'}</span>
|
<span>{row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? row[COLUMNS.MOQ] : '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleFullSync(group, row)}
|
onClick={() => handleFullSync(group, row)}
|
||||||
title="FULL SYNC: Apply ALL packaging measures labels to all in group"
|
title="FULL SYNC: Apply ALL packaging measures labels to all in group"
|
||||||
disabled={syncing?.key === group.key}
|
disabled={syncing?.key === group.key}
|
||||||
className="p-1.5 hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 rounded transition-all"
|
className="p-1.5 hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 rounded transition-all opacity-0 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
{syncing?.key === group.key && syncing?.field === 'all' ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
{syncing?.key === group.key && syncing?.field === 'all' ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRevertRow(String(row[COLUMNS.ARTICLE_NO]))}
|
||||||
|
title="Undo pending changes"
|
||||||
|
className="p-1.5 hover:bg-red-600/20 text-yellow-500 hover:text-red-400 rounded transition-all"
|
||||||
|
>
|
||||||
|
<Undo2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(index)}
|
onClick={() => onEdit(index)}
|
||||||
title="Edit product"
|
title="Edit product"
|
||||||
className="p-1.5 hover:bg-slate-600/20 text-slate-500 hover:text-slate-300 rounded transition-all"
|
className="p-1.5 hover:bg-slate-600/20 text-slate-500 hover:text-slate-300 rounded transition-all opacity-0 group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{filteredGroups.length === 0 && (
|
{filteredGroups.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 bg-slate-800/20 border border-dashed border-slate-700 rounded-xl">
|
<div className="flex flex-col items-center justify-center py-20 bg-slate-800/20 border border-dashed border-slate-700 rounded-xl">
|
||||||
|
|||||||
+210
-84
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
import { ExcelRow, COLUMNS } from '../types';
|
||||||
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2 } from 'lucide-react';
|
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2, Mic, MicOff } from 'lucide-react';
|
||||||
import { generateGemini } from '../services/gemini';
|
import { generateGemini } from '../services/gemini';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { useSpeechRecognition } from '../lib/useSpeechRecognition';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
|
|
||||||
interface EditPanelProps {
|
interface EditPanelProps {
|
||||||
@@ -13,6 +14,117 @@ interface EditPanelProps {
|
|||||||
onCaptureState: (message: string) => void;
|
onCaptureState: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DimensionInputProps {
|
||||||
|
label: string;
|
||||||
|
field: string;
|
||||||
|
value: string;
|
||||||
|
isModified: boolean;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DimensionInput = ({ label, value, isModified, onChange, placeholder }: DimensionInputProps) => (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">{label}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
|
||||||
|
isModified ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface FieldEditorProps {
|
||||||
|
title: string;
|
||||||
|
field: string;
|
||||||
|
value: string;
|
||||||
|
isModified: boolean;
|
||||||
|
canTranslate: boolean;
|
||||||
|
isGenerated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
onGenerate: () => void;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
|
isListening?: boolean;
|
||||||
|
onToggleVoice?: () => void;
|
||||||
|
voiceSupported?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldEditor = ({
|
||||||
|
title,
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
isModified,
|
||||||
|
canTranslate,
|
||||||
|
isGenerated,
|
||||||
|
isLoading,
|
||||||
|
onGenerate,
|
||||||
|
onChange,
|
||||||
|
onKeyDown,
|
||||||
|
isListening,
|
||||||
|
onToggleVoice,
|
||||||
|
voiceSupported
|
||||||
|
}: FieldEditorProps) => (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-slate-300">{title}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{canTranslate && (
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-slate-500 italic">
|
||||||
|
<Languages className="w-3 h-3" />
|
||||||
|
Can translate from existing
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{voiceSupported && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleVoice}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded transition-colors",
|
||||||
|
isListening
|
||||||
|
? "bg-red-600/20 text-red-400 animate-pulse"
|
||||||
|
: "bg-slate-700/50 text-slate-400 hover:bg-slate-600 hover:text-white"
|
||||||
|
)}
|
||||||
|
title={isListening ? "Stop recording" : "Voice input"}
|
||||||
|
>
|
||||||
|
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
|
||||||
|
{isListening ? 'Stop' : 'Voice'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onGenerate}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-2 py-1 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
||||||
|
{canTranslate ? 'Translate with Gemini' : 'Generate with Gemini'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isGenerated && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
AI Generated - You can still edit manually
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id={`field-${field}`}
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-32 bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
|
||||||
|
isModified ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||||
|
)}
|
||||||
|
placeholder={`Enter ${title}...`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: EditPanelProps) {
|
export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: EditPanelProps) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
longDe: row[COLUMNS.LONG_DE] || '',
|
longDe: row[COLUMNS.LONG_DE] || '',
|
||||||
@@ -21,14 +133,14 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
shortEn: row[COLUMNS.SHORT_EN] || '',
|
shortEn: row[COLUMNS.SHORT_EN] || '',
|
||||||
detailsDe: row[COLUMNS.DETAILS_DE] || '',
|
detailsDe: row[COLUMNS.DETAILS_DE] || '',
|
||||||
detailsEn: row[COLUMNS.DETAILS_EN] || '',
|
detailsEn: row[COLUMNS.DETAILS_EN] || '',
|
||||||
innerW: row[COLUMNS.INNER_W] || '',
|
innerW: row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? String(row[COLUMNS.INNER_W]) : '',
|
||||||
innerL: row[COLUMNS.INNER_L] || '',
|
innerL: row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? String(row[COLUMNS.INNER_L]) : '',
|
||||||
innerH: row[COLUMNS.INNER_H] || '',
|
innerH: row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? String(row[COLUMNS.INNER_H]) : '',
|
||||||
outerW: row[COLUMNS.OUTER_W] || '',
|
outerW: row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? String(row[COLUMNS.OUTER_W]) : '',
|
||||||
outerL: row[COLUMNS.OUTER_L] || '',
|
outerL: row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? String(row[COLUMNS.OUTER_L]) : '',
|
||||||
outerH: row[COLUMNS.OUTER_H] || '',
|
outerH: row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? String(row[COLUMNS.OUTER_H]) : '',
|
||||||
unitsOuter: row[COLUMNS.UNITS_OUTER] || '',
|
unitsOuter: row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? String(row[COLUMNS.UNITS_OUTER]) : '',
|
||||||
moq: row[COLUMNS.MOQ] || '',
|
moq: row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? String(row[COLUMNS.MOQ]) : '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [loadingField, setLoadingField] = useState<string | null>(null);
|
const [loadingField, setLoadingField] = useState<string | null>(null);
|
||||||
@@ -37,6 +149,17 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
|
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
|
||||||
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
|
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const handleSpeechResult = useCallback((transcript: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, longDe: prev.longDe + ' ' + transcript }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { isListening: isListeningDe, start: startListeningDe, stop: stopListeningDe, isSupported: speechSupported } = useSpeechRecognition({ onResult: handleSpeechResult });
|
||||||
|
|
||||||
|
const handleSpeechResultEn = useCallback((transcript: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, longEn: prev.longEn + ' ' + transcript }));
|
||||||
|
}, []);
|
||||||
|
const { isListening: isListeningEn, start: startListeningEn, stop: stopListeningEn } = useSpeechRecognition({ onResult: handleSpeechResultEn, lang: 'en-US' });
|
||||||
|
|
||||||
const isModified = (field: keyof typeof formData) => {
|
const isModified = (field: keyof typeof formData) => {
|
||||||
const colMap: Record<string, number> = {
|
const colMap: Record<string, number> = {
|
||||||
longDe: COLUMNS.LONG_DE,
|
longDe: COLUMNS.LONG_DE,
|
||||||
@@ -56,7 +179,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
};
|
};
|
||||||
const colIndex = (colMap as Record<string, number>)[field as string];
|
const colIndex = (colMap as Record<string, number>)[field as string];
|
||||||
if (colIndex === undefined) return false;
|
if (colIndex === undefined) return false;
|
||||||
return formData[field] !== (row[colIndex] || '');
|
return formData[field] !== (row[colIndex] !== undefined && row[colIndex] !== null ? String(row[colIndex]) : '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerate = async (field: keyof typeof formData) => {
|
const handleGenerate = async (field: keyof typeof formData) => {
|
||||||
@@ -74,7 +197,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
let prompt = '';
|
let prompt = '';
|
||||||
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${formData.detailsEn || 'N/A'}\nArticle Details (DE): ${formData.detailsDe || 'N/A'}`;
|
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${formData.detailsEn || 'N/A'}\nArticle Details (DE): ${formData.detailsDe || 'N/A'}`;
|
||||||
|
|
||||||
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. You excel at translating product descriptions between German and English, maintaining the commercial and professional tone while ensuring all technical toy details are accurate. Use clear, engaging language.";
|
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. CRITICAL: Output ONLY the translated or generated content. Do not include any introductions, conclusions, or conversational text. Translate EVERYTHING, including phrases in ALL CAPS (maintain the all-caps casing for those phrases in the translation). Your response must contain only the final product description.";
|
||||||
|
|
||||||
if (field === 'longDe') {
|
if (field === 'longDe') {
|
||||||
if (formData.longEn) {
|
if (formData.longEn) {
|
||||||
@@ -84,6 +207,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
2. Maintain the EXACT same length and detailed information.
|
2. Maintain the EXACT same length and detailed information.
|
||||||
3. Keep all technical specs intact.
|
3. Keep all technical specs intact.
|
||||||
4. Translate every single paragraph into natural, commercial German for toy buyers.
|
4. Translate every single paragraph into natural, commercial German for toy buyers.
|
||||||
|
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
|
||||||
|
|
||||||
English Text to Translate:
|
English Text to Translate:
|
||||||
${formData.longEn}`;
|
${formData.longEn}`;
|
||||||
@@ -98,6 +222,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
2. Maintain the EXACT same length and detailed information.
|
2. Maintain the EXACT same length and detailed information.
|
||||||
3. Keep all technical specs intact.
|
3. Keep all technical specs intact.
|
||||||
4. Translate every single paragraph into natural, commercial English for toy buyers.
|
4. Translate every single paragraph into natural, commercial English for toy buyers.
|
||||||
|
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
|
||||||
|
|
||||||
German Text to Translate:
|
German Text to Translate:
|
||||||
${formData.longDe}`;
|
${formData.longDe}`;
|
||||||
@@ -106,7 +231,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
}
|
}
|
||||||
} else if (field === 'shortDe') {
|
} else if (field === 'shortDe') {
|
||||||
if (formData.shortEn) {
|
if (formData.shortEn) {
|
||||||
prompt = `Translate exactly this short English product description into professional German for the toy market:\n\n${formData.shortEn}`;
|
prompt = `Translate exactly this short English product description into professional German for the toy market. IMPORTANT: Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting:\n\n${formData.shortEn}`;
|
||||||
} else if (formData.longDe) {
|
} else if (formData.longDe) {
|
||||||
const targetChars = Math.round(formData.longDe.length * 0.3);
|
const targetChars = Math.round(formData.longDe.length * 0.3);
|
||||||
prompt = `Create a concise summary of the following German product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longDe}`;
|
prompt = `Create a concise summary of the following German product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longDe}`;
|
||||||
@@ -115,7 +240,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
}
|
}
|
||||||
} else if (field === 'shortEn') {
|
} else if (field === 'shortEn') {
|
||||||
if (formData.shortDe) {
|
if (formData.shortDe) {
|
||||||
prompt = `Translate exactly this short German product description into professional English for the toy market:\n\n${formData.shortDe}`;
|
prompt = `Translate exactly this short German product description into professional English for the toy market. IMPORTANT: Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting:\n\n${formData.shortDe}`;
|
||||||
} else if (formData.longEn) {
|
} else if (formData.longEn) {
|
||||||
const targetChars = Math.round(formData.longEn.length * 0.3);
|
const targetChars = Math.round(formData.longEn.length * 0.3);
|
||||||
prompt = `Create a concise summary of the following English product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longEn}`;
|
prompt = `Create a concise summary of the following English product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longEn}`;
|
||||||
@@ -127,6 +252,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
const generatedText = await generateGemini(prompt, systemPrompt);
|
const generatedText = await generateGemini(prompt, systemPrompt);
|
||||||
setFormData(prev => ({ ...prev, [field]: generatedText.trim() }));
|
setFormData(prev => ({ ...prev, [field]: generatedText.trim() }));
|
||||||
setGeneratedFields(prev => new Set(prev).add(field));
|
setGeneratedFields(prev => new Set(prev).add(field));
|
||||||
|
// Focus the textarea so user can edit immediately after AI generation
|
||||||
|
setTimeout(() => {
|
||||||
|
const textarea = document.getElementById(`field-${field}`);
|
||||||
|
if (textarea) textarea.focus();
|
||||||
|
}, 100);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'An error occurred during generation.');
|
setError(err.message || 'An error occurred during generation.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -158,63 +288,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
onSave(rowIndex, newRow);
|
onSave(rowIndex, newRow);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DimensionInput = ({ label, field, placeholder }: { label: string, field: keyof typeof formData, placeholder?: string }) => (
|
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
||||||
<div className="flex flex-col gap-1.5">
|
e.stopPropagation();
|
||||||
<label className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">{label}</label>
|
};
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData[field]}
|
|
||||||
onChange={e => setFormData(prev => ({ ...prev, [field]: e.target.value }))}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className={cn(
|
|
||||||
"w-full bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
|
|
||||||
isModified(field) ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const FieldEditor = ({ title, field }: { title: string, field: keyof typeof formData }) => (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium text-slate-300">{title}</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{((field === 'longDe' && formData.longEn) ||
|
|
||||||
(field === 'longEn' && formData.longDe) ||
|
|
||||||
(field === 'shortDe' && (formData.longDe || formData.shortEn)) ||
|
|
||||||
(field === 'shortEn' && (formData.shortDe || formData.longEn))) && (
|
|
||||||
<div className="flex items-center gap-1 text-[10px] text-slate-500 italic">
|
|
||||||
<Languages className="w-3 h-3" />
|
|
||||||
Can translate from existing
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleGenerate(field)}
|
|
||||||
disabled={loadingField !== null}
|
|
||||||
className="flex items-center gap-1.5 text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-2 py-1 rounded transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loadingField === field ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
|
||||||
{((field === 'longDe' && formData.longEn) || (field === 'longEn' && formData.longDe) || (field === 'shortDe' && formData.shortEn) || (field === 'shortEn' && formData.shortDe)) ? 'Translate with Gemini' : 'Generate with Gemini'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{generatedFields.has(field) && (
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
|
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
|
||||||
AI Generated - You can still edit manually
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={formData[field]}
|
|
||||||
onChange={e => setFormData(prev => ({ ...prev, [field]: e.target.value }))}
|
|
||||||
className={cn(
|
|
||||||
"w-full h-32 bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
|
|
||||||
isModified(field) ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
|
||||||
)}
|
|
||||||
placeholder={`Enter ${title}...`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -279,28 +355,78 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="col-span-3 text-[10px] text-slate-500 font-medium">INNER BOX (L × W × H) cm</div>
|
<div className="col-span-3 text-[10px] text-slate-500 font-medium">INNER BOX (L × W × H) cm</div>
|
||||||
<DimensionInput label="Length" field="innerL" placeholder="L" />
|
<DimensionInput label="Length" value={formData.innerL} field="innerL" isModified={isModified('innerL')} onChange={(val) => setFormData(p => ({...p, innerL: val}))} placeholder="L" />
|
||||||
<DimensionInput label="Width" field="innerW" placeholder="W" />
|
<DimensionInput label="Width" value={formData.innerW} field="innerW" isModified={isModified('innerW')} onChange={(val) => setFormData(p => ({...p, innerW: val}))} placeholder="W" />
|
||||||
<DimensionInput label="Height" field="innerH" placeholder="H" />
|
<DimensionInput label="Height" value={formData.innerH} field="innerH" isModified={isModified('innerH')} onChange={(val) => setFormData(p => ({...p, innerH: val}))} placeholder="H" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="col-span-3 text-[10px] text-slate-500 font-medium">OUTER BOX (L × W × H) cm</div>
|
<div className="col-span-3 text-[10px] text-slate-500 font-medium">OUTER BOX (L × W × H) cm</div>
|
||||||
<DimensionInput label="Length" field="outerL" placeholder="L" />
|
<DimensionInput label="Length" value={formData.outerL} field="outerL" isModified={isModified('outerL')} onChange={(val) => setFormData(p => ({...p, outerL: val}))} placeholder="L" />
|
||||||
<DimensionInput label="Width" field="outerW" placeholder="W" />
|
<DimensionInput label="Width" value={formData.outerW} field="outerW" isModified={isModified('outerW')} onChange={(val) => setFormData(p => ({...p, outerW: val}))} placeholder="W" />
|
||||||
<DimensionInput label="Height" field="outerH" placeholder="H" />
|
<DimensionInput label="Height" value={formData.outerH} field="outerH" isModified={isModified('outerH')} onChange={(val) => setFormData(p => ({...p, outerH: val}))} placeholder="H" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<DimensionInput label="Units per Outer" field="unitsOuter" />
|
<DimensionInput label="Units per Outer" value={formData.unitsOuter} field="unitsOuter" isModified={isModified('unitsOuter')} onChange={(val) => setFormData(p => ({...p, unitsOuter: val}))} />
|
||||||
<DimensionInput label="MOQ" field="moq" />
|
<DimensionInput label="MOQ" value={formData.moq} field="moq" isModified={isModified('moq')} onChange={(val) => setFormData(p => ({...p, moq: val}))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FieldEditor title="Long Description (DE)" field="longDe" />
|
<FieldEditor
|
||||||
<FieldEditor title="Long Description (EN)" field="longEn" />
|
title="Long Description (DE)"
|
||||||
<FieldEditor title="Short Description (DE)" field="shortDe" />
|
field="longDe"
|
||||||
<FieldEditor title="Short Description (EN)" field="shortEn" />
|
value={formData.longDe}
|
||||||
|
isModified={isModified('longDe')}
|
||||||
|
canTranslate={!!formData.longEn}
|
||||||
|
isGenerated={generatedFields.has('longDe')}
|
||||||
|
isLoading={loadingField === 'longDe'}
|
||||||
|
onGenerate={() => handleGenerate('longDe')}
|
||||||
|
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
isListening={isListeningDe}
|
||||||
|
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
|
||||||
|
voiceSupported={speechSupported}
|
||||||
|
/>
|
||||||
|
<FieldEditor
|
||||||
|
title="Long Description (EN)"
|
||||||
|
field="longEn"
|
||||||
|
value={formData.longEn}
|
||||||
|
isModified={isModified('longEn')}
|
||||||
|
canTranslate={!!formData.longDe}
|
||||||
|
isGenerated={generatedFields.has('longEn')}
|
||||||
|
isLoading={loadingField === 'longEn'}
|
||||||
|
onGenerate={() => handleGenerate('longEn')}
|
||||||
|
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
isListening={isListeningEn}
|
||||||
|
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
|
||||||
|
voiceSupported={speechSupported}
|
||||||
|
/>
|
||||||
|
<FieldEditor
|
||||||
|
title="Short Description (DE)"
|
||||||
|
field="shortDe"
|
||||||
|
value={formData.shortDe}
|
||||||
|
isModified={isModified('shortDe')}
|
||||||
|
canTranslate={!!formData.shortEn || !!formData.longDe}
|
||||||
|
isGenerated={generatedFields.has('shortDe')}
|
||||||
|
isLoading={loadingField === 'shortDe'}
|
||||||
|
onGenerate={() => handleGenerate('shortDe')}
|
||||||
|
onChange={(val) => setFormData(p => ({...p, shortDe: val}))}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
/>
|
||||||
|
<FieldEditor
|
||||||
|
title="Short Description (EN)"
|
||||||
|
field="shortEn"
|
||||||
|
value={formData.shortEn}
|
||||||
|
isModified={isModified('shortEn')}
|
||||||
|
canTranslate={!!formData.shortDe || !!formData.longEn}
|
||||||
|
isGenerated={generatedFields.has('shortEn')}
|
||||||
|
isLoading={loadingField === 'shortEn'}
|
||||||
|
onGenerate={() => handleGenerate('shortEn')}
|
||||||
|
onChange={(val) => setFormData(p => ({...p, shortEn: val}))}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
|
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
|
||||||
@@ -315,7 +441,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-lg shadow-blue-900/20 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-lg shadow-blue-900/20 transition-colors"
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4" />
|
<Save className="w-4 h-4" />
|
||||||
Save to Memory
|
Queue Changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2 } from 'lucide-react';
|
||||||
|
import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase';
|
||||||
|
import { ExcelRow, COLUMNS } from '../types';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
interface HistoryViewProps {
|
||||||
|
headers: string[];
|
||||||
|
data: ExcelRow[];
|
||||||
|
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: number) => void;
|
||||||
|
onEdit?: (rowIndex: number) => void;
|
||||||
|
sessionToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: HistoryViewProps) {
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHistory();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadHistory = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getHistory(sessionToken);
|
||||||
|
setHistory(data);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevert = (entry: HistoryEntry) => {
|
||||||
|
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
|
||||||
|
const currentRow = data.find(r => String(r[0]) === entry.product_id);
|
||||||
|
if (currentRow && JSON.stringify(currentRow) === JSON.stringify(entry.old_data)) {
|
||||||
|
if (!window.confirm("Reverting will restore original data. No actual changes will be made. Continue?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onRevert(entry.product_id, entry.old_data, entry.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangedFields = (oldData: ExcelRow, newData: ExcelRow) => {
|
||||||
|
const changes: { header: string; old: any; new: any; index: number }[] = [];
|
||||||
|
const maxLen = Math.max(oldData.length, newData.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
if (oldData[i] !== newData[i]) {
|
||||||
|
changes.push({
|
||||||
|
header: headers[i] || `Col ${i}`,
|
||||||
|
old: oldData[i],
|
||||||
|
new: newData[i],
|
||||||
|
index: i
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredHistory = history.filter(entry =>
|
||||||
|
entry.article_name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
entry.product_id.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
entry.changed_by?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-slate-400">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
|
||||||
|
<p className="text-lg">Fetching history...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<History className="w-8 h-8 text-blue-500" />
|
||||||
|
Change History
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400 mt-1">Review and revert any changes made to products.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search history..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 focus:outline-none focus:border-blue-500 transition-colors w-64"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadHistory}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors flex items-center gap-2 border border-slate-700"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#0a1628] border border-slate-800 rounded-xl overflow-hidden shadow-2xl">
|
||||||
|
{filteredHistory.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<History className="w-12 h-12 text-slate-700 mx-auto mb-4" />
|
||||||
|
<p className="text-slate-500 text-lg">No history records found.</p>
|
||||||
|
<p className="text-slate-600 text-sm mt-1">
|
||||||
|
{search ? "Try adjusting your search filters." : "Changes are recorded when you 'Save All' pending modifications."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-800">
|
||||||
|
{filteredHistory.map((entry) => {
|
||||||
|
const isExpanded = expandedId === entry.id;
|
||||||
|
const changes = getChangedFields(entry.old_data, entry.new_data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
isExpanded ? "bg-blue-600/5" : "hover:bg-slate-800/30"
|
||||||
|
)}>
|
||||||
|
{/* Summary Row */}
|
||||||
|
<div
|
||||||
|
className="p-4 flex items-center gap-4 cursor-pointer"
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : entry.id)}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown className="w-5 h-5 text-slate-500" /> : <ChevronRight className="w-5 h-5 text-slate-500" />}
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-4 gap-4 items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500 font-bold shrink-0">
|
||||||
|
{entry.product_id.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-slate-200 truncate">{entry.article_name}</div>
|
||||||
|
<div className="text-xs text-slate-500 font-mono">ID: {entry.product_id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span className="text-sm truncate">{entry.changed_by}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{formatDate(entry.changed_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 text-sm">
|
||||||
|
<span className="px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 font-medium">
|
||||||
|
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onEdit) {
|
||||||
|
const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
|
||||||
|
if (idx !== -1) onEdit(idx);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors border border-blue-500/20"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRevert(entry);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-orange-500/10 text-orange-400 hover:bg-orange-500/20 transition-colors border border-orange-500/20"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details View */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-14 pb-6 pt-2 animate-in slide-in-from-top-2 duration-300">
|
||||||
|
<div className="bg-[#040d1a] border border-slate-700/50 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-800/50 text-slate-400 text-left">
|
||||||
|
<th className="px-4 py-2 font-medium">Field</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Original Value</th>
|
||||||
|
<th className="px-4 py-2 font-medium">New Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{changes.map((change, idx) => (
|
||||||
|
<tr key={idx} className="hover:bg-slate-700/20">
|
||||||
|
<td className="px-4 py-2 text-slate-300 font-medium whitespace-nowrap">
|
||||||
|
{change.header}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="text-red-400/80 line-through decoration-red-500/50">
|
||||||
|
{String(change.old ?? '-')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="text-emerald-400 font-medium">
|
||||||
|
{String(change.new ?? '-')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between px-2">
|
||||||
|
<div className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
|
<Tag className="w-3 h-3" />
|
||||||
|
Row Index Reference: {entry.product_id}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 italic">
|
||||||
|
Reverting will move this record to 'Pending Validation' for final approval before re-syncing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -111,7 +111,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
|||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
placeholder="you@craze-group.com"
|
placeholder="you@example.com"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-md px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
className="w-full bg-slate-900 border border-slate-700 rounded-md px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
||||||
@@ -157,7 +157,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-slate-600 text-xs mt-6">
|
<p className="text-center text-slate-600 text-xs mt-6">
|
||||||
Access restricted to @craze-group.com accounts
|
Create an account to get started
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { ExcelRow } from '../types';
|
import { ExcelRow } from '../types';
|
||||||
import { Search, Filter, ChevronDown, ChevronUp, X as XIcon } from 'lucide-react';
|
import { Search, Filter, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||||
|
|
||||||
interface MatrixViewProps {
|
interface MatrixViewProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
headers: string[];
|
headers: string[];
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MatrixView({ data, headers }: MatrixViewProps) {
|
export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -169,8 +170,16 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
|
|||||||
placeholder="Search all columns..."
|
placeholder="Search all columns..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-medium"
|
className="w-full pl-9 pr-10 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-medium"
|
||||||
/>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearch(''); setPage(1); }}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||||
<button
|
<button
|
||||||
@@ -180,7 +189,7 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
|
|||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
||||||
>
|
>
|
||||||
<XIcon className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
Clear All Column Filters
|
Clear All Column Filters
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -196,8 +205,8 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
|
|||||||
key={index}
|
key={index}
|
||||||
className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative"
|
className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center overflow-hidden">
|
||||||
<div
|
<span
|
||||||
className="flex items-center gap-1 cursor-pointer hover:text-white"
|
className="flex items-center gap-1 cursor-pointer hover:text-white"
|
||||||
onClick={() => handleSort(index)}
|
onClick={() => handleSort(index)}
|
||||||
>
|
>
|
||||||
@@ -205,14 +214,14 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
|
|||||||
{sortCol === index && (
|
{sortCol === index && (
|
||||||
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpenFilterCol(openFilterCol === index ? null : index);
|
setOpenFilterCol(openFilterCol === index ? null : index);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1 rounded hover:bg-slate-700 transition-colors",
|
"p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
|
||||||
(columnFilters[index]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10 opacity-100" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
(columnFilters[index]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10 opacity-100" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { ExcelRow, COLUMNS } from '../types';
|
||||||
|
import { Search, ChevronDown, ChevronUp, X, Edit2, Save, Filter, XCircle } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
interface MissingDataViewProps {
|
||||||
|
data: ExcelRow[];
|
||||||
|
headers: string[];
|
||||||
|
onSaveRow: (rowIndex: number, updatedRow: ExcelRow) => void;
|
||||||
|
onCaptureState: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateValue(val: any): string {
|
||||||
|
if (val === null || val === undefined || val === '') return '';
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
if (val >= 25569 && val <= 60000) {
|
||||||
|
const excelEpoch = new Date(1899, 11, 30);
|
||||||
|
const date = new Date(excelEpoch.getTime() + val * 86400000);
|
||||||
|
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyOrEpoch(val: any): boolean {
|
||||||
|
if (val === null || val === undefined || val === '') return true;
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
if (val === 0 || val === 1) return true;
|
||||||
|
if (val >= 25569 && val <= 60000) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const s = String(val).trim();
|
||||||
|
if (s === '' || s === '0' || s === '1') return true;
|
||||||
|
if (s.endsWith('/1900')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = 'missingClass' | 'missingLaunch';
|
||||||
|
|
||||||
|
interface EditingState {
|
||||||
|
rowIndex: number;
|
||||||
|
classification: string;
|
||||||
|
launchDate: string;
|
||||||
|
readyToOrder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: MissingDataViewProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('missingClass');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||||
|
const [sortDesc, setSortDesc] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [editing, setEditing] = useState<EditingState | null>(null);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({});
|
||||||
|
const [showFilterMenu, setShowFilterMenu] = useState<number | null>(null);
|
||||||
|
const pageSize = 100;
|
||||||
|
|
||||||
|
const launchDateCol = useMemo(() =>
|
||||||
|
headers.findIndex(h => h.toLowerCase().includes('launch')), [headers]);
|
||||||
|
const readyToOrderCol = useMemo(() =>
|
||||||
|
headers.findIndex(h => h.toLowerCase().includes('ready')), [headers]);
|
||||||
|
|
||||||
|
const launchHeader = launchDateCol >= 0 ? headers[launchDateCol] : 'Launch Date';
|
||||||
|
const readyHeader = readyToOrderCol >= 0 ? headers[readyToOrderCol] : 'Ready to Order';
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
let result = data.map((row, index) => ({ row, index }));
|
||||||
|
|
||||||
|
if (activeTab === 'missingClass') {
|
||||||
|
result = result.filter(r => {
|
||||||
|
const val = r.row[COLUMNS.CLASSIFICATION];
|
||||||
|
return !val || String(val).trim() === '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'missingLaunch') {
|
||||||
|
result = result.filter(r => {
|
||||||
|
const val = launchDateCol >= 0 ? r.row[launchDateCol] : undefined;
|
||||||
|
return isEmptyOrEpoch(val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const s = search.toLowerCase();
|
||||||
|
result = result.filter(r =>
|
||||||
|
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||||
|
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(Object.entries(columnFilters) as [string, string][]).forEach(([colIdx, filterValue]) => {
|
||||||
|
if (!filterValue) return;
|
||||||
|
const colIdxNum = parseInt(colIdx);
|
||||||
|
const filterLower = filterValue.toLowerCase();
|
||||||
|
result = result.filter(r => {
|
||||||
|
const val: any = r.row[colIdxNum];
|
||||||
|
const displayVal = colIdxNum === launchDateCol || colIdxNum === readyToOrderCol
|
||||||
|
? formatDateValue(val) || String(val ?? '')
|
||||||
|
: String(val ?? '');
|
||||||
|
return displayVal.toLowerCase().includes(filterLower);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sortCol !== null) {
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const valA = a.row[sortCol];
|
||||||
|
const valB = b.row[sortCol];
|
||||||
|
if (typeof valA === 'number' && typeof valB === 'number') {
|
||||||
|
return sortDesc ? valB - valA : valA - valB;
|
||||||
|
}
|
||||||
|
const sA = String(valA || '');
|
||||||
|
const sB = String(valB || '');
|
||||||
|
return sortDesc ? sB.localeCompare(sA) : sA.localeCompare(sB);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [data, activeTab, search, sortCol, sortDesc, launchDateCol, columnFilters]);
|
||||||
|
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return filteredData.slice(start, start + pageSize);
|
||||||
|
}, [filteredData, page]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||||
|
|
||||||
|
const handleSort = (col: number) => {
|
||||||
|
if (sortCol === col) setSortDesc(d => !d);
|
||||||
|
else { setSortCol(col); setSortDesc(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (rowIndex: number, row: ExcelRow) => {
|
||||||
|
setEditing({
|
||||||
|
rowIndex,
|
||||||
|
classification: String(row[COLUMNS.CLASSIFICATION] || ''),
|
||||||
|
launchDate: launchDateCol >= 0 ? formatDateValue(row[launchDateCol]) || String(row[launchDateCol] ?? '') : '',
|
||||||
|
readyToOrder: readyToOrderCol >= 0 ? formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol] ?? '') : '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!editing) return;
|
||||||
|
const row = data[editing.rowIndex];
|
||||||
|
onCaptureState(`Updated product ${row[COLUMNS.ARTICLE_NO]}`);
|
||||||
|
const newRow = [...row];
|
||||||
|
newRow[COLUMNS.CLASSIFICATION] = editing.classification;
|
||||||
|
if (launchDateCol >= 0) newRow[launchDateCol] = editing.launchDate;
|
||||||
|
if (readyToOrderCol >= 0) newRow[readyToOrderCol] = editing.readyToOrder;
|
||||||
|
onSaveRow(editing.rowIndex, newRow);
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs: { id: TabType; label: string }[] = [
|
||||||
|
{ id: 'missingClass', label: 'Missing Classification' },
|
||||||
|
{ id: 'missingLaunch', label: 'Missing Launch Date' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ col: COLUMNS.ARTICLE_NO, label: 'SKU', width: 100 },
|
||||||
|
{ col: COLUMNS.ARTICLE_NAME, label: 'Name', width: 220 },
|
||||||
|
{ col: COLUMNS.CLASSIFICATION, label: 'Classification', width: 130 },
|
||||||
|
...(launchDateCol >= 0 ? [{ col: launchDateCol, label: launchHeader, width: 130 }] : []),
|
||||||
|
...(readyToOrderCol >= 0 ? [{ col: readyToOrderCol, label: readyHeader, width: 130 }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const editingRow = editing ? data[editing.rowIndex] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex flex-wrap gap-2 mb-6">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => { setActiveTab(tab.id); setPage(1); }}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
activeTab === tab.id
|
||||||
|
? "bg-indigo-600 text-white shadow-md"
|
||||||
|
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
|
||||||
|
<div className="flex-1 min-w-[200px] relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search SKU or Name..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||||
|
className="w-full pl-9 pr-10 py-2 bg-slate-900/50 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearch(''); setPage(1); }}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-xs text-slate-500">
|
||||||
|
{filteredData.length} items
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||||
|
<div className="overflow-x-auto flex-1">
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
|
||||||
|
<tr>
|
||||||
|
{columns.map(({ col, label, width }) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
style={{ width, minWidth: width }}
|
||||||
|
className="px-2 py-2 font-medium border-r border-slate-700/30 relative"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 cursor-pointer select-none hover:text-white"
|
||||||
|
onClick={() => handleSort(col)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{sortCol === col && (
|
||||||
|
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter..."
|
||||||
|
value={columnFilters[col] || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
setColumnFilters(prev => ({ ...prev, [col]: e.target.value }));
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-full px-1.5 py-0.5 bg-slate-800 border border-slate-600 rounded text-[10px] text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
{columnFilters[col] && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setColumnFilters(prev => { const n = { ...prev }; delete n[col]; return n; });
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white"
|
||||||
|
>
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-2 py-2 font-medium text-right" style={{ width: 60 }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-700/30">
|
||||||
|
{paginatedData.map(({ row, index }) => (
|
||||||
|
<tr key={index} className="hover:bg-slate-700/20 transition-colors">
|
||||||
|
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: 100 }}>
|
||||||
|
{row[COLUMNS.ARTICLE_NO]}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: 220 }} title={row[COLUMNS.ARTICLE_NAME]}>
|
||||||
|
{row[COLUMNS.ARTICLE_NAME]}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 truncate" style={{ width: 130 }}>
|
||||||
|
{row[COLUMNS.CLASSIFICATION] && String(row[COLUMNS.CLASSIFICATION]).trim() !== '' ? (
|
||||||
|
<span className={cn(
|
||||||
|
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
|
||||||
|
String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
|
||||||
|
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||||
|
: "bg-slate-700/50 text-slate-400 border-slate-600/50"
|
||||||
|
)}>
|
||||||
|
{row[COLUMNS.CLASSIFICATION]}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/20">Empty</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{launchDateCol >= 0 && (
|
||||||
|
<td className="px-3 py-2 truncate font-mono text-slate-300" style={{ width: 130 }}>
|
||||||
|
{isEmptyOrEpoch(row[launchDateCol]) ? (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/20">Empty</span>
|
||||||
|
) : (
|
||||||
|
formatDateValue(row[launchDateCol]) || String(row[launchDateCol])
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{readyToOrderCol >= 0 && (
|
||||||
|
<td className="px-3 py-2 truncate font-mono text-slate-300" style={{ width: 130 }}>
|
||||||
|
{row[readyToOrderCol] !== null && row[readyToOrderCol] !== undefined && row[readyToOrderCol] !== '' ? (
|
||||||
|
formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol])
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-700/50 text-slate-500 border border-slate-600/50">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="px-3 py-2 text-right" style={{ width: 60 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(index, row)}
|
||||||
|
className="p-1.5 text-slate-500 hover:text-indigo-400 hover:bg-indigo-400/10 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{paginatedData.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length + 1} className="px-4 py-8 text-center text-slate-500">
|
||||||
|
No items found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-xs text-slate-500">
|
||||||
|
<div>Showing {paginatedData.length} of {filteredData.length} items</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => setPage(p => p - 1)}
|
||||||
|
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-slate-300">Page {page} of {totalPages || 1}</span>
|
||||||
|
<button
|
||||||
|
disabled={page === totalPages || totalPages === 0}
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Focused edit panel */}
|
||||||
|
{editing && editingRow && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm z-40" onClick={() => setEditing(null)} />
|
||||||
|
<div className="fixed right-0 top-0 bottom-0 w-[400px] bg-slate-800 border-l border-slate-700 shadow-2xl z-50 flex flex-col animate-in slide-in-from-right duration-200">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-slate-700 bg-slate-800/50">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Edit Fields</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
{editingRow[COLUMNS.ARTICLE_NO]} — {editingRow[COLUMNS.ARTICLE_NAME]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-6 space-y-5">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Classification</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editing.classification}
|
||||||
|
onChange={e => setEditing(prev => prev ? { ...prev, classification: e.target.value } : prev)}
|
||||||
|
placeholder="e.g. CORE, OOC..."
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
|
||||||
|
editing.classification !== String(editingRow[COLUMNS.CLASSIFICATION] || '')
|
||||||
|
? "border-blue-500 focus:ring-blue-500"
|
||||||
|
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{launchDateCol >= 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{launchHeader}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editing.launchDate}
|
||||||
|
onChange={e => setEditing(prev => prev ? { ...prev, launchDate: e.target.value } : prev)}
|
||||||
|
placeholder="DD/MM/YYYY"
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white font-mono focus:outline-none focus:ring-1 transition-colors",
|
||||||
|
editing.launchDate !== (formatDateValue(editingRow[launchDateCol]) || String(editingRow[launchDateCol] ?? ''))
|
||||||
|
? "border-blue-500 focus:ring-blue-500"
|
||||||
|
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{readyToOrderCol >= 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{readyHeader}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editing.readyToOrder}
|
||||||
|
onChange={e => setEditing(prev => prev ? { ...prev, readyToOrder: e.target.value } : prev)}
|
||||||
|
placeholder="DD/MM/YYYY"
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white font-mono focus:outline-none focus:ring-1 transition-colors",
|
||||||
|
editing.readyToOrder !== (formatDateValue(editingRow[readyToOrderCol]) || String(editingRow[readyToOrderCol] ?? ''))
|
||||||
|
? "border-blue-500 focus:ring-blue-500"
|
||||||
|
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-700 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-lg shadow-blue-900/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Queue Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ExcelRow, COLUMNS } from '../types';
|
||||||
|
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
interface PendingValidationViewProps {
|
||||||
|
data: ExcelRow[];
|
||||||
|
pendingRows: Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>;
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
|
onRevertRow: (articleNo: string) => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingValidationView({ data, pendingRows, rowStatuses, onRevertRow, onEdit }: PendingValidationViewProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const pendingEntries = Object.entries(pendingRows);
|
||||||
|
|
||||||
|
const filteredEntries = search
|
||||||
|
? pendingEntries.filter(([articleNo, { articleName }]) =>
|
||||||
|
articleNo.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
articleName.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: pendingEntries;
|
||||||
|
|
||||||
|
const getFieldDiff = (original: ExcelRow, updated: ExcelRow, colIndex: number) => {
|
||||||
|
const orig = original[colIndex];
|
||||||
|
const upd = updated[colIndex];
|
||||||
|
if (orig !== upd) {
|
||||||
|
return { from: String(orig ?? ''), to: String(upd ?? '') };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangedFields = (original: ExcelRow, updated: ExcelRow) => {
|
||||||
|
const changes: { field: string; from: string; to: string }[] = [];
|
||||||
|
|
||||||
|
const fieldConfigs = [
|
||||||
|
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
|
||||||
|
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
|
||||||
|
{ col: COLUMNS.INNER_L, label: 'Inner L' },
|
||||||
|
{ col: COLUMNS.INNER_W, label: 'Inner W' },
|
||||||
|
{ col: COLUMNS.INNER_H, label: 'Inner H' },
|
||||||
|
{ col: COLUMNS.OUTER_L, label: 'Outer L' },
|
||||||
|
{ col: COLUMNS.OUTER_W, label: 'Outer W' },
|
||||||
|
{ col: COLUMNS.OUTER_H, label: 'Outer H' },
|
||||||
|
{ col: COLUMNS.UNITS_INNER, label: 'Units Inner' },
|
||||||
|
{ col: COLUMNS.UNITS_OUTER, label: 'Units Outer' },
|
||||||
|
{ col: COLUMNS.MOQ, label: 'MOQ' },
|
||||||
|
{ col: COLUMNS.BARCODE, label: 'Barcode' },
|
||||||
|
{ col: COLUMNS.TARIFF_CODE, label: 'Tariff Code' },
|
||||||
|
{ col: COLUMNS.COUNTRY_ORIGIN, label: 'Country' },
|
||||||
|
{ col: COLUMNS.RECOMMENDED_AGE, label: 'Recommended Age' },
|
||||||
|
];
|
||||||
|
|
||||||
|
fieldConfigs.forEach(({ col, label }) => {
|
||||||
|
const diff = getFieldDiff(original, updated, col);
|
||||||
|
if (diff) {
|
||||||
|
changes.push({ field: label, ...diff });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-slate-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="w-5 h-5 text-yellow-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">Pending Validation</h2>
|
||||||
|
<span className="text-sm text-slate-500">
|
||||||
|
{pendingEntries.length} change{pendingEntries.length !== 1 ? 's' : ''} pending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by article no or name..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="bg-slate-800 border border-slate-700 rounded-md pl-9 pr-10 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 w-64"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredEntries.length === 0 ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-slate-500">
|
||||||
|
<Clock className="w-16 h-16 mb-4 opacity-30" />
|
||||||
|
{search ? (
|
||||||
|
<p>No pending changes match your search</p>
|
||||||
|
) : (
|
||||||
|
<p>No pending validation changes</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{filteredEntries.map(([articleNo, { rowIndex, originalData, newData, articleName }]) => {
|
||||||
|
const changes = getChangedFields(originalData, newData);
|
||||||
|
const status = rowStatuses[articleNo];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={articleNo}
|
||||||
|
className="border border-yellow-500/30 bg-yellow-500/5 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-yellow-500/10 border-b border-yellow-500/20">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
|
||||||
|
{articleNo}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-300">{articleName}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(rowIndex)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
View/Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRevertRow(articleNo)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-900/30 hover:bg-red-900/50 text-red-400 rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Undo2 className="w-3 h-3" />
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-3">
|
||||||
|
Changes ({changes.length})
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{changes.map((change, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-slate-400 w-28 shrink-0">{change.field}:</span>
|
||||||
|
<span className="text-red-400 line-through opacity-70">{change.from}</span>
|
||||||
|
<span className="text-slate-500">→</span>
|
||||||
|
<span className="text-green-400 font-medium">{change.to}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ interface PricingViewProps {
|
|||||||
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
|
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
|
||||||
onCaptureState: (message: string) => void;
|
onCaptureState: (message: string) => void;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DetectedCol {
|
interface DetectedCol {
|
||||||
@@ -45,7 +46,7 @@ function findCol(headers: string[], ...keywords: string[]): number {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }: PricingViewProps) {
|
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses }: PricingViewProps) {
|
||||||
const [filterMode, setFilterMode] = useState<FilterMode>('all_errors');
|
const [filterMode, setFilterMode] = useState<FilterMode>('all_errors');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||||
@@ -281,9 +282,18 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
|||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="w-full pl-4 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
className="w-full pl-4 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
||||||
/>
|
/>
|
||||||
|
{search ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500">
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500">
|
||||||
<Package className="w-4 h-4" />
|
<Package className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Filter tabs ── */}
|
{/* ── Filter tabs ── */}
|
||||||
@@ -454,12 +464,17 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
|||||||
<tbody>
|
<tbody>
|
||||||
{filteredRows.map(({ row, dataIndex, pricingErrors, unitErrors, isCritical }) => {
|
{filteredRows.map(({ row, dataIndex, pricingErrors, unitErrors, isCritical }) => {
|
||||||
const hasAnyError = pricingErrors.length > 0 || unitErrors.length > 0;
|
const hasAnyError = pricingErrors.length > 0 || unitErrors.length > 0;
|
||||||
|
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={dataIndex}
|
key={dataIndex}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b border-slate-700/50 transition-colors hover:bg-slate-700/20',
|
'border-b border-slate-700/50 transition-colors hover:bg-slate-700/20',
|
||||||
isCritical
|
saveStatus === 'error'
|
||||||
|
? 'bg-red-400/20 border-l-4 border-l-red-500'
|
||||||
|
: saveStatus === 'pending'
|
||||||
|
? 'bg-yellow-400/20 border-l-4 border-l-yellow-400'
|
||||||
|
: isCritical
|
||||||
? 'bg-red-950/20'
|
? 'bg-red-950/20'
|
||||||
: pricingErrors.length > 0
|
: pricingErrors.length > 0
|
||||||
? 'bg-amber-950/10'
|
? 'bg-amber-950/10'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
import { ExcelRow, COLUMNS } from '../types';
|
||||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react';
|
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
@@ -6,25 +6,40 @@ import { ColumnFilterPopover } from './ColumnFilterPopover';
|
|||||||
|
|
||||||
interface ProductDescriptionsProps {
|
interface ProductDescriptionsProps {
|
||||||
data: ExcelRow[];
|
data: ExcelRow[];
|
||||||
|
headers: string[];
|
||||||
|
asinColumnIndex: number | null;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
|
rowStatuses: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingLongAny' | 'missingShortDE' | 'missingShortEN' | 'missingShortAny' | 'complete' | 'incomplete';
|
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete';
|
||||||
|
|
||||||
// Description columns that should only have Present/Missing filters
|
// Description columns that should only have Present/Missing filters
|
||||||
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN];
|
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN];
|
||||||
|
|
||||||
export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) {
|
export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses }: ProductDescriptionsProps) {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [lineFilter, setLineFilter] = useState('');
|
const [lineFilter, setLineFilter] = useState('');
|
||||||
const [licenseFilter, setLicenseFilter] = useState('');
|
const [licenseFilter, setLicenseFilter] = useState('');
|
||||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||||
const [sortDesc, setSortDesc] = useState(false);
|
const [sortDesc, setSortDesc] = useState(false);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(100);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||||
|
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
||||||
|
[COLUMNS.ARTICLE_NO]: 110,
|
||||||
|
[COLUMNS.ARTICLE_NAME]: 450, // More flexible space for name
|
||||||
|
...(asinColumnIndex !== null ? { [asinColumnIndex]: 100 } : {}),
|
||||||
|
[COLUMNS.LINE]: 80,
|
||||||
|
[COLUMNS.LICENSE]: 120,
|
||||||
|
[COLUMNS.CLASSIFICATION]: 100,
|
||||||
|
[COLUMNS.LONG_DE]: 110,
|
||||||
|
[COLUMNS.LONG_EN]: 110,
|
||||||
|
[COLUMNS.SHORT_DE]: 110,
|
||||||
|
[COLUMNS.SHORT_EN]: 110,
|
||||||
|
});
|
||||||
|
|
||||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||||
const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]);
|
const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]);
|
||||||
@@ -35,10 +50,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
// Tab filter
|
// Tab filter
|
||||||
if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
|
if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
|
||||||
if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
|
if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
|
||||||
if (activeTab === 'missingLongAny') result = result.filter(r => !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN]);
|
|
||||||
if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
|
if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
|
||||||
if (activeTab === 'missingShortEN') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
|
if (activeTab === 'missingShortEN') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
|
||||||
if (activeTab === 'missingShortAny') result = result.filter(r => !r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]);
|
|
||||||
|
|
||||||
if (activeTab === 'complete') result = result.filter(r =>
|
if (activeTab === 'complete') result = result.filter(r =>
|
||||||
r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] &&
|
r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] &&
|
||||||
@@ -63,6 +76,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
if (licenseFilter) result = result.filter(r => r.row[COLUMNS.LICENSE] === licenseFilter);
|
if (licenseFilter) result = result.filter(r => r.row[COLUMNS.LICENSE] === licenseFilter);
|
||||||
|
|
||||||
// Column-specific filters (Excel-like)
|
// Column-specific filters (Excel-like)
|
||||||
|
console.log('[Filter] applying columnFilters:', columnFilters, 'result count before:', result.length);
|
||||||
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
||||||
const col = Number(colIdx);
|
const col = Number(colIdx);
|
||||||
const vals = selectedValues as string[];
|
const vals = selectedValues as string[];
|
||||||
@@ -71,12 +85,17 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||||
result = result.filter(r => {
|
result = result.filter(r => {
|
||||||
const hasValue = Boolean(r.row[col]);
|
const hasValue = Boolean(r.row[col]);
|
||||||
const shouldInclude = vals.includes('Present') && hasValue || vals.includes('Missing') && !hasValue;
|
const shouldInclude = (vals.includes('Present') && hasValue) || (vals.includes('Missing') && !hasValue);
|
||||||
return shouldInclude;
|
return shouldInclude;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// For other columns, use regular value matching
|
// For other columns, use regular value matching
|
||||||
result = result.filter(r => vals.includes(String(r.row[col] || '')));
|
const before = result.length;
|
||||||
|
result = result.filter(r => {
|
||||||
|
const cellVal = String(r.row[col] ?? '').trim();
|
||||||
|
return vals.some(v => v.trim() === cellVal);
|
||||||
|
});
|
||||||
|
console.log('[Filter] col', col, 'vals', vals, 'before:', before, 'after:', result.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -91,7 +110,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [data, activeTab, search, lineFilter, licenseFilter, sortCol, sortDesc]);
|
}, [data, activeTab, search, lineFilter, licenseFilter, columnFilters, sortCol, sortDesc]);
|
||||||
|
|
||||||
const paginatedData = useMemo(() => {
|
const paginatedData = useMemo(() => {
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize;
|
||||||
@@ -109,6 +128,25 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResize = (colIndex: number, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const startX = e.pageX;
|
||||||
|
const startWidth = columnWidths[colIndex] || 100;
|
||||||
|
|
||||||
|
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
|
||||||
|
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
const getUniqueValues = (col: number) => {
|
const getUniqueValues = (col: number) => {
|
||||||
// For description columns, return only 'Present' and 'Missing'
|
// For description columns, return only 'Present' and 'Missing'
|
||||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||||
@@ -120,12 +158,15 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleColumnFilter = (col: number, value: string) => {
|
const toggleColumnFilter = (col: number, value: string) => {
|
||||||
|
console.log('[Filter] toggleColumnFilter called, col:', col, 'value:', JSON.stringify(value));
|
||||||
setColumnFilters(prev => {
|
setColumnFilters(prev => {
|
||||||
const current = prev[col] || [];
|
const current = prev[col] || [];
|
||||||
const next = current.includes(value)
|
const next = current.includes(value)
|
||||||
? current.filter(v => v !== value)
|
? current.filter(v => v !== value)
|
||||||
: [...current, value];
|
: [...current, value];
|
||||||
return { ...prev, [col]: next };
|
const updated = { ...prev, [col]: next };
|
||||||
|
console.log('[Filter] new columnFilters:', updated);
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
@@ -155,7 +196,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Badge = ({ content, row }: { content: any, row: ExcelRow }) => {
|
const Badge = ({ content, row }: { content: any, row: ExcelRow }) => {
|
||||||
if (content) return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30">✓</span>;
|
if (content !== undefined && content !== null && content !== '') return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30">✓</span>;
|
||||||
|
|
||||||
// EOL Exception: OOC and stock <= 0
|
// EOL Exception: OOC and stock <= 0
|
||||||
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
|
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
|
||||||
@@ -172,10 +213,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
{ id: 'all', label: 'All Products' },
|
{ id: 'all', label: 'All Products' },
|
||||||
{ id: 'missingLongDE', label: 'Missing Long DE' },
|
{ id: 'missingLongDE', label: 'Missing Long DE' },
|
||||||
{ id: 'missingLongEN', label: 'Missing Long EN' },
|
{ id: 'missingLongEN', label: 'Missing Long EN' },
|
||||||
{ id: 'missingLongAny', label: 'Missing Long DE/EN' },
|
|
||||||
{ id: 'missingShortDE', label: 'Missing Short DE' },
|
{ id: 'missingShortDE', label: 'Missing Short DE' },
|
||||||
{ id: 'missingShortEN', label: 'Missing Short EN' },
|
{ id: 'missingShortEN', label: 'Missing Short EN' },
|
||||||
{ id: 'missingShortAny', label: 'Missing Short DE/EN' },
|
|
||||||
{ id: 'complete', label: 'Complete' },
|
{ id: 'complete', label: 'Complete' },
|
||||||
{ id: 'incomplete', label: 'Incomplete' },
|
{ id: 'incomplete', label: 'Incomplete' },
|
||||||
];
|
];
|
||||||
@@ -207,8 +246,16 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
placeholder="Search Article Name or No..."
|
placeholder="Search Article Name or No..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
className="w-full pl-9 pr-10 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearch(''); setPage(1); }}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||||
<button
|
<button
|
||||||
@@ -242,12 +289,13 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
|
|
||||||
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||||
<div className="overflow-x-auto flex-1">
|
<div className="overflow-x-auto flex-1">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm" style={{ tableLayout: 'fixed' }}>
|
||||||
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
|
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
{[
|
{[
|
||||||
{ col: COLUMNS.ARTICLE_NO, label: 'Article No.' },
|
{ col: COLUMNS.ARTICLE_NO, label: 'Article No.' },
|
||||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
|
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
|
||||||
|
...(asinColumnIndex !== null ? [{ col: asinColumnIndex, label: 'ASIN' }] : []),
|
||||||
{ col: COLUMNS.LINE, label: 'Line' },
|
{ col: COLUMNS.LINE, label: 'Line' },
|
||||||
{ col: COLUMNS.LICENSE, label: 'License' },
|
{ col: COLUMNS.LICENSE, label: 'License' },
|
||||||
{ col: COLUMNS.CLASSIFICATION, label: 'Classification' },
|
{ col: COLUMNS.CLASSIFICATION, label: 'Classification' },
|
||||||
@@ -258,22 +306,23 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
].map(({ col, label }) => (
|
].map(({ col, label }) => (
|
||||||
<th
|
<th
|
||||||
key={col}
|
key={col}
|
||||||
className="px-4 py-3 font-medium transition-colors select-none group relative"
|
className="px-4 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
|
||||||
|
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center overflow-hidden">
|
||||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}>
|
<span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
||||||
{label}
|
{label}
|
||||||
{sortCol === col && (
|
{sortCol === col && (
|
||||||
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
|
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1 rounded hover:bg-slate-700 transition-colors",
|
"p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
|
||||||
(columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
(columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -281,6 +330,12 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resizer handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={(e) => handleResize(col, e)}
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
|
||||||
|
/>
|
||||||
|
|
||||||
{openFilterCol === col && (
|
{openFilterCol === col && (
|
||||||
<ColumnFilterPopover
|
<ColumnFilterPopover
|
||||||
uniqueValues={getUniqueValues(col)}
|
uniqueValues={getUniqueValues(col)}
|
||||||
@@ -300,17 +355,30 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
<th className="px-4 py-3 font-medium text-right" style={{ width: 100 }}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-700/50">
|
<tbody className="divide-y divide-slate-700/50">
|
||||||
{paginatedData.map(({ row, index }) => (
|
{paginatedData.map(({ row, index }) => {
|
||||||
<tr key={index} className={cn("transition-colors", getRowColor(row))}>
|
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs">{row[COLUMNS.ARTICLE_NO]}</td>
|
return (
|
||||||
<td className="px-4 py-3 font-medium text-white max-w-[200px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
<tr
|
||||||
<td className="px-4 py-3 text-slate-300 text-xs">{row[COLUMNS.LINE]}</td>
|
key={index}
|
||||||
<td className="px-4 py-3 text-slate-300 text-xs truncate max-w-[120px]" title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
className={cn(
|
||||||
<td className="px-4 py-3">
|
"transition-colors",
|
||||||
|
getRowColor(row),
|
||||||
|
saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
|
||||||
|
saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-white truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
||||||
|
{asinColumnIndex !== null && (
|
||||||
|
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[asinColumnIndex] || 100 }} title={row[asinColumnIndex]}>{row[asinColumnIndex] || '—'}</td>
|
||||||
|
)}
|
||||||
|
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LINE] }}>{row[COLUMNS.LINE]}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
||||||
|
<td className="px-4 py-3 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"px-2 py-0.5 rounded text-[10px] font-bold border",
|
"px-2 py-0.5 rounded text-[10px] font-bold border",
|
||||||
String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
|
String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
|
||||||
@@ -320,10 +388,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td>
|
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_DE] }}><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td>
|
||||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td>
|
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_EN] }}><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td>
|
||||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
|
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_DE] }}><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
|
||||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td>
|
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_EN] }}><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(index)}
|
onClick={() => onEdit(index)}
|
||||||
@@ -334,10 +402,11 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{paginatedData.length === 0 && (
|
{paginatedData.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="px-4 py-8 text-center text-slate-500">
|
<td colSpan={asinColumnIndex !== null ? 10 : 9} className="px-4 py-8 text-center text-slate-500">
|
||||||
No products found matching the criteria.
|
No products found matching the criteria.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FileText, Table, Box, DollarSign, Package } from 'lucide-react';
|
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeModule: string;
|
activeModule: string;
|
||||||
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing') => void;
|
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data') => void;
|
||||||
|
userEmail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
|
export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarProps) {
|
||||||
const navItems = [
|
const isMasterUser = userEmail?.toLowerCase() === 'christian.vidal@craze-group.com';
|
||||||
|
|
||||||
|
type ModuleId = 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data';
|
||||||
|
|
||||||
|
const navItems: { id: ModuleId; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'matrix', label: 'Matrix', icon: Table },
|
{ id: 'matrix', label: 'Matrix', icon: Table },
|
||||||
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
||||||
{ id: 'article_details', label: 'Article Details', icon: Package },
|
{ id: 'article_details', label: 'Article Details', icon: Package },
|
||||||
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
||||||
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
||||||
] as const;
|
{ id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
|
||||||
|
...(isMasterUser ? [
|
||||||
|
{ id: 'pending_validation' as ModuleId, label: 'Pending Validation', icon: Clock },
|
||||||
|
{ id: 'history' as ModuleId, label: 'Change History', icon: History }
|
||||||
|
] : [])
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 bg-slate-800 border-r border-slate-700 flex flex-col shrink-0">
|
<aside className="w-60 bg-slate-800 border-r border-slate-700 flex flex-col shrink-0">
|
||||||
|
|||||||
+109
-9
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Download, Database, LogOut, Undo2 } from 'lucide-react';
|
import { Download, LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
stats: any;
|
stats: any;
|
||||||
@@ -12,9 +13,33 @@ interface TopBarProps {
|
|||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
undoMessage?: string;
|
undoMessage?: string;
|
||||||
undoSteps: number;
|
undoSteps: number;
|
||||||
|
pendingCount: number;
|
||||||
|
pendingChanges: Record<string, { articleName: string }>;
|
||||||
|
onSaveAll: () => Promise<void>;
|
||||||
|
onRevertRow: (articleNo: string) => void;
|
||||||
|
isSavingAll: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps }: TopBarProps) {
|
export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, pendingChanges, onSaveAll, onRevertRow, isSavingAll }: TopBarProps) {
|
||||||
|
const [showPending, setShowPending] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPending) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setShowPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [showPending]);
|
||||||
|
|
||||||
|
// Close dropdown when all changes are saved/reverted
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingCount === 0) setShowPending(false);
|
||||||
|
}, [pendingCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 z-10 shadow-2xl">
|
<header className="bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 z-10 shadow-2xl">
|
||||||
<div className="flex items-center -ml-4">
|
<div className="flex items-center -ml-4">
|
||||||
@@ -55,6 +80,77 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Split button: Save All + dropdown toggle */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center rounded-md overflow-hidden shadow-lg transition-all",
|
||||||
|
pendingCount > 0 ? "shadow-green-900/30" : "shadow-none opacity-40"
|
||||||
|
)}>
|
||||||
|
<button
|
||||||
|
onClick={onSaveAll}
|
||||||
|
disabled={isSavingAll || pendingCount === 0}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 text-sm font-bold transition-all text-white",
|
||||||
|
pendingCount > 0
|
||||||
|
? "bg-green-600 hover:bg-green-500 disabled:opacity-60"
|
||||||
|
: "bg-slate-700 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSavingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <CloudUpload className="w-4 h-4" />}
|
||||||
|
{isSavingAll
|
||||||
|
? 'Saving...'
|
||||||
|
: pendingCount > 0
|
||||||
|
? `Save ${pendingCount} change${pendingCount > 1 ? 's' : ''}`
|
||||||
|
: 'No pending changes'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => pendingCount > 0 && setShowPending(v => !v)}
|
||||||
|
disabled={isSavingAll || pendingCount === 0}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-2 py-2 text-white border-l transition-all",
|
||||||
|
pendingCount > 0
|
||||||
|
? "bg-green-700 hover:bg-green-600 border-green-500/40"
|
||||||
|
: "bg-slate-700 cursor-not-allowed border-slate-600"
|
||||||
|
)}
|
||||||
|
title="View pending changes"
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn("w-4 h-4 transition-transform", showPending && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown: list of pending changes */}
|
||||||
|
{showPending && pendingCount > 0 && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Pending changes</span>
|
||||||
|
<span className="text-xs text-slate-500">{pendingCount} unsaved</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
{Object.entries(pendingChanges).map(([articleNo, { articleName }]) => (
|
||||||
|
<div
|
||||||
|
key={articleNo}
|
||||||
|
className="flex items-center gap-2 px-3 py-2.5 hover:bg-slate-700/50 border-b border-slate-700/50 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-yellow-400 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-mono text-slate-400">{articleNo}</p>
|
||||||
|
<p className="text-sm text-white truncate">{articleName}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onRevertRow(articleNo)}
|
||||||
|
title="Discard this change"
|
||||||
|
className="shrink-0 flex items-center gap-1 px-2 py-1 text-xs text-red-400 hover:text-white hover:bg-red-600 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<button
|
<button
|
||||||
onClick={onExport}
|
onClick={onExport}
|
||||||
@@ -70,21 +166,25 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canUndo && (
|
|
||||||
<button
|
<button
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
title={`Undo: ${undoMessage}`}
|
disabled={!canUndo}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-md text-sm font-bold transition-all shadow-lg shadow-amber-900/40 animate-in fade-in zoom-in duration-300 relative group"
|
title={canUndo ? `Undo: ${undoMessage}` : 'No changes to undo'}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all shadow-lg relative group",
|
||||||
|
canUndo
|
||||||
|
? "bg-amber-600 hover:bg-amber-700 text-white shadow-amber-900/40 cursor-pointer"
|
||||||
|
: "bg-slate-800 text-slate-600 shadow-none cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Undo2 className="w-4 h-4" />
|
<Undo2 className="w-4 h-4" />
|
||||||
BACK / UNDO
|
BACK / UNDO
|
||||||
{undoSteps > 1 && (
|
{canUndo && undoSteps > 1 && (
|
||||||
<span className="absolute -top-1 -right-1 bg-white text-amber-700 text-[10px] w-4 h-4 rounded-full flex items-center justify-center shadow-md">
|
<span className="absolute -top-1 -right-1 bg-white text-amber-700 text-[10px] w-4 h-4 rounded-full flex items-center justify-center shadow-md font-bold">
|
||||||
{undoSteps}
|
{undoSteps}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
{userEmail && (
|
{userEmail && (
|
||||||
<div className="flex items-center gap-2 border-l border-slate-700 pl-3">
|
<div className="flex items-center gap-2 border-l border-slate-700 pl-3">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||||
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||||
const ALLOWED_DOMAIN = '@craze-group.com';
|
|
||||||
const SESSION_KEY = 'craze_auth_session';
|
const SESSION_KEY = 'craze_auth_session';
|
||||||
|
|
||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
@@ -9,10 +8,6 @@ export interface AuthSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function signUp(email: string, password: string): Promise<void> {
|
export async function signUp(email: string, password: string): Promise<void> {
|
||||||
if (!email.toLowerCase().endsWith(ALLOWED_DOMAIN)) {
|
|
||||||
throw new Error(`Only ${ALLOWED_DOMAIN} email addresses are allowed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${SUPABASE_URL}/auth/v1/signup`, {
|
const response = await fetch(`${SUPABASE_URL}/auth/v1/signup`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -29,10 +24,6 @@ export async function signUp(email: string, password: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function signIn(email: string, password: string): Promise<AuthSession> {
|
export async function signIn(email: string, password: string): Promise<AuthSession> {
|
||||||
if (!email.toLowerCase().endsWith(ALLOWED_DOMAIN)) {
|
|
||||||
throw new Error(`Only ${ALLOWED_DOMAIN} email addresses are allowed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
|
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
+197
-52
@@ -1,69 +1,214 @@
|
|||||||
import { ExcelRow } from '../types';
|
|
||||||
|
|
||||||
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
|
||||||
const SUPABASE_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
const SUPABASE_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||||
|
|
||||||
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow) {
|
export interface ExcelRow extends Array<any> {}
|
||||||
try {
|
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?id=eq.${encodeURIComponent(articleNo)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'apikey': SUPABASE_KEY,
|
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Prefer': 'resolution=merge-duplicates'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: articleNo,
|
|
||||||
data: rowData,
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 204 || response.ok) {
|
export interface SyncedRow {
|
||||||
// If PATCH didn't find the record, try UPSERT
|
data: ExcelRow;
|
||||||
const upsertResponse = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, {
|
status?: 'pending' | 'synced';
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'apikey': SUPABASE_KEY,
|
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Prefer': 'resolution=merge-duplicates'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: articleNo,
|
|
||||||
data: rowData,
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
return upsertResponse.ok;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving to Supabase:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllSyncedRows(): Promise<Record<string, ExcelRow>> {
|
export async function getAllSyncedRows(token?: string): Promise<Record<string, SyncedRow>> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, {
|
const response = await fetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products?select=product_id,data,status&order=updated_at.desc`,
|
||||||
|
{
|
||||||
|
cache: 'no-store',
|
||||||
headers: {
|
headers: {
|
||||||
'apikey': SUPABASE_KEY,
|
'apikey': SUPABASE_KEY,
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`
|
'Authorization': `Bearer ${SUPABASE_KEY}`
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) return {};
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
const data = await response.json();
|
console.error('getAllSyncedRows failed:', response.status, errText);
|
||||||
const result: Record<string, ExcelRow> = {};
|
return {};
|
||||||
data.forEach((item: any) => {
|
}
|
||||||
result[item.id] = item.data;
|
const rows = await response.json();
|
||||||
});
|
const result: Record<string, SyncedRow> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
result[row.product_id] = { data: row.data, status: row.status };
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching from Supabase:', error);
|
console.error('Error fetching synced rows from Supabase:', error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, token?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'apikey': SUPABASE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
||||||
|
'Prefer': 'resolution=merge-duplicates'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
product_id: articleNo,
|
||||||
|
data: rowData,
|
||||||
|
status: 'synced',
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `${response.status} ${response.statusText}: ${err.message || err.error_description || 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error saving to Supabase:', error);
|
||||||
|
return { success: false, error: error.message || 'Network error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
id?: number;
|
||||||
|
product_id: string;
|
||||||
|
article_name: string;
|
||||||
|
old_data: ExcelRow;
|
||||||
|
new_data: ExcelRow;
|
||||||
|
changed_by: string;
|
||||||
|
changed_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveHistoryEntry(
|
||||||
|
productId: string,
|
||||||
|
articleName: string,
|
||||||
|
oldData: ExcelRow,
|
||||||
|
newData: ExcelRow,
|
||||||
|
changedBy: string,
|
||||||
|
token?: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products_history`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'apikey': SUPABASE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
||||||
|
'Prefer': 'return=minimal'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
product_id: productId,
|
||||||
|
article_name: articleName,
|
||||||
|
old_data: oldData,
|
||||||
|
new_data: newData,
|
||||||
|
changed_by: changedBy,
|
||||||
|
changed_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `History ${response.status}: ${err.message || 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error saving history to Supabase:', error);
|
||||||
|
return { success: false, error: error.message || 'Network error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHistory(token?: string): Promise<HistoryEntry[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=100`,
|
||||||
|
{
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_KEY,
|
||||||
|
// Try using only the ANON key just in case RLS or token expiry is failing silently
|
||||||
|
'Authorization': `Bearer ${SUPABASE_KEY}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.text();
|
||||||
|
return [{
|
||||||
|
id: 'error-' + Date.now(),
|
||||||
|
product_id: 'ERROR',
|
||||||
|
article_name: `Failed: ${response.status} ${err}`,
|
||||||
|
old_data: [],
|
||||||
|
new_data: [],
|
||||||
|
changed_at: new Date().toISOString(),
|
||||||
|
changed_by: 'system'
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching history:', error);
|
||||||
|
return [{
|
||||||
|
id: 'error-' + Date.now(),
|
||||||
|
product_id: 'EXCEPTION',
|
||||||
|
article_name: `Message: ${error.message}`,
|
||||||
|
old_data: [],
|
||||||
|
new_data: [],
|
||||||
|
changed_at: new Date().toISOString(),
|
||||||
|
changed_by: 'system'
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHistoryEntry(id: string, token?: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(id)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_KEY}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting history from Supabase:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetAllPendingRows(token?: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/products?status=eq.pending`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'apikey': SUPABASE_KEY,
|
||||||
|
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
||||||
|
'Prefer': 'return=minimal'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: 'synced' })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting pending rows in Supabase:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface UseSpeechRecognitionOptions {
|
||||||
|
onResult: (transcript: string) => void;
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSpeechRecognitionReturn {
|
||||||
|
isListening: boolean;
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
isSupported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpeechRecognition({ onResult, lang = 'en-US' }: UseSpeechRecognitionOptions): UseSpeechRecognitionReturn {
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const recognitionRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const isSupported = typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSupported) return;
|
||||||
|
|
||||||
|
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||||
|
const recognition = new SpeechRecognition();
|
||||||
|
recognition.continuous = true;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.lang = lang;
|
||||||
|
|
||||||
|
recognition.onresult = (event: any) => {
|
||||||
|
let transcript = '';
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
transcript += event.results[i][0].transcript;
|
||||||
|
}
|
||||||
|
if (event.results[event.resultIndex].isFinal) {
|
||||||
|
onResult(transcript);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = () => {
|
||||||
|
setIsListening(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
setIsListening(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognitionRef.current = recognition;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
recognition.stop();
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
}, [isSupported, lang, onResult]);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
if (!recognitionRef.current || isListening) return;
|
||||||
|
try {
|
||||||
|
recognitionRef.current.start();
|
||||||
|
setIsListening(true);
|
||||||
|
} catch (e) {}
|
||||||
|
}, [isListening]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
if (!recognitionRef.current || !isListening) return;
|
||||||
|
try {
|
||||||
|
recognitionRef.current.stop();
|
||||||
|
setIsListening(false);
|
||||||
|
} catch (e) {}
|
||||||
|
}, [isListening]);
|
||||||
|
|
||||||
|
return { isListening, start, stop, isSupported };
|
||||||
|
}
|
||||||
+6
-3
@@ -6,6 +6,7 @@ export interface AppState {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
fileDate: Date | null;
|
fileDate: Date | null;
|
||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
|
asinColumnIndex: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const COLUMNS = {
|
export const COLUMNS = {
|
||||||
@@ -23,15 +24,17 @@ export const COLUMNS = {
|
|||||||
SHORT_DE: 64,
|
SHORT_DE: 64,
|
||||||
SHORT_EN: 65,
|
SHORT_EN: 65,
|
||||||
RECOMMENDED_AGE: 67,
|
RECOMMENDED_AGE: 67,
|
||||||
CLASSIFICATION: 11, // Column L (index 11)
|
CLASSIFICATION: 11,
|
||||||
ITEM_AVAILABLE: 14, // Column O (index 14)
|
ITEM_AVAILABLE: 14,
|
||||||
MOQ: 27,
|
MOQ: 27,
|
||||||
UNITS_INNER: 31,
|
UNITS_INNER: 31,
|
||||||
UNITS_OUTER: 32,
|
UNITS_OUTER: 32,
|
||||||
|
ASIN: 33,
|
||||||
INNER_W: 42,
|
INNER_W: 42,
|
||||||
INNER_L: 43,
|
INNER_L: 43,
|
||||||
INNER_H: 44,
|
INNER_H: 44,
|
||||||
OUTER_W: 47,
|
OUTER_W: 47,
|
||||||
OUTER_L: 48,
|
OUTER_L: 48,
|
||||||
OUTER_H: 49
|
OUTER_H: 49,
|
||||||
|
VERIFIED_DIMS: 100
|
||||||
};
|
};
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
{ "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" }
|
{ "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" },
|
||||||
|
{ "source": "/api/dropbox-sync", "destination": "api/dropbox-sync.js" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user