mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 15:25:24 +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) {
|
||||
const fileUrl =
|
||||
'https://dl.dropboxusercontent.com/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx' +
|
||||
'?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1';
|
||||
let accessToken;
|
||||
try {
|
||||
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 {
|
||||
const upstream = await fetch(fileUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
const upstream = await fetch('https://content.dropboxapi.com/2/files/download', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Dropbox-API-Arg': JSON.stringify({ path: '/CRAZE GmbH/Sales Reports/Data Matrix.xlsx' })
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -18,6 +69,7 @@ export default async function handler(req, res) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(Buffer.from(buffer));
|
||||
} catch (err) {
|
||||
console.error('Dropbox proxy error:', err);
|
||||
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",
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.29.0",
|
||||
"@supabase/supabase-js": "^2.103.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"buzz": "^2.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"dropbox": "^10.34.0",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
@@ -1175,6 +1178,92 @@
|
||||
"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": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
@@ -1549,6 +1638,17 @@
|
||||
"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": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
@@ -1602,6 +1702,15 @@
|
||||
"@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": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||
@@ -1659,6 +1768,12 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"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": {
|
||||
"version": "10.4.27",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||
@@ -1816,6 +1931,12 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1905,6 +2026,18 @@
|
||||
"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": {
|
||||
"version": "0.5.4",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -2025,6 +2167,41 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2112,6 +2289,21 @@
|
||||
"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": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
@@ -2318,6 +2510,22 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"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": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
@@ -2564,6 +2772,21 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -2609,6 +2832,15 @@
|
||||
"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": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
@@ -3709,6 +3941,12 @@
|
||||
"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": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -4367,6 +4605,22 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.29.0",
|
||||
"@supabase/supabase-js": "^2.103.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"buzz": "^2.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"dropbox": "^10.34.0",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
|
||||
+166
-58
@@ -6,7 +6,7 @@ import { TopBar } from './components/TopBar';
|
||||
import { ProductDescriptions } from './components/ProductDescriptions';
|
||||
import { MatrixView } from './components/MatrixView';
|
||||
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 { LoginPage } from './components/LoginPage';
|
||||
import { DimensionsView } from './components/DimensionsView';
|
||||
@@ -15,6 +15,7 @@ import { ArticleDetails } from './components/ArticleDetails';
|
||||
import { HistoryView } from './components/HistoryView';
|
||||
import { UndoToast } from './components/UndoToast';
|
||||
import { PendingValidationView } from './components/PendingValidationView';
|
||||
import { MissingDataView } from './components/MissingDataView';
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
|
||||
@@ -27,7 +28,7 @@ export default function App() {
|
||||
hasUnsavedChanges: false,
|
||||
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 [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
||||
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
|
||||
@@ -47,66 +48,113 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
setDefaultLoadError(null);
|
||||
|
||||
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);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
if (arrayBuffer.byteLength < 100) {
|
||||
throw new Error('File too small — possibly empty or error response');
|
||||
}
|
||||
if (arrayBuffer.byteLength < 100) throw new Error('File too small');
|
||||
|
||||
const wb = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
const wsname = wb.SheetNames[0];
|
||||
const ws = wb.Sheets[wsname];
|
||||
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
|
||||
|
||||
if (data.length > 0) {
|
||||
const rawHeaders = data[0];
|
||||
const rawRows = data.slice(1);
|
||||
|
||||
// Find ASIN column index from headers (case insensitive)
|
||||
const asinIdx = (rawHeaders as string[]).findIndex((h: string) =>
|
||||
String(h).toLowerCase().trim() === 'asin'
|
||||
);
|
||||
if (asinIdx !== -1) {
|
||||
console.log('ASIN column found at index:', asinIdx);
|
||||
allData = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
|
||||
rows = allData.slice(1);
|
||||
fileMeta = { rev: 'dev', size: arrayBuffer.byteLength };
|
||||
} else {
|
||||
console.log('Fetching file info from Dropbox...');
|
||||
const infoRes = await fetch('/api/dropbox-proxy?info=1');
|
||||
if (infoRes.ok) {
|
||||
fileMeta = await infoRes.json();
|
||||
console.log('File meta:', fileMeta);
|
||||
}
|
||||
|
||||
console.log('Applying Supabase overrides...');
|
||||
const syncedData = await getAllSyncedRows();
|
||||
const articleNoIdx = COLUMNS.ARTICLE_NO;
|
||||
console.log('Fetching Data-Matrix.xlsx from proxy...');
|
||||
const response = await fetch('/api/dropbox-proxy');
|
||||
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 synced = syncedData[articleNo];
|
||||
const finalRow = synced ? synced.data : row;
|
||||
|
||||
// Sync status_check
|
||||
if (synced && synced.status === 'pending') {
|
||||
const finalRow = [...row];
|
||||
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' }));
|
||||
}
|
||||
}
|
||||
|
||||
// Format numeric/price fields to 2 decimal places
|
||||
return finalRow.map((val, idx) => {
|
||||
return finalRow.map((val: any, idx: number) => {
|
||||
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') ||
|
||||
header.includes('art.') || header.includes('barcode') || header.includes('article')) &&
|
||||
!(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({
|
||||
headers: rawHeaders,
|
||||
headers: headers,
|
||||
data: processedRows,
|
||||
fileName: 'Data-Matrix.xlsx (Cloud Sync)',
|
||||
fileDate: new Date(),
|
||||
@@ -172,13 +224,13 @@ export default function App() {
|
||||
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
|
||||
|
||||
if (data.length > 0) {
|
||||
const rawHeaders = data[0];
|
||||
const headers = data[0];
|
||||
const rawRows = data.slice(1);
|
||||
|
||||
const processedRows = rawRows.map(row => {
|
||||
return row.map((val, idx) => {
|
||||
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') ||
|
||||
header.includes('art.') || header.includes('barcode') || header.includes('article')) {
|
||||
@@ -217,7 +269,7 @@ export default function App() {
|
||||
});
|
||||
|
||||
setAppState({
|
||||
headers: rawHeaders,
|
||||
headers: headers,
|
||||
data: processedRows,
|
||||
fileName: file.name,
|
||||
fileDate: new Date(),
|
||||
@@ -233,7 +285,14 @@ export default function App() {
|
||||
const handleExport = () => {
|
||||
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 wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Products');
|
||||
@@ -243,7 +302,7 @@ export default function App() {
|
||||
|
||||
// 3. Post-export: Reset pending statuses in Supabase
|
||||
console.log('Resetting pending statuses in Supabase...');
|
||||
resetAllPendingRows().then(success => {
|
||||
resetAllPendingRows(session?.access_token).then(success => {
|
||||
if (success) {
|
||||
console.log('Successfully reset all pending statuses');
|
||||
setRowStatuses({}); // Clear local statuses
|
||||
@@ -281,7 +340,7 @@ export default function App() {
|
||||
setAppState(prev => {
|
||||
const newData = [...prev.data];
|
||||
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 };
|
||||
});
|
||||
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
|
||||
@@ -289,25 +348,59 @@ export default function App() {
|
||||
};
|
||||
|
||||
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 }][];
|
||||
if (entries.length === 0) return;
|
||||
if (entries.length === 0) {
|
||||
console.log('[handleSaveAll] No entries to save, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
const success = await saveRowToSupabase(articleNo, newData);
|
||||
if (success) {
|
||||
console.log('[handleSaveAll] Saving article:', articleNo);
|
||||
const result = await saveRowToSupabase(articleNo, newData, token);
|
||||
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
|
||||
|
||||
if (result.success) {
|
||||
// Also save to history
|
||||
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown');
|
||||
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown', token);
|
||||
|
||||
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
|
||||
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
|
||||
setPendingRows(prev => {
|
||||
const n = { ...prev };
|
||||
delete n[articleNo];
|
||||
return n;
|
||||
});
|
||||
} else {
|
||||
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);
|
||||
console.log('[handleSaveAll] isSavingAll set to false');
|
||||
}
|
||||
};
|
||||
|
||||
const captureState = (message: string) => {
|
||||
@@ -394,7 +487,7 @@ export default function App() {
|
||||
isSavingAll={isSavingAll}
|
||||
/>
|
||||
<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]">
|
||||
{isLoadingDefault ? (
|
||||
<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)}
|
||||
/>
|
||||
)}
|
||||
{activeModule === 'missing_data' && (
|
||||
<MissingDataView
|
||||
data={appState.data}
|
||||
headers={appState.headers}
|
||||
onSaveRow={handleSaveRow}
|
||||
onCaptureState={captureState}
|
||||
/>
|
||||
)}
|
||||
{activeModule === 'history' && (
|
||||
<HistoryView
|
||||
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
|
||||
const rowIndex = appState.data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === articleNo);
|
||||
if (rowIndex !== -1) {
|
||||
@@ -493,6 +597,10 @@ export default function App() {
|
||||
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 { 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 { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
const [lineFilter, setLineFilter] = useState('');
|
||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const [page, setPage] = useState(1);
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
@@ -185,8 +185,16 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
placeholder="Search SKU or Name..."
|
||||
value={search}
|
||||
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>
|
||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||
<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"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
<X className="w-4 h-4" />
|
||||
Clear All Column Filters
|
||||
</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"
|
||||
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 gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
||||
<div className="flex items-center overflow-hidden">
|
||||
<span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
||||
{label}
|
||||
{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
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||
}}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
<Filter className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Search, Check } from 'lucide-react';
|
||||
import { Search, Check, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface ColumnFilterPopoverProps {
|
||||
@@ -50,9 +50,17 @@ export function ColumnFilterPopover({
|
||||
placeholder="Filter values..."
|
||||
value={search}
|
||||
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
|
||||
/>
|
||||
{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 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 { 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 { ConfirmModal } from './ConfirmModal';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
@@ -102,12 +102,14 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
const parts = key.split('x').map(Number);
|
||||
const volume = parts[0] * parts[1] * parts[2];
|
||||
|
||||
const isVerified = rows.some(({ row }) => row[COLUMNS.VERIFIED_DIMS] === true);
|
||||
|
||||
result.push({
|
||||
key,
|
||||
innerDims: key,
|
||||
rows,
|
||||
volume,
|
||||
isInconsistent: !outerMatch || !unitsMatch || !moqMatch,
|
||||
isInconsistent: (!outerMatch || !unitsMatch || !moqMatch) && !isVerified,
|
||||
discrepancies: {
|
||||
outer: !outerMatch,
|
||||
units: !unitsMatch,
|
||||
@@ -221,6 +223,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
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 () => {
|
||||
if (!pendingNearDupSync) return;
|
||||
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
||||
@@ -307,8 +319,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
placeholder="Search SKU or Name in groups..."
|
||||
value={search}
|
||||
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 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"
|
||||
title="Clear all filters"
|
||||
>
|
||||
<XIcon className="w-5 h-5" />
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -576,6 +596,17 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
)}
|
||||
</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) && (
|
||||
<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
|
||||
@@ -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>
|
||||
</td>
|
||||
<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 className={cn(
|
||||
"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" />}
|
||||
</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>
|
||||
</td>
|
||||
<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" />}
|
||||
</button>
|
||||
<span>{row[COLUMNS.UNITS_OUTER] || '-'}</span>
|
||||
<span>{row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? row[COLUMNS.UNITS_OUTER] : '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<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" />}
|
||||
</button>
|
||||
<span>{row[COLUMNS.MOQ] || '-'}</span>
|
||||
<span>{row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? row[COLUMNS.MOQ] : '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<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 { 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 { cn } from '../lib/utils';
|
||||
import { useSpeechRecognition } from '../lib/useSpeechRecognition';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
|
||||
interface EditPanelProps {
|
||||
@@ -49,6 +50,9 @@ interface FieldEditorProps {
|
||||
onGenerate: () => void;
|
||||
onChange: (val: string) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
isListening?: boolean;
|
||||
onToggleVoice?: () => void;
|
||||
voiceSupported?: boolean;
|
||||
}
|
||||
|
||||
const FieldEditor = ({
|
||||
@@ -61,7 +65,10 @@ const FieldEditor = ({
|
||||
isLoading,
|
||||
onGenerate,
|
||||
onChange,
|
||||
onKeyDown
|
||||
onKeyDown,
|
||||
isListening,
|
||||
onToggleVoice,
|
||||
voiceSupported
|
||||
}: FieldEditorProps) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -73,6 +80,21 @@ const FieldEditor = ({
|
||||
Can translate from existing
|
||||
</div>
|
||||
)}
|
||||
{voiceSupported && (
|
||||
<button
|
||||
onClick={onToggleVoice}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded transition-colors",
|
||||
isListening
|
||||
? "bg-red-600/20 text-red-400 animate-pulse"
|
||||
: "bg-slate-700/50 text-slate-400 hover:bg-slate-600 hover:text-white"
|
||||
)}
|
||||
title={isListening ? "Stop recording" : "Voice input"}
|
||||
>
|
||||
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
|
||||
{isListening ? 'Stop' : 'Voice'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={isLoading}
|
||||
@@ -111,14 +133,14 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
shortEn: row[COLUMNS.SHORT_EN] || '',
|
||||
detailsDe: row[COLUMNS.DETAILS_DE] || '',
|
||||
detailsEn: row[COLUMNS.DETAILS_EN] || '',
|
||||
innerW: row[COLUMNS.INNER_W] || '',
|
||||
innerL: row[COLUMNS.INNER_L] || '',
|
||||
innerH: row[COLUMNS.INNER_H] || '',
|
||||
outerW: row[COLUMNS.OUTER_W] || '',
|
||||
outerL: row[COLUMNS.OUTER_L] || '',
|
||||
outerH: row[COLUMNS.OUTER_H] || '',
|
||||
unitsOuter: row[COLUMNS.UNITS_OUTER] || '',
|
||||
moq: row[COLUMNS.MOQ] || '',
|
||||
innerW: row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? String(row[COLUMNS.INNER_W]) : '',
|
||||
innerL: row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? String(row[COLUMNS.INNER_L]) : '',
|
||||
innerH: row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? String(row[COLUMNS.INNER_H]) : '',
|
||||
outerW: row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? String(row[COLUMNS.OUTER_W]) : '',
|
||||
outerL: row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? String(row[COLUMNS.OUTER_L]) : '',
|
||||
outerH: row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? String(row[COLUMNS.OUTER_H]) : '',
|
||||
unitsOuter: row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? String(row[COLUMNS.UNITS_OUTER]) : '',
|
||||
moq: row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? String(row[COLUMNS.MOQ]) : '',
|
||||
});
|
||||
|
||||
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 [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 colMap: Record<string, number> = {
|
||||
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];
|
||||
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) => {
|
||||
@@ -351,6 +384,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
onGenerate={() => handleGenerate('longDe')}
|
||||
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
isListening={isListeningDe}
|
||||
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
|
||||
voiceSupported={speechSupported}
|
||||
/>
|
||||
<FieldEditor
|
||||
title="Long Description (EN)"
|
||||
@@ -363,6 +399,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
onGenerate={() => handleGenerate('longEn')}
|
||||
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
isListening={isListeningEn}
|
||||
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
|
||||
voiceSupported={speechSupported}
|
||||
/>
|
||||
<FieldEditor
|
||||
title="Short Description (DE)"
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag } from 'lucide-react';
|
||||
import { getHistory, HistoryEntry } from '../lib/supabase';
|
||||
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2 } from 'lucide-react';
|
||||
import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface HistoryViewProps {
|
||||
headers: string[];
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
@@ -21,11 +24,23 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
||||
|
||||
const loadHistory = async () => {
|
||||
setLoading(true);
|
||||
const data = await getHistory();
|
||||
const data = await getHistory(sessionToken);
|
||||
setHistory(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleRevert = (entry: HistoryEntry) => {
|
||||
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
|
||||
const currentRow = data.find(r => String(r[0]) === entry.product_id);
|
||||
if (currentRow && JSON.stringify(currentRow) === JSON.stringify(entry.old_data)) {
|
||||
if (!window.confirm("Reverting will restore original data. No actual changes will be made. Continue?")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
onRevert(entry.product_id, entry.old_data, entry.id);
|
||||
}
|
||||
};
|
||||
|
||||
const getChangedFields = (oldData: ExcelRow, newData: ExcelRow) => {
|
||||
const changes: { header: string; old: any; new: any; index: number }[] = [];
|
||||
const maxLen = Math.max(oldData.length, newData.length);
|
||||
@@ -88,8 +103,16 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
||||
placeholder="Search history..."
|
||||
value={search}
|
||||
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>
|
||||
<button
|
||||
onClick={loadHistory}
|
||||
@@ -156,10 +179,21 @@ export function HistoryView({ headers, onRevert }: HistoryViewProps) {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
|
||||
onRevert(entry.product_id, entry.old_data);
|
||||
if (onEdit) {
|
||||
const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
|
||||
if (idx !== -1) onEdit(idx);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors border border-blue-500/20"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRevert(entry);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-orange-500/10 text-orange-400 hover:bg-orange-500/20 transition-colors border border-orange-500/20"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
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 { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
@@ -170,8 +170,16 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
placeholder="Search all columns..."
|
||||
value={search}
|
||||
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>
|
||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||
<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"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
<X className="w-4 h-4" />
|
||||
Clear All Column Filters
|
||||
</button>
|
||||
)}
|
||||
@@ -197,8 +205,8 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
key={index}
|
||||
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
|
||||
<div className="flex items-center overflow-hidden">
|
||||
<span
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-white"
|
||||
onClick={() => handleSort(index)}
|
||||
>
|
||||
@@ -206,14 +214,14 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
{sortCol === index && (
|
||||
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === index ? null : index);
|
||||
}}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 { 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';
|
||||
|
||||
interface PendingValidationViewProps {
|
||||
@@ -81,8 +81,16 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
|
||||
placeholder="Search by article no or name..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-700 rounded-md pl-9 pr-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>
|
||||
|
||||
@@ -282,9 +282,18 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit,
|
||||
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"
|
||||
/>
|
||||
{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">
|
||||
<Package className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Filter tabs ── */}
|
||||
|
||||
@@ -24,21 +24,21 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
const [licenseFilter, setLicenseFilter] = useState('');
|
||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const [page, setPage] = useState(1);
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
||||
[COLUMNS.ARTICLE_NO]: 100,
|
||||
[COLUMNS.ARTICLE_NAME]: 250,
|
||||
[COLUMNS.ASIN]: asinColumnIndex !== null ? 150 : 0,
|
||||
[COLUMNS.ARTICLE_NO]: 110,
|
||||
[COLUMNS.ARTICLE_NAME]: 450, // More flexible space for name
|
||||
...(asinColumnIndex !== null ? { [asinColumnIndex]: 100 } : {}),
|
||||
[COLUMNS.LINE]: 80,
|
||||
[COLUMNS.LICENSE]: 120,
|
||||
[COLUMNS.CLASSIFICATION]: 100,
|
||||
[COLUMNS.LONG_DE]: 80,
|
||||
[COLUMNS.LONG_EN]: 80,
|
||||
[COLUMNS.SHORT_DE]: 80,
|
||||
[COLUMNS.SHORT_EN]: 80,
|
||||
[COLUMNS.LONG_DE]: 110,
|
||||
[COLUMNS.LONG_EN]: 110,
|
||||
[COLUMNS.SHORT_DE]: 110,
|
||||
[COLUMNS.SHORT_EN]: 110,
|
||||
});
|
||||
|
||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||
@@ -196,7 +196,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
|
||||
};
|
||||
|
||||
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
|
||||
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..."
|
||||
value={search}
|
||||
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>
|
||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||
<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"
|
||||
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 gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
||||
<div className="flex items-center overflow-hidden">
|
||||
<span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
||||
{label}
|
||||
{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
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||
}}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
@@ -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-medium text-white truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
||||
{asinColumnIndex !== null && (
|
||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[asinColumnIndex] || 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.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
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';
|
||||
|
||||
interface SidebarProps {
|
||||
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) {
|
||||
const navItems = [
|
||||
export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarProps) {
|
||||
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: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
||||
{ id: 'article_details', label: 'Article Details', icon: Package },
|
||||
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
||||
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
||||
{ id: 'pending_validation', label: 'Pending Validation', icon: Clock },
|
||||
{ id: 'history', label: 'Change History', icon: History },
|
||||
] as const;
|
||||
{ id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
|
||||
...(isMasterUser ? [
|
||||
{ id: 'pending_validation' as ModuleId, label: 'Pending Validation', icon: Clock },
|
||||
{ id: 'history' as ModuleId, label: 'Change History', icon: History }
|
||||
] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<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_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
retries = 2,
|
||||
delayMs = 1000
|
||||
): 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 interface ExcelRow extends Array<any> {}
|
||||
|
||||
export interface SyncedRow {
|
||||
data: ExcelRow;
|
||||
status?: 'pending' | 'synced';
|
||||
}
|
||||
|
||||
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow): Promise<boolean> {
|
||||
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[]> {
|
||||
export async function getAllSyncedRows(token?: string): Promise<Record<string, SyncedRow>> {
|
||||
try {
|
||||
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: {
|
||||
'apikey': SUPABASE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_KEY}`
|
||||
@@ -164,10 +21,194 @@ export async function getHistory(): Promise<HistoryEntry[]> {
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return await response.json();
|
||||
if (!response.ok) {
|
||||
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) {
|
||||
console.error('Error fetching history from Supabase:', error);
|
||||
return [];
|
||||
console.error('Error fetching synced rows from Supabase:', error);
|
||||
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,
|
||||
OUTER_W: 47,
|
||||
OUTER_L: 48,
|
||||
OUTER_H: 49
|
||||
OUTER_H: 49,
|
||||
VERIFIED_DIMS: 100
|
||||
};
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"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