mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 14:25:23 +02:00
Compare commits
53
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",
|
||||
|
||||
+210
-81
@@ -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,27 +15,20 @@ 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());
|
||||
|
||||
const handleSignOut = () => {
|
||||
signOut();
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return <LoginPage onLogin={() => setSession(getStoredSession())} />;
|
||||
}
|
||||
|
||||
const [appState, setAppState] = useState<AppState>({
|
||||
headers: [],
|
||||
data: [],
|
||||
fileName: '',
|
||||
fileDate: null,
|
||||
hasUnsavedChanges: false
|
||||
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);
|
||||
@@ -45,72 +38,136 @@ export default function App() {
|
||||
const [isSavingAll, setIsSavingAll] = useState(false);
|
||||
|
||||
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';
|
||||
console.log('[App] session changed:', session ? 'logged in' : 'logged out');
|
||||
}, [session]);
|
||||
|
||||
const handleSignOut = () => {
|
||||
signOut();
|
||||
setSession(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadDefaultData = async () => {
|
||||
setIsLoadingDefault(true);
|
||||
setDefaultLoadError(null);
|
||||
|
||||
try {
|
||||
console.log('Fetching Data-Matrix.xlsx via Vite proxy...');
|
||||
const response = await fetch(fileUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
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}`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
if (arrayBuffer.byteLength < 100) throw new Error('File too small');
|
||||
|
||||
const wb = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
const wsname = wb.SheetNames[0];
|
||||
const ws = wb.Sheets[wsname];
|
||||
allData = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
|
||||
rows = allData.slice(1);
|
||||
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('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 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);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
if (arrayBuffer.byteLength < 100) {
|
||||
throw new Error('File too small — possibly empty or error response');
|
||||
}
|
||||
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 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);
|
||||
|
||||
console.log('Applying Supabase overrides...');
|
||||
const syncedData = await getAllSyncedRows();
|
||||
const articleNoIdx = COLUMNS.ARTICLE_NO;
|
||||
|
||||
const processedRows = rawRows.map(row => {
|
||||
const processedRows = rows.map(row => {
|
||||
const articleNo = String(row[articleNoIdx]);
|
||||
const synced = syncedData[articleNo];
|
||||
const finalRow = synced ? synced.data : row;
|
||||
|
||||
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];
|
||||
}
|
||||
});
|
||||
|
||||
// Sync status_check
|
||||
if (synced && synced.status === 'pending') {
|
||||
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
|
||||
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();
|
||||
|
||||
// Skip Article No, Barcodes, and other code-like fields
|
||||
// But allow if it's a weight/measure column (e.g. Article NW (kg))
|
||||
const header = (headers[idx] || '').toLowerCase();
|
||||
|
||||
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'))) {
|
||||
return val;
|
||||
}
|
||||
|
||||
|
||||
const formatKeywords = ['price', 'eur', 'cost', 'msrp', 'net', 'gross', 'netto', 'brutto', 'pp', 'pph', 'uvp', 'vpe', 'stk', 'nw', 'gw', 'weight', 'kg'];
|
||||
const shouldFormat = formatKeywords.some(kw => header.includes(kw));
|
||||
|
||||
|
||||
if (typeof val === 'number') {
|
||||
return Number(val.toFixed(2));
|
||||
}
|
||||
|
||||
|
||||
if (typeof val === 'string') {
|
||||
const normalized = val.trim().replace(',', '.');
|
||||
const num = parseFloat(normalized);
|
||||
@@ -121,13 +178,18 @@ export default function App() {
|
||||
return val;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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(),
|
||||
hasUnsavedChanges: false
|
||||
hasUnsavedChanges: false,
|
||||
asinColumnIndex: asinIdx !== -1 ? asinIdx : null
|
||||
});
|
||||
setActiveModule('descriptions');
|
||||
}
|
||||
@@ -139,8 +201,8 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
loadDefaultData();
|
||||
}, []);
|
||||
if (session) loadDefaultData();
|
||||
}, [session]);
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -162,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')) {
|
||||
@@ -207,11 +269,12 @@ export default function App() {
|
||||
});
|
||||
|
||||
setAppState({
|
||||
headers: rawHeaders,
|
||||
headers: headers,
|
||||
data: processedRows,
|
||||
fileName: file.name,
|
||||
fileDate: new Date(),
|
||||
hasUnsavedChanges: false
|
||||
hasUnsavedChanges: false,
|
||||
asinColumnIndex: null
|
||||
});
|
||||
setActiveModule('descriptions');
|
||||
}
|
||||
@@ -222,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');
|
||||
@@ -232,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
|
||||
@@ -270,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; });
|
||||
@@ -278,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;
|
||||
for (const [articleNo, { newData, originalData, articleName }] of entries) {
|
||||
const success = await saveRowToSupabase(articleNo, newData);
|
||||
if (success) {
|
||||
// Also save to history
|
||||
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown');
|
||||
let failedArticles: string[] = [];
|
||||
let jwtExpired = false;
|
||||
const token = session?.access_token;
|
||||
|
||||
try {
|
||||
for (const [articleNo, { newData, originalData, articleName }] of entries) {
|
||||
console.log('[handleSaveAll] Saving article:', articleNo);
|
||||
const result = await saveRowToSupabase(articleNo, newData, token);
|
||||
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
|
||||
|
||||
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
|
||||
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
|
||||
if (result.success) {
|
||||
// Also save to history
|
||||
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown', token);
|
||||
|
||||
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
|
||||
setPendingRows(prev => {
|
||||
const n = { ...prev };
|
||||
delete n[articleNo];
|
||||
return n;
|
||||
});
|
||||
} else {
|
||||
setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' }));
|
||||
failedArticles.push(`${articleNo} [${result.error || 'Unknown error'}]`);
|
||||
if (result.error?.includes('401') || result.error?.includes('JWT expired')) {
|
||||
jwtExpired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[handleSaveAll] Finished loop. Failed:', failedArticles.length);
|
||||
|
||||
if (jwtExpired) {
|
||||
alert("Tu sesión ha caducado (JWT expired). Por favor, haz clic en el botón de Cerrar Sesión (Sign out) arriba a la derecha y vuelve a iniciar sesión para guardar tus cambios.");
|
||||
} else if (failedArticles.length > 0) {
|
||||
alert(`Failed to save items:\n\n${failedArticles.join('\n')}\n\nPlease try again.`);
|
||||
} else {
|
||||
setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' }));
|
||||
allSuccess = false;
|
||||
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');
|
||||
}
|
||||
if (allSuccess) setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
|
||||
setIsSavingAll(false);
|
||||
};
|
||||
|
||||
const captureState = (message: string) => {
|
||||
@@ -355,6 +459,14 @@ export default function App() {
|
||||
};
|
||||
}, [appState.data]);
|
||||
|
||||
if (!session) {
|
||||
return <LoginPage onLogin={() => {
|
||||
const stored = getStoredSession();
|
||||
console.log('[App] onLogin, stored session:', stored ? 'found' : 'null');
|
||||
setSession(stored);
|
||||
}} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-[#040d1a] text-slate-200 flex flex-col font-sans overflow-hidden">
|
||||
<TopBar
|
||||
@@ -375,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">
|
||||
@@ -403,6 +515,8 @@ export default function App() {
|
||||
{activeModule === 'descriptions' && (
|
||||
<ProductDescriptions
|
||||
data={appState.data}
|
||||
headers={appState.headers}
|
||||
asinColumnIndex={appState.asinColumnIndex}
|
||||
onEdit={(index) => setEditingRowIndex(index)}
|
||||
rowStatuses={rowStatuses}
|
||||
/>
|
||||
@@ -449,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) {
|
||||
@@ -472,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);
|
||||
@@ -45,7 +45,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
// Search filter
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
result = result.filter(r =>
|
||||
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
@@ -67,11 +67,11 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
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);
|
||||
@@ -139,7 +139,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
|
||||
const getBadge = (val: any, type: 'success' | 'warning' | 'error' | 'info' = 'info') => {
|
||||
if (!val) return <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>;
|
||||
|
||||
|
||||
const styles = {
|
||||
success: "bg-green-500/10 text-green-400 border-green-500/20",
|
||||
warning: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||
@@ -167,8 +167,8 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
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"
|
||||
activeTab === tab.id
|
||||
? "bg-indigo-600 text-white shadow-md"
|
||||
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||
)}
|
||||
>
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -223,29 +231,29 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
|
||||
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
|
||||
].map(({ col, label }) => (
|
||||
<th
|
||||
key={col}
|
||||
<th
|
||||
key={col}
|
||||
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>
|
||||
|
||||
@@ -340,7 +348,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
|
||||
</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} articles</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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';
|
||||
@@ -101,13 +101,15 @@ 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">
|
||||
|
||||
+201
-86
@@ -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 {
|
||||
@@ -13,6 +14,117 @@ interface EditPanelProps {
|
||||
onCaptureState: (message: string) => void;
|
||||
}
|
||||
|
||||
interface DimensionInputProps {
|
||||
label: string;
|
||||
field: string;
|
||||
value: string;
|
||||
isModified: boolean;
|
||||
onChange: (val: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const DimensionInput = ({ label, value, isModified, onChange, placeholder }: DimensionInputProps) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"w-full bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
|
||||
isModified ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface FieldEditorProps {
|
||||
title: string;
|
||||
field: string;
|
||||
value: string;
|
||||
isModified: boolean;
|
||||
canTranslate: boolean;
|
||||
isGenerated: boolean;
|
||||
isLoading: boolean;
|
||||
onGenerate: () => void;
|
||||
onChange: (val: string) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
isListening?: boolean;
|
||||
onToggleVoice?: () => void;
|
||||
voiceSupported?: boolean;
|
||||
}
|
||||
|
||||
const FieldEditor = ({
|
||||
title,
|
||||
field,
|
||||
value,
|
||||
isModified,
|
||||
canTranslate,
|
||||
isGenerated,
|
||||
isLoading,
|
||||
onGenerate,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
isListening,
|
||||
onToggleVoice,
|
||||
voiceSupported
|
||||
}: FieldEditorProps) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-slate-300">{title}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{canTranslate && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-slate-500 italic">
|
||||
<Languages className="w-3 h-3" />
|
||||
Can translate from existing
|
||||
</div>
|
||||
)}
|
||||
{voiceSupported && (
|
||||
<button
|
||||
onClick={onToggleVoice}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded transition-colors",
|
||||
isListening
|
||||
? "bg-red-600/20 text-red-400 animate-pulse"
|
||||
: "bg-slate-700/50 text-slate-400 hover:bg-slate-600 hover:text-white"
|
||||
)}
|
||||
title={isListening ? "Stop recording" : "Voice input"}
|
||||
>
|
||||
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
|
||||
{isListening ? 'Stop' : 'Voice'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1.5 text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-2 py-1 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
||||
{canTranslate ? 'Translate with Gemini' : 'Generate with Gemini'}
|
||||
</button>
|
||||
</div>
|
||||
{isGenerated && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
AI Generated - You can still edit manually
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
id={`field-${field}`}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
"w-full h-32 bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
|
||||
isModified ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||
)}
|
||||
placeholder={`Enter ${title}...`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: EditPanelProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
longDe: row[COLUMNS.LONG_DE] || '',
|
||||
@@ -21,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);
|
||||
@@ -37,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,
|
||||
@@ -56,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) => {
|
||||
@@ -74,7 +197,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
let prompt = '';
|
||||
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${formData.detailsEn || 'N/A'}\nArticle Details (DE): ${formData.detailsDe || 'N/A'}`;
|
||||
|
||||
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. You excel at translating product descriptions between German and English, maintaining the commercial and professional tone while ensuring all technical toy details are accurate. Use clear, engaging language.";
|
||||
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. CRITICAL: Output ONLY the translated or generated content. Do not include any introductions, conclusions, or conversational text. Translate EVERYTHING, including phrases in ALL CAPS (maintain the all-caps casing for those phrases in the translation). Your response must contain only the final product description.";
|
||||
|
||||
if (field === 'longDe') {
|
||||
if (formData.longEn) {
|
||||
@@ -84,6 +207,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
2. Maintain the EXACT same length and detailed information.
|
||||
3. Keep all technical specs intact.
|
||||
4. Translate every single paragraph into natural, commercial German for toy buyers.
|
||||
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
|
||||
|
||||
English Text to Translate:
|
||||
${formData.longEn}`;
|
||||
@@ -98,6 +222,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
2. Maintain the EXACT same length and detailed information.
|
||||
3. Keep all technical specs intact.
|
||||
4. Translate every single paragraph into natural, commercial English for toy buyers.
|
||||
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
|
||||
|
||||
German Text to Translate:
|
||||
${formData.longDe}`;
|
||||
@@ -106,7 +231,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
}
|
||||
} else if (field === 'shortDe') {
|
||||
if (formData.shortEn) {
|
||||
prompt = `Translate exactly this short English product description into professional German for the toy market:\n\n${formData.shortEn}`;
|
||||
prompt = `Translate exactly this short English product description into professional German for the toy market. IMPORTANT: Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting:\n\n${formData.shortEn}`;
|
||||
} else if (formData.longDe) {
|
||||
const targetChars = Math.round(formData.longDe.length * 0.3);
|
||||
prompt = `Create a concise summary of the following German product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longDe}`;
|
||||
@@ -115,7 +240,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
}
|
||||
} else if (field === 'shortEn') {
|
||||
if (formData.shortDe) {
|
||||
prompt = `Translate exactly this short German product description into professional English for the toy market:\n\n${formData.shortDe}`;
|
||||
prompt = `Translate exactly this short German product description into professional English for the toy market. IMPORTANT: Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting:\n\n${formData.shortDe}`;
|
||||
} else if (formData.longEn) {
|
||||
const targetChars = Math.round(formData.longEn.length * 0.3);
|
||||
prompt = `Create a concise summary of the following English product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longEn}`;
|
||||
@@ -163,70 +288,10 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
onSave(rowIndex, newRow);
|
||||
};
|
||||
|
||||
const DimensionInput = ({ label, field, placeholder }: { label: string, field: keyof typeof formData, placeholder?: string }) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData[field]}
|
||||
onChange={e => setFormData(prev => ({ ...prev, [field]: e.target.value }))}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"w-full bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
|
||||
isModified(field) ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const FieldEditor = ({ title, field }: { title: string, field: keyof typeof formData }) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-slate-300">{title}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{((field === 'longDe' && formData.longEn) ||
|
||||
(field === 'longEn' && formData.longDe) ||
|
||||
(field === 'shortDe' && (formData.longDe || formData.shortEn)) ||
|
||||
(field === 'shortEn' && (formData.shortDe || formData.longEn))) && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-slate-500 italic">
|
||||
<Languages className="w-3 h-3" />
|
||||
Can translate from existing
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleGenerate(field)}
|
||||
disabled={loadingField !== null}
|
||||
className="flex items-center gap-1.5 text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-2 py-1 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loadingField === field ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
||||
{((field === 'longDe' && formData.longEn) || (field === 'longEn' && formData.longDe) || (field === 'shortDe' && formData.shortEn) || (field === 'shortEn' && formData.shortDe)) ? 'Translate with Gemini' : 'Generate with Gemini'}
|
||||
</button>
|
||||
</div>
|
||||
{generatedFields.has(field) && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
AI Generated - You can still edit manually
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
id={`field-${String(field)}`}
|
||||
value={formData[field]}
|
||||
onChange={e => setFormData(prev => ({ ...prev, [field]: e.target.value }))}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
className={cn(
|
||||
"w-full h-32 bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
|
||||
isModified(field) ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
|
||||
)}
|
||||
placeholder={`Enter ${title}...`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm z-40" onClick={onClose} />
|
||||
@@ -290,28 +355,78 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-3 text-[10px] text-slate-500 font-medium">INNER BOX (L × W × H) cm</div>
|
||||
<DimensionInput label="Length" field="innerL" placeholder="L" />
|
||||
<DimensionInput label="Width" field="innerW" placeholder="W" />
|
||||
<DimensionInput label="Height" field="innerH" placeholder="H" />
|
||||
<DimensionInput label="Length" value={formData.innerL} field="innerL" isModified={isModified('innerL')} onChange={(val) => setFormData(p => ({...p, innerL: val}))} placeholder="L" />
|
||||
<DimensionInput label="Width" value={formData.innerW} field="innerW" isModified={isModified('innerW')} onChange={(val) => setFormData(p => ({...p, innerW: val}))} placeholder="W" />
|
||||
<DimensionInput label="Height" value={formData.innerH} field="innerH" isModified={isModified('innerH')} onChange={(val) => setFormData(p => ({...p, innerH: val}))} placeholder="H" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-3 text-[10px] text-slate-500 font-medium">OUTER BOX (L × W × H) cm</div>
|
||||
<DimensionInput label="Length" field="outerL" placeholder="L" />
|
||||
<DimensionInput label="Width" field="outerW" placeholder="W" />
|
||||
<DimensionInput label="Height" field="outerH" placeholder="H" />
|
||||
<DimensionInput label="Length" value={formData.outerL} field="outerL" isModified={isModified('outerL')} onChange={(val) => setFormData(p => ({...p, outerL: val}))} placeholder="L" />
|
||||
<DimensionInput label="Width" value={formData.outerW} field="outerW" isModified={isModified('outerW')} onChange={(val) => setFormData(p => ({...p, outerW: val}))} placeholder="W" />
|
||||
<DimensionInput label="Height" value={formData.outerH} field="outerH" isModified={isModified('outerH')} onChange={(val) => setFormData(p => ({...p, outerH: val}))} placeholder="H" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DimensionInput label="Units per Outer" field="unitsOuter" />
|
||||
<DimensionInput label="MOQ" field="moq" />
|
||||
<DimensionInput label="Units per Outer" value={formData.unitsOuter} field="unitsOuter" isModified={isModified('unitsOuter')} onChange={(val) => setFormData(p => ({...p, unitsOuter: val}))} />
|
||||
<DimensionInput label="MOQ" value={formData.moq} field="moq" isModified={isModified('moq')} onChange={(val) => setFormData(p => ({...p, moq: val}))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldEditor title="Long Description (DE)" field="longDe" />
|
||||
<FieldEditor title="Long Description (EN)" field="longEn" />
|
||||
<FieldEditor title="Short Description (DE)" field="shortDe" />
|
||||
<FieldEditor title="Short Description (EN)" field="shortEn" />
|
||||
<FieldEditor
|
||||
title="Long Description (DE)"
|
||||
field="longDe"
|
||||
value={formData.longDe}
|
||||
isModified={isModified('longDe')}
|
||||
canTranslate={!!formData.longEn}
|
||||
isGenerated={generatedFields.has('longDe')}
|
||||
isLoading={loadingField === 'longDe'}
|
||||
onGenerate={() => handleGenerate('longDe')}
|
||||
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
isListening={isListeningDe}
|
||||
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
|
||||
voiceSupported={speechSupported}
|
||||
/>
|
||||
<FieldEditor
|
||||
title="Long Description (EN)"
|
||||
field="longEn"
|
||||
value={formData.longEn}
|
||||
isModified={isModified('longEn')}
|
||||
canTranslate={!!formData.longDe}
|
||||
isGenerated={generatedFields.has('longEn')}
|
||||
isLoading={loadingField === 'longEn'}
|
||||
onGenerate={() => handleGenerate('longEn')}
|
||||
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
isListening={isListeningEn}
|
||||
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
|
||||
voiceSupported={speechSupported}
|
||||
/>
|
||||
<FieldEditor
|
||||
title="Short Description (DE)"
|
||||
field="shortDe"
|
||||
value={formData.shortDe}
|
||||
isModified={isModified('shortDe')}
|
||||
canTranslate={!!formData.shortEn || !!formData.longDe}
|
||||
isGenerated={generatedFields.has('shortDe')}
|
||||
isLoading={loadingField === 'shortDe'}
|
||||
onGenerate={() => handleGenerate('shortDe')}
|
||||
onChange={(val) => setFormData(p => ({...p, shortDe: val}))}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
<FieldEditor
|
||||
title="Short Description (EN)"
|
||||
field="shortEn"
|
||||
value={formData.shortEn}
|
||||
isModified={isModified('shortEn')}
|
||||
canTranslate={!!formData.shortDe || !!formData.longEn}
|
||||
isGenerated={generatedFields.has('shortEn')}
|
||||
isLoading={loadingField === 'shortEn'}
|
||||
onGenerate={() => handleGenerate('shortEn')}
|
||||
onChange={(val) => setFormData(p => ({...p, shortEn: val}))}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500">
|
||||
<Package className="w-4 h-4" />
|
||||
</div>
|
||||
{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 ── */}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
interface ProductDescriptionsProps {
|
||||
data: ExcelRow[];
|
||||
headers: string[];
|
||||
asinColumnIndex: number | null;
|
||||
onEdit: (index: number) => void;
|
||||
rowStatuses: Record<string, string>;
|
||||
}
|
||||
@@ -15,27 +17,28 @@ type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'm
|
||||
// Description columns that should only have Present/Missing filters
|
||||
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN];
|
||||
|
||||
export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescriptionsProps) {
|
||||
export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses }: ProductDescriptionsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [lineFilter, setLineFilter] = useState('');
|
||||
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.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]);
|
||||
@@ -193,7 +196,7 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -243,8 +246,16 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
|
||||
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
|
||||
@@ -284,6 +295,7 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
|
||||
{[
|
||||
{ col: COLUMNS.ARTICLE_NO, label: 'Article No.' },
|
||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
|
||||
...(asinColumnIndex !== null ? [{ col: asinColumnIndex, label: 'ASIN' }] : []),
|
||||
{ col: COLUMNS.LINE, label: 'Line' },
|
||||
{ col: COLUMNS.LICENSE, label: 'License' },
|
||||
{ col: COLUMNS.CLASSIFICATION, label: 'Classification' },
|
||||
@@ -297,20 +309,20 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
|
||||
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"
|
||||
)}
|
||||
>
|
||||
@@ -361,6 +373,9 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-4 py-3 font-medium text-white truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
||||
{asinColumnIndex !== null && (
|
||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[asinColumnIndex] || 100 }} title={row[asinColumnIndex]}>{row[asinColumnIndex] || '—'}</td>
|
||||
)}
|
||||
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LINE] }}>{row[COLUMNS.LINE]}</td>
|
||||
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
||||
<td className="px-4 py-3 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
|
||||
@@ -391,7 +406,7 @@ export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescri
|
||||
})}
|
||||
{paginatedData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-slate-500">
|
||||
<td colSpan={asinColumnIndex !== null ? 10 : 9} className="px-4 py-8 text-center text-slate-500">
|
||||
No products found matching the criteria.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+7
-4
@@ -6,6 +6,7 @@ export interface AppState {
|
||||
fileName: string;
|
||||
fileDate: Date | null;
|
||||
hasUnsavedChanges: boolean;
|
||||
asinColumnIndex: number | null;
|
||||
}
|
||||
|
||||
export const COLUMNS = {
|
||||
@@ -23,15 +24,17 @@ export const COLUMNS = {
|
||||
SHORT_DE: 64,
|
||||
SHORT_EN: 65,
|
||||
RECOMMENDED_AGE: 67,
|
||||
CLASSIFICATION: 11, // Column L (index 11)
|
||||
ITEM_AVAILABLE: 14, // Column O (index 14)
|
||||
CLASSIFICATION: 11,
|
||||
ITEM_AVAILABLE: 14,
|
||||
MOQ: 27,
|
||||
UNITS_INNER: 31,
|
||||
UNITS_OUTER: 32,
|
||||
ASIN: 33,
|
||||
INNER_W: 42,
|
||||
INNER_L: 43,
|
||||
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