mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 17:15:23 +02:00
Compare commits
42
Commits
+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",
|
||||||
|
|||||||
+166
-58
@@ -6,7 +6,7 @@ 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, resetAllPendingRows, saveHistoryEntry } 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';
|
||||||
@@ -15,6 +15,7 @@ import { ArticleDetails } from './components/ArticleDetails';
|
|||||||
import { HistoryView } from './components/HistoryView';
|
import { HistoryView } from './components/HistoryView';
|
||||||
import { UndoToast } from './components/UndoToast';
|
import { UndoToast } from './components/UndoToast';
|
||||||
import { PendingValidationView } from './components/PendingValidationView';
|
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());
|
||||||
@@ -27,7 +28,7 @@ export default function App() {
|
|||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
asinColumnIndex: null
|
asinColumnIndex: null
|
||||||
});
|
});
|
||||||
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history'>('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);
|
||||||
@@ -47,66 +48,113 @@ export default function App() {
|
|||||||
|
|
||||||
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);
|
||||||
if (data.length > 0) {
|
fileMeta = { rev: 'dev', size: arrayBuffer.byteLength };
|
||||||
const rawHeaders = data[0];
|
} else {
|
||||||
const rawRows = data.slice(1);
|
console.log('Fetching file info from Dropbox...');
|
||||||
|
const infoRes = await fetch('/api/dropbox-proxy?info=1');
|
||||||
// Find ASIN column index from headers (case insensitive)
|
if (infoRes.ok) {
|
||||||
const asinIdx = (rawHeaders as string[]).findIndex((h: string) =>
|
fileMeta = await infoRes.json();
|
||||||
String(h).toLowerCase().trim() === 'asin'
|
console.log('File meta:', fileMeta);
|
||||||
);
|
|
||||||
if (asinIdx !== -1) {
|
|
||||||
console.log('ASIN column found at index:', asinIdx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Applying Supabase overrides...');
|
console.log('Fetching Data-Matrix.xlsx from proxy...');
|
||||||
const syncedData = await getAllSyncedRows();
|
const response = await fetch('/api/dropbox-proxy');
|
||||||
const articleNoIdx = COLUMNS.ARTICLE_NO;
|
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 processedRows = rawRows.map(row => {
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const articleNoIdx = COLUMNS.ARTICLE_NO;
|
||||||
|
const processedRows = rows.map(row => {
|
||||||
const articleNo = String(row[articleNoIdx]);
|
const articleNo = String(row[articleNoIdx]);
|
||||||
const synced = syncedData[articleNo];
|
const synced = syncedData[articleNo];
|
||||||
const finalRow = synced ? synced.data : row;
|
|
||||||
|
|
||||||
// Sync status_check
|
const finalRow = [...row];
|
||||||
if (synced && synced.status === 'pending') {
|
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' }));
|
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Format numeric/price fields to 2 decimal places
|
return finalRow.map((val: any, idx: number) => {
|
||||||
return finalRow.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();
|
||||||
|
|
||||||
// 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'))) {
|
||||||
@@ -131,8 +179,12 @@ 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(),
|
||||||
@@ -172,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')) {
|
||||||
@@ -217,7 +269,7 @@ 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(),
|
||||||
@@ -233,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');
|
||||||
@@ -243,7 +302,7 @@ export default function App() {
|
|||||||
|
|
||||||
// 3. Post-export: Reset pending statuses in Supabase
|
// 3. Post-export: Reset pending statuses in Supabase
|
||||||
console.log('Resetting pending statuses in Supabase...');
|
console.log('Resetting pending statuses in Supabase...');
|
||||||
resetAllPendingRows().then(success => {
|
resetAllPendingRows(session?.access_token).then(success => {
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log('Successfully reset all pending statuses');
|
console.log('Successfully reset all pending statuses');
|
||||||
setRowStatuses({}); // Clear local statuses
|
setRowStatuses({}); // Clear local statuses
|
||||||
@@ -281,7 +340,7 @@ export default function App() {
|
|||||||
setAppState(prev => {
|
setAppState(prev => {
|
||||||
const newData = [...prev.data];
|
const newData = [...prev.data];
|
||||||
newData[pending.rowIndex] = pending.originalData;
|
newData[pending.rowIndex] = pending.originalData;
|
||||||
const stillPending = Object.keys(pendingRows).length > 1;
|
const stillPending = Object.keys(pendingRows).length > 0;
|
||||||
return { ...prev, data: newData, hasUnsavedChanges: stillPending };
|
return { ...prev, data: newData, hasUnsavedChanges: stillPending };
|
||||||
});
|
});
|
||||||
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
|
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
|
||||||
@@ -289,25 +348,59 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveAll = async () => {
|
const handleSaveAll = async () => {
|
||||||
|
console.log('[handleSaveAll] Starting save, pendingRows:', pendingRows);
|
||||||
const entries = Object.entries(pendingRows) as [string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }][];
|
const entries = Object.entries(pendingRows) as [string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }][];
|
||||||
if (entries.length === 0) return;
|
if (entries.length === 0) {
|
||||||
|
console.log('[handleSaveAll] No entries to save, returning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSavingAll(true);
|
setIsSavingAll(true);
|
||||||
let allSuccess = true;
|
let failedArticles: string[] = [];
|
||||||
|
let jwtExpired = false;
|
||||||
|
const token = session?.access_token;
|
||||||
|
|
||||||
|
try {
|
||||||
for (const [articleNo, { newData, originalData, articleName }] of entries) {
|
for (const [articleNo, { newData, originalData, articleName }] of entries) {
|
||||||
const success = await saveRowToSupabase(articleNo, newData);
|
console.log('[handleSaveAll] Saving article:', articleNo);
|
||||||
if (success) {
|
const result = await saveRowToSupabase(articleNo, newData, token);
|
||||||
|
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
// Also save to history
|
// Also save to history
|
||||||
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown');
|
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown', token);
|
||||||
|
|
||||||
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
|
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
|
||||||
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
|
setPendingRows(prev => {
|
||||||
|
const n = { ...prev };
|
||||||
|
delete n[articleNo];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' }));
|
setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' }));
|
||||||
allSuccess = false;
|
failedArticles.push(`${articleNo} [${result.error || 'Unknown error'}]`);
|
||||||
|
if (result.error?.includes('401') || result.error?.includes('JWT expired')) {
|
||||||
|
jwtExpired = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (allSuccess) setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
|
}
|
||||||
|
|
||||||
|
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);
|
setIsSavingAll(false);
|
||||||
|
console.log('[handleSaveAll] isSavingAll set to false');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const captureState = (message: string) => {
|
const captureState = (message: string) => {
|
||||||
@@ -394,7 +487,7 @@ export default function App() {
|
|||||||
isSavingAll={isSavingAll}
|
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">
|
||||||
@@ -470,10 +563,21 @@ export default function App() {
|
|||||||
onEdit={(index) => setEditingRowIndex(index)}
|
onEdit={(index) => setEditingRowIndex(index)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeModule === 'missing_data' && (
|
||||||
|
<MissingDataView
|
||||||
|
data={appState.data}
|
||||||
|
headers={appState.headers}
|
||||||
|
onSaveRow={handleSaveRow}
|
||||||
|
onCaptureState={captureState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeModule === 'history' && (
|
{activeModule === 'history' && (
|
||||||
<HistoryView
|
<HistoryView
|
||||||
headers={appState.headers}
|
headers={appState.headers}
|
||||||
onRevert={(articleNo, revertedData) => {
|
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
|
// Find the row in appState.data and update it
|
||||||
const rowIndex = appState.data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === articleNo);
|
const rowIndex = appState.data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === articleNo);
|
||||||
if (rowIndex !== -1) {
|
if (rowIndex !== -1) {
|
||||||
@@ -493,6 +597,10 @@ export default function App() {
|
|||||||
articleName: String(revertedData[COLUMNS.ARTICLE_NAME] || articleNo),
|
articleName: String(revertedData[COLUMNS.ARTICLE_NAME] || articleNo),
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
// Delete the history entry after revert
|
||||||
|
if (historyId) {
|
||||||
|
await deleteHistoryEntry(String(historyId), session?.access_token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 { 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';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
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);
|
||||||
@@ -185,8 +185,16 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
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
|
||||||
@@ -196,7 +204,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
}}
|
}}
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
@@ -228,24 +236,24 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
|||||||
className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
|
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' }}
|
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1 overflow-hidden">
|
<div className="flex items-center overflow-hidden">
|
||||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white truncate" 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 shrink-0" /> : <ChevronUp className="w-3 h-3 shrink-0" />
|
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 shrink-0",
|
"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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Search, Check } from 'lucide-react';
|
import { Search, Check, X } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
interface ColumnFilterPopoverProps {
|
interface ColumnFilterPopoverProps {
|
||||||
@@ -50,9 +50,17 @@ 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">
|
||||||
|
|||||||
@@ -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, Undo2 } 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';
|
||||||
@@ -102,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,
|
||||||
@@ -221,6 +223,16 @@ 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 { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
||||||
@@ -307,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">
|
||||||
@@ -368,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>
|
||||||
@@ -576,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
|
||||||
@@ -613,7 +644,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
<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",
|
||||||
@@ -628,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(
|
||||||
@@ -644,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(
|
||||||
@@ -660,7 +691,7 @@ 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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -49,6 +50,9 @@ interface FieldEditorProps {
|
|||||||
onGenerate: () => void;
|
onGenerate: () => void;
|
||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
|
isListening?: boolean;
|
||||||
|
onToggleVoice?: () => void;
|
||||||
|
voiceSupported?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FieldEditor = ({
|
const FieldEditor = ({
|
||||||
@@ -61,7 +65,10 @@ const FieldEditor = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
onChange,
|
onChange,
|
||||||
onKeyDown
|
onKeyDown,
|
||||||
|
isListening,
|
||||||
|
onToggleVoice,
|
||||||
|
voiceSupported
|
||||||
}: FieldEditorProps) => (
|
}: FieldEditorProps) => (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -73,6 +80,21 @@ const FieldEditor = ({
|
|||||||
Can translate from existing
|
Can translate from existing
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={onGenerate}
|
onClick={onGenerate}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -111,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);
|
||||||
@@ -127,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,
|
||||||
@@ -146,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) => {
|
||||||
@@ -351,6 +384,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
onGenerate={() => handleGenerate('longDe')}
|
onGenerate={() => handleGenerate('longDe')}
|
||||||
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
|
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
|
isListening={isListeningDe}
|
||||||
|
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
|
||||||
|
voiceSupported={speechSupported}
|
||||||
/>
|
/>
|
||||||
<FieldEditor
|
<FieldEditor
|
||||||
title="Long Description (EN)"
|
title="Long Description (EN)"
|
||||||
@@ -363,6 +399,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
|||||||
onGenerate={() => handleGenerate('longEn')}
|
onGenerate={() => handleGenerate('longEn')}
|
||||||
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
|
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
|
isListening={isListeningEn}
|
||||||
|
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
|
||||||
|
voiceSupported={speechSupported}
|
||||||
/>
|
/>
|
||||||
<FieldEditor
|
<FieldEditor
|
||||||
title="Short Description (DE)"
|
title="Short Description (DE)"
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag } from 'lucide-react';
|
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2 } from 'lucide-react';
|
||||||
import { getHistory, HistoryEntry } from '../lib/supabase';
|
import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
import { ExcelRow, COLUMNS } from '../types';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
interface HistoryViewProps {
|
interface HistoryViewProps {
|
||||||
headers: string[];
|
headers: string[];
|
||||||
onRevert: (articleNo: string, oldData: ExcelRow) => void;
|
data: ExcelRow[];
|
||||||
|
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: number) => void;
|
||||||
|
onEdit?: (rowIndex: number) => void;
|
||||||
|
sessionToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: HistoryViewProps) {
|
||||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
@@ -21,11 +24,23 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
|
|
||||||
const loadHistory = async () => {
|
const loadHistory = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getHistory();
|
const data = await getHistory(sessionToken);
|
||||||
setHistory(data);
|
setHistory(data);
|
||||||
setLoading(false);
|
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 getChangedFields = (oldData: ExcelRow, newData: ExcelRow) => {
|
||||||
const changes: { header: string; old: any; new: any; index: number }[] = [];
|
const changes: { header: string; old: any; new: any; index: number }[] = [];
|
||||||
const maxLen = Math.max(oldData.length, newData.length);
|
const maxLen = Math.max(oldData.length, newData.length);
|
||||||
@@ -88,8 +103,16 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
placeholder="Search history..."
|
placeholder="Search history..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-10 pr-4 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"
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={loadHistory}
|
onClick={loadHistory}
|
||||||
@@ -156,10 +179,21 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
|
if (onEdit) {
|
||||||
onRevert(entry.product_id, entry.old_data);
|
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"
|
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" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -170,8 +170,16 @@ export function MatrixView({ data, headers, rowStatuses }: 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
|
||||||
@@ -181,7 +189,7 @@ export function MatrixView({ data, headers, rowStatuses }: 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>
|
||||||
)}
|
)}
|
||||||
@@ -197,8 +205,8 @@ export function MatrixView({ data, headers, rowStatuses }: 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)}
|
||||||
>
|
>
|
||||||
@@ -206,14 +214,14 @@ export function MatrixView({ data, headers, rowStatuses }: 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
import { ExcelRow, COLUMNS } from '../types';
|
||||||
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter } from 'lucide-react';
|
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
interface PendingValidationViewProps {
|
interface PendingValidationViewProps {
|
||||||
@@ -81,8 +81,16 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
|
|||||||
placeholder="Search by article no or name..."
|
placeholder="Search by article no or name..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="bg-slate-800 border border-slate-700 rounded-md pl-9 pr-3 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 w-64"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -282,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 ── */}
|
||||||
|
|||||||
@@ -24,21 +24,21 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
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>>({
|
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
||||||
[COLUMNS.ARTICLE_NO]: 100,
|
[COLUMNS.ARTICLE_NO]: 110,
|
||||||
[COLUMNS.ARTICLE_NAME]: 250,
|
[COLUMNS.ARTICLE_NAME]: 450, // More flexible space for name
|
||||||
[COLUMNS.ASIN]: asinColumnIndex !== null ? 150 : 0,
|
...(asinColumnIndex !== null ? { [asinColumnIndex]: 100 } : {}),
|
||||||
[COLUMNS.LINE]: 80,
|
[COLUMNS.LINE]: 80,
|
||||||
[COLUMNS.LICENSE]: 120,
|
[COLUMNS.LICENSE]: 120,
|
||||||
[COLUMNS.CLASSIFICATION]: 100,
|
[COLUMNS.CLASSIFICATION]: 100,
|
||||||
[COLUMNS.LONG_DE]: 80,
|
[COLUMNS.LONG_DE]: 110,
|
||||||
[COLUMNS.LONG_EN]: 80,
|
[COLUMNS.LONG_EN]: 110,
|
||||||
[COLUMNS.SHORT_DE]: 80,
|
[COLUMNS.SHORT_DE]: 110,
|
||||||
[COLUMNS.SHORT_EN]: 80,
|
[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]);
|
||||||
@@ -196,7 +196,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
};
|
};
|
||||||
|
|
||||||
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();
|
||||||
@@ -246,8 +246,16 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
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
|
||||||
@@ -301,20 +309,20 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
className="px-4 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
|
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' }}
|
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-1 overflow-hidden">
|
<div className="flex items-center overflow-hidden">
|
||||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white truncate" 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 shrink-0" /> : <ChevronUp className="w-4 h-4 shrink-0" />
|
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 shrink-0",
|
"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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -366,7 +374,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
|||||||
<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-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>
|
<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 && (
|
{asinColumnIndex !== null && (
|
||||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[asinColumnIndex] || 150 }} title={row[asinColumnIndex]}>{row[asinColumnIndex] || '—'}</td>
|
<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.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 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FileText, Table, Box, DollarSign, Package, Clock, History } 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' | 'pending_validation' | 'history') => 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 },
|
||||||
{ id: 'pending_validation', label: 'Pending Validation', icon: Clock },
|
{ id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
|
||||||
{ id: 'history', label: 'Change History', icon: History },
|
...(isMasterUser ? [
|
||||||
] as const;
|
{ 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">
|
||||||
|
|||||||
+196
-155
@@ -1,162 +1,19 @@
|
|||||||
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';
|
||||||
|
|
||||||
async function fetchWithRetry(
|
export interface ExcelRow extends Array<any> {}
|
||||||
url: string,
|
|
||||||
options: RequestInit,
|
export interface SyncedRow {
|
||||||
retries = 2,
|
data: ExcelRow;
|
||||||
delayMs = 1000
|
status?: 'pending' | 'synced';
|
||||||
): Promise<Response> {
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, options);
|
|
||||||
if (res.ok || res.status < 500 || attempt === retries) return res;
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err as Error;
|
|
||||||
if (attempt === retries) throw lastError;
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, delayMs));
|
|
||||||
}
|
|
||||||
throw lastError ?? new Error('fetch failed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow): Promise<boolean> {
|
export async function getAllSyncedRows(token?: string): Promise<Record<string, SyncedRow>> {
|
||||||
try {
|
|
||||||
// Single upsert: POST with Prefer=resolution=merge-duplicates
|
|
||||||
// This handles both INSERT (new article) and UPDATE (existing) atomically.
|
|
||||||
// The old PATCH approach silently failed for new articles because Supabase
|
|
||||||
// returns 200 OK with an empty body when no rows match — indistinguishable
|
|
||||||
// from a successful update.
|
|
||||||
const response = await fetchWithRetry(`${SUPABASE_URL}/rest/v1/products_sync`, {
|
|
||||||
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,
|
|
||||||
status_check: 'pending',
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
console.error('Supabase upsert failed:', response.status, errorData);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving to Supabase:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllSyncedRows(): Promise<Record<string, { data: ExcelRow, status: string }>> {
|
|
||||||
try {
|
|
||||||
// Explicit limit to avoid Supabase's default 1000-row cap
|
|
||||||
const response = await fetch(
|
|
||||||
`${SUPABASE_URL}/rest/v1/products_sync?select=id,data,status_check&limit=10000`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'apikey': SUPABASE_KEY,
|
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
|
||||||
'Range': '0-9999'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) return {};
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const result: Record<string, { data: ExcelRow, status: string }> = {};
|
|
||||||
data.forEach((item: any) => {
|
|
||||||
result[item.id] = {
|
|
||||||
data: item.data,
|
|
||||||
status: item.status_check || 'original'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching from Supabase:', error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resetAllPendingRows(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?status_check=eq.pending`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'apikey': SUPABASE_KEY,
|
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
status_check: 'original',
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error resetting statuses in Supabase:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryEntry {
|
|
||||||
id: string;
|
|
||||||
product_id: string;
|
|
||||||
article_name: string;
|
|
||||||
old_data: ExcelRow;
|
|
||||||
new_data: ExcelRow;
|
|
||||||
changed_at: string;
|
|
||||||
changed_by: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveHistoryEntry(
|
|
||||||
articleNo: string,
|
|
||||||
articleName: string,
|
|
||||||
oldData: ExcelRow,
|
|
||||||
newData: ExcelRow,
|
|
||||||
userEmail: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_history`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'apikey': SUPABASE_KEY,
|
|
||||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
product_id: articleNo,
|
|
||||||
article_name: articleName,
|
|
||||||
old_data: oldData,
|
|
||||||
new_data: newData,
|
|
||||||
changed_at: new Date().toISOString(),
|
|
||||||
changed_by: userEmail
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving history to Supabase:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getHistory(): Promise<HistoryEntry[]> {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=100`,
|
`${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}`
|
||||||
@@ -164,10 +21,194 @@ export async function getHistory(): Promise<HistoryEntry[]> {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) return [];
|
if (!response.ok) {
|
||||||
return await response.json();
|
const errText = await response.text();
|
||||||
|
console.error('getAllSyncedRows failed:', response.status, errText);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
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;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching history 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 };
|
||||||
|
}
|
||||||
+2
-1
@@ -35,5 +35,6 @@ export const COLUMNS = {
|
|||||||
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